diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 859744c3e75..13c9afc6929 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -740,6 +740,8 @@ jobs: handoff_dir="$(python3 .github/scripts/handoff.py dir comment "$handoff_id" --root "$handoff_root")" mkdir -p "$handoff_dir" marker="" + # Markdown code spans are intentionally literal in these single-quoted strings. + # shellcheck disable=SC2016 { printf '%s\n' "$marker" # Markdown code spans are intentional literal text. diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index fb36fe6b099..339e567954b 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -55,6 +55,11 @@ on: required: false type: string default: "" + amr_profile: + description: "Optional AMR profile baked into the build (e.g. feature-test to point the packaged app at that profile's AMR backend and enable the workspace-team transport). Empty leaves the default (prod)." + required: false + type: string + default: "" mac_arm64_sign_mode: description: "macOS arm64 signing mode." required: true @@ -205,6 +210,10 @@ on: required: false type: string default: "" + amr_profile: + required: false + type: string + default: "" mac_arm64_sign_mode: required: false type: string @@ -307,6 +316,20 @@ env: POSTHOG_HOST: ${{ inputs.publish && vars.POSTHOG_HOST || '' }} POSTHOG_CLI_API_KEY: ${{ inputs.publish && secrets.POSTHOG_CLI_API_KEY || '' }} POSTHOG_CLI_PROJECT_ID: ${{ inputs.publish && vars.POSTHOG_CLI_PROJECT_ID || '' }} + # Vela web console origin for the AMR profile this build targets. Held in a + # per-profile repository secret instead of the source tree: the non-prod AMR + # environments are internal deployments and this repository is public, so + # neither the workflow nor the product code may name their hostnames. + # + # tools-pack bakes it into open-design-config.json and the packaged runtime + # forwards it to the daemon as OD_VELA_WEB_URL, which is the second half of the + # workspace-team gate (the first being the AMR profile allowlist). A profile + # whose secret is not configured therefore ships with workspace-team dormant + # rather than pointed at an unknown backend. `prod` never gets an origin — the + # packaged gate rejects it regardless. + # + # Secrets read here: VELA_WEB_URL_FEATURE_TEST, VELA_WEB_URL_TEST. + OD_VELA_WEB_URL: ${{ inputs.amr_profile == 'feature-test' && secrets.VELA_WEB_URL_FEATURE_TEST || inputs.amr_profile == 'test' && secrets.VELA_WEB_URL_TEST || '' }} RELEASE_BRANCH: ${{ github.ref_name }} RELEASE_REPOSITORY: ${{ github.repository }} RELEASE_RUN_ATTEMPT: ${{ github.run_attempt }} @@ -475,6 +498,13 @@ jobs: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + # Bake the AMR profile (tools-pack reads OPEN_DESIGN_AMR_PROFILE) so a + # feature-test dispatch produces a packaged app that targets that + # profile's AMR backend and enables the workspace-team transport. The + # matching console origin arrives via the workflow-level + # OD_VELA_WEB_URL. Empty = default (prod) — the normal beta pipeline is + # unaffected. + OPEN_DESIGN_AMR_PROFILE: ${{ inputs.amr_profile }} run: | set -euo pipefail mkdir -p "$RUNNER_TEMP/release-build/mac_arm64" @@ -508,6 +538,11 @@ jobs: - name: Retry beta mac_arm64 without restored cache if: ${{ steps.mac_arm64_tools_pack_build.outcome == 'failure' }} + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + OPEN_DESIGN_AMR_PROFILE: ${{ inputs.amr_profile }} run: | set -euo pipefail rm -rf "$RUNNER_TEMP/tools-pack-cache" @@ -540,6 +575,42 @@ jobs: exit "$build_status" fi + - name: Upload mac_arm64 DMG for manual distribution + # Only when NOT publishing to the release feed: this is the downloadable + # artifact for a manual dispatch (e.g. a signed feature-test build handed + # to teammates). The normal publish=true release pipeline is unchanged. + if: ${{ !cancelled() && !inputs.publish }} + uses: actions/upload-artifact@v7 + with: + name: open-design-beta-mac-arm64-dmg + path: ${{ runner.temp }}/tools-pack/out/mac/namespaces/release-beta/**/*.dmg + if-no-files-found: warn + retention-days: 14 + + - name: Upload mac_arm64 dogfood build to release storage + # Companion to the artifact step above, for publish=false only. A GitHub + # artifact needs a logged-in teammate and a zip round-trip; this puts the + # same DMG in R2 under dogfood/// so the link can be + # handed over directly. It writes no channel metadata and no latest + # pointer (enforced by the guard in tools/release/src/storage/dogfood.ts), + # so it cannot change what an installed client sees as an available + # update. The publish=true pipeline is untouched. + if: ${{ !cancelled() && !inputs.publish }} + continue-on-error: true + env: + DOGFOOD_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }} + DOGFOOD_BUILD_JSON_KEYS: dmgPath + DOGFOOD_BUILD_JSON_PATH: ${{ runner.temp }}/release-build/mac_arm64/build.json + DOGFOOD_LABEL: macOS arm64 beta ${{ needs.metadata.outputs.beta_version }} + DOGFOOD_VERSION: ${{ needs.metadata.outputs.beta_version }} + RELEASE_PUBLIC_ORIGIN: ${{ inputs.release_public_origin != '' && inputs.release_public_origin || vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + RELEASE_STORAGE_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }} + RELEASE_STORAGE_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }} + RELEASE_STORAGE_ENDPOINT: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }} + RELEASE_STORAGE_REGION: auto + RELEASE_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }} + run: pnpm exec tools-release publish-dogfood + - name: Delete failed mac_arm64 tools-pack cache if: ${{ steps.mac_arm64_tools_pack_build.outcome == 'failure' && steps.mac_arm64_tools_pack_cache_restore.outputs.cache-matched-key != '' }} continue-on-error: true @@ -1054,6 +1125,14 @@ jobs: OD_BETA_WINDOWS_SIGN_CERT_SHA1: ${{ steps.sign_probe.outputs.thumbprint }} OD_PACKAGED_E2E_WIN_UPDATE_METADATA_URL: ${{ inputs.win_x64_update_metadata_url }} OD_PACKAGED_E2E_WIN_UPDATE_VERSION: ${{ inputs.win_x64_update_target_version }} + # Bake the AMR profile (tools-pack reads OPEN_DESIGN_AMR_PROFILE) so a + # feature-test dispatch produces a packaged app that targets that + # profile's AMR backend and enables the workspace-team transport. Empty + # = default (prod) — the normal beta pipeline is unaffected. Mirrors the + # mac_arm64 build step above; this was missing here, so every win_x64 + # dogfood build silently fell back to prod and could not sign in to a + # feature-test AMR environment. + OPEN_DESIGN_AMR_PROFILE: ${{ inputs.amr_profile }} run: | $ErrorActionPreference = "Stop" New-Item -ItemType Directory -Force -Path "${{ runner.temp }}\release-build\win_x64" | Out-Null @@ -1070,6 +1149,8 @@ jobs: id: win_tools_pack_build_retry if: ${{ steps.win_tools_pack_build.outcome == 'failure' }} shell: pwsh + env: + OPEN_DESIGN_AMR_PROFILE: ${{ inputs.amr_profile }} run: | $ErrorActionPreference = "Stop" Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "${{ runner.temp }}\tools-pack-cache" @@ -1083,6 +1164,45 @@ jobs: $build = $buildOutput | ConvertFrom-Json pnpm.cmd exec tools-pack win validate-payload --namespace release-beta-win --payload-path $build.payloadPath --expected-version "${{ needs.metadata.outputs.beta_version }}" --json + - name: Upload win_x64 installer for manual distribution + # Only when NOT publishing to the release feed, mirroring the mac DMG + # step. Before this existed a publish=false dispatch left the Windows job + # with nothing retrievable at all, so a QA/dogfood build could not be + # handed to a teammate without also publishing to the public feed. The + # glob covers every win_x64_target: nsis produces the setup exe, zip the + # portable zip, all both. The normal publish=true pipeline is unchanged. + if: ${{ !cancelled() && !inputs.publish }} + uses: actions/upload-artifact@v7 + with: + name: open-design-beta-win-x64-installer + path: | + ${{ runner.temp }}\tools-pack\out\win\namespaces\release-beta-win\builder\*-setup.exe + ${{ runner.temp }}\tools-pack\out\win\namespaces\release-beta-win\builder\*-portable.zip + if-no-files-found: warn + retention-days: 14 + + - name: Upload win_x64 dogfood build to release storage + # publish=false only. Artifact paths come from the build's own --json + # output rather than a hardcoded layout, so whichever of installerPath / + # portableZipPath the selected win_x64_target actually produced is what + # gets uploaded. Writes under dogfood/// and nowhere + # else; see the guard in tools/release/src/storage/dogfood.ts. + if: ${{ !cancelled() && !inputs.publish }} + continue-on-error: true + env: + DOGFOOD_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }} + DOGFOOD_BUILD_JSON_KEYS: installerPath,portableZipPath + DOGFOOD_BUILD_JSON_PATH: ${{ runner.temp }}\release-build\win_x64\build.json + DOGFOOD_LABEL: Windows x64 beta ${{ needs.metadata.outputs.beta_version }} + DOGFOOD_VERSION: ${{ needs.metadata.outputs.beta_version }} + RELEASE_PUBLIC_ORIGIN: ${{ inputs.release_public_origin != '' && inputs.release_public_origin || vars.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + RELEASE_STORAGE_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }} + RELEASE_STORAGE_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }} + RELEASE_STORAGE_ENDPOINT: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }} + RELEASE_STORAGE_REGION: auto + RELEASE_STORAGE_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }} + run: pnpm exec tools-release publish-dogfood + - name: Delete failed Windows tools-pack cache if: ${{ steps.win_tools_pack_build.outcome == 'failure' && steps.win_tools_pack_cache_restore.outputs.cache-matched-key != '' }} shell: pwsh diff --git a/.github/workflows/release-branch-direct-pr-guard.yml b/.github/workflows/release-branch-direct-pr-guard.yml index 9b64ec6a3cd..b2bee785f32 100644 --- a/.github/workflows/release-branch-direct-pr-guard.yml +++ b/.github/workflows/release-branch-direct-pr-guard.yml @@ -51,11 +51,11 @@ jobs: body="$(printf '%s\n' \ '🚫 Direct PRs into release branches are not accepted.' \ '' \ - 'Changes reach a release branch through the **backport flow**, not by targeting `release/*` directly:' \ + "Changes reach a release branch through the **backport flow**, not by targeting \`release/*\` directly:" \ '' \ - '1. Open your PR against **`main`** and get it merged there.' \ - '2. Add the **`backport release/vX.Y.Z`** label to that main PR — the release bot cherry-picks it onto the release branch automatically (resolving conflicts in a draft if needed).' \ + "1. Open your PR against **\`main\`** and get it merged there." \ + "2. Add the **\`backport release/vX.Y.Z\`** label to that main PR — the release bot cherry-picks it onto the release branch automatically (resolving conflicts in a draft if needed)." \ '' \ - 'Closing this PR. If this is a genuine release-only fix that cannot go through `main`, ask a maintainer to handle it directly.')" + "Closing this PR. If this is a genuine release-only fix that cannot go through \`main\`, ask a maintainer to handle it directly.")" gh pr comment "$PR" --repo "$REPO" --body "$body" gh pr close "$PR" --repo "$REPO" diff --git a/apps/daemon/AGENTS.md b/apps/daemon/AGENTS.md index 92cc0135223..c3e8b2aa291 100644 --- a/apps/daemon/AGENTS.md +++ b/apps/daemon/AGENTS.md @@ -25,6 +25,7 @@ The daemon is not a shared library for the web app. Do not import daemon private - `src/runtimes/` owns agent runtime definitions, spawning, parser integration, executable discovery, and runtime environment shaping. Agent argument definitions belong in `src/runtimes/defs/`. - `src/prompts/` owns daemon-side prompt construction. Keep mirrored BYOK/API wording in `packages/contracts/src/prompts/` when the same text is exposed outside the daemon. - `src/plugins/`, `src/connectors/`, `src/registry/`, `src/research/`, `src/media-adapters/`, `src/live-artifacts/`, `src/storage/`, and `src/critique/` own their named domains. Prefer adding code inside the existing domain folder before creating a new top-level folder. +- Team resource storage is Vela-owned. Daemon adapters under `src/collab/vela-cli-*` must invoke Vela through `src/integrations/vela-command.ts`, which shares the login/agent binary resolver and environment. Do not add Resource Hub tokens, direct HTTP clients, or a second content-addressed drive implementation to Open Design. `od resource` is only a thin Vela CLI compatibility entry point. - `tests/` contains daemon tests. Keep test paths roughly parallel to `src/` when useful. Do not edit generated `dist/` output. diff --git a/apps/daemon/package.json b/apps/daemon/package.json index e9fb53d44c7..4ff590f4309 100644 --- a/apps/daemon/package.json +++ b/apps/daemon/package.json @@ -1,6 +1,6 @@ { "name": "@open-design/daemon", - "version": "0.16.1", + "version": "0.16.2", "private": true, "type": "module", "main": "./dist/cli.js", diff --git a/apps/daemon/src/agent-protocol/acp/json.ts b/apps/daemon/src/agent-protocol/acp/json.ts index ab0aa3d2ab0..d59354ca099 100644 --- a/apps/daemon/src/agent-protocol/acp/json.ts +++ b/apps/daemon/src/agent-protocol/acp/json.ts @@ -132,3 +132,16 @@ export function extractAcpUpdateText(update: JsonObject): string | null { } return null; } + +/** + * Pull a short human-readable status detail (`message` / `detail`) off an ACP + * `session/update`, so a status event can carry e.g. a "compacting context" + * reason. Returns `undefined` when neither field is a non-empty string. + */ +export function extractAcpStatusDetail(update: JsonObject): string | undefined { + for (const key of ['message', 'detail']) { + const value = update[key]; + if (typeof value === 'string' && value.trim()) return value; + } + return undefined; +} diff --git a/apps/daemon/src/agent-protocol/acp/rpc.ts b/apps/daemon/src/agent-protocol/acp/rpc.ts index 6a36777eb6b..2da3486cd42 100644 --- a/apps/daemon/src/agent-protocol/acp/rpc.ts +++ b/apps/daemon/src/agent-protocol/acp/rpc.ts @@ -87,6 +87,27 @@ export function rpcErrorRetryable(data: unknown): boolean | undefined { const details = asObject(data); return typeof details?.retryable === 'boolean' ? details.retryable : undefined; } +/** + * Fallback retryability inference from the error message/details text, used when + * the runtime does not set an explicit `retryable` field. `request_too_large` + * (the prompt must shrink) is non-retryable; upstream transport blips + * (`stream idle timeout`, `overloaded`, gateway/service outages) are retryable. + * Returns `undefined` when nothing matches so callers keep their own default. + */ +export function inferRpcErrorRetryable(message: string, data: unknown): boolean | undefined { + const details = asObject(data); + const text = [ + message, + details ? JSON.stringify(details) : '', + ].join('\n'); + if (/\b(request_too_large|request body exceeds configured limit)\b/i.test(text)) { + return false; + } + if (/\b(upstream_error|stream idle timeout|no data received within configured window|temporarily unavailable|overloaded|gateway timeout|service unavailable)\b/i.test(text)) { + return true; + } + return undefined; +} /** * Promotes an opencode `ROLE_MARKER_HALLUCINATION` error embedded in an ACP * JSON-RPC `error.data` payload into a canonical Open Design error object. diff --git a/apps/daemon/src/agent-protocol/acp/session.ts b/apps/daemon/src/agent-protocol/acp/session.ts index 077c62454fe..7a8babc6a75 100644 --- a/apps/daemon/src/agent-protocol/acp/session.ts +++ b/apps/daemon/src/agent-protocol/acp/session.ts @@ -22,7 +22,7 @@ import { ACP_RAW_EVENT_SHAPE_DIAGNOSTIC_LIMIT, AMR_STDERR_RETRY_TAIL_LIMIT, } from './constants.js'; -import { errorMessage, asObject, extractAcpUpdateText } from './json.js'; +import { errorMessage, asObject, extractAcpUpdateText, extractAcpStatusDetail } from './json.js'; import { sendRpc, sendRpcResult, @@ -30,6 +30,7 @@ import { rpcErrorMessage, rpcErrorData, rpcErrorRetryable, + inferRpcErrorRetryable, promotedOpenCodeSessionErrorPayload, formatUsage, choosePermissionOutcome, @@ -92,9 +93,11 @@ export interface AttachAcpSessionOptions { // `onCliReady` fires once on the first well-formed ACP JSON-RPC message // (the CLI is up and speaking the protocol); `onSessionInit` fires once when // the `session/new` handshake is acknowledged (a session id is established). - // Both are best-effort and the caller dedupes, so extra calls are harmless. + // `onPromptComplete` fires once when a clean `session/prompt` result is + // accepted. Error paths never invoke it. onCliReady?: () => void; onSessionInit?: () => void; + onPromptComplete?: () => void; } /** * Attaches an ACP protocol session to an already-spawned child process and @@ -139,6 +142,7 @@ export function attachAcpSession({ resumeSessionId, onCliReady, onSessionInit, + onPromptComplete, }: AttachAcpSessionOptions) { const runStartedAt = Date.now(); const effectiveCwd = path.resolve(cwd || process.cwd()); @@ -511,6 +515,10 @@ export function attachAcpSession({ const finishCleanPrompt = (usageSource?: unknown) => { if (finished) return; + // Mark the prompt finished before notifying observers so duplicate results + // and callback re-entry cannot report clean completion more than once. + finished = true; + onPromptComplete?.(); // Flush any tools still open when the prompt completes so traces stay // complete (one tool_use + tool_result per id). flushOpenAcpTools(); @@ -524,7 +532,6 @@ export function attachAcpSession({ emitToolCallTextSuppressionSummary(); emitArtifactTextSuppressionSummary(); emitUsageIfPresent(usageSource); - finished = true; clearStageTimer(); stdin.end(); // Some ACP agents keep the child process alive after stdin closes, @@ -607,7 +614,7 @@ export function attachAcpSession({ failWithPayload(promotedPayload); return; } - const retryable = rpcErrorRetryable(details); + const retryable = rpcErrorRetryable(details) ?? inferRpcErrorRetryable(rpcErr, details); fail(rpcErr, { details, ...(retryable === undefined ? {} : { retryable }), @@ -628,9 +635,11 @@ export function attachAcpSession({ } } if (update.sessionUpdate !== 'agent_message_chunk' && update.sessionUpdate !== 'agent_thought_chunk') { + const detail = extractAcpStatusDetail(update); send('agent', { type: 'status', label: String(update.sessionUpdate || 'session_update'), + ...(detail ? { detail } : {}), elapsedMs: Date.now() - runStartedAt, }); emitAcpRawShapeDiagnostic(update); diff --git a/apps/daemon/src/app-config.ts b/apps/daemon/src/app-config.ts index 0c2c8356dfc..1eabaa970c7 100644 --- a/apps/daemon/src/app-config.ts +++ b/apps/daemon/src/app-config.ts @@ -93,6 +93,10 @@ export interface OrbitConfigPrefs { enabled: boolean; time: string; templateSkillId?: string | null; + workspaceScope?: { + workspaceId: string; + workspaceMemberId: string; + } | null; } export interface ProjectLocationPrefs { @@ -320,6 +324,23 @@ function validateOrbit(raw: unknown): OrbitConfigPrefs | undefined { ? obj.templateSkillId.trim() : null; } + if (Object.hasOwn(obj, 'workspaceScope')) { + const rawScope = obj.workspaceScope; + if (rawScope && typeof rawScope === 'object' && !Array.isArray(rawScope)) { + const workspaceId = + typeof (rawScope as Record).workspaceId === 'string' + ? ((rawScope as Record).workspaceId as string).trim() + : ''; + const workspaceMemberId = + typeof (rawScope as Record).workspaceMemberId === 'string' + ? ((rawScope as Record).workspaceMemberId as string).trim() + : ''; + orbit.workspaceScope = + workspaceId && workspaceMemberId ? { workspaceId, workspaceMemberId } : null; + } else { + orbit.workspaceScope = null; + } + } return orbit; } @@ -580,6 +601,19 @@ function applyConfigValue( if (key === 'orbit') { const validated = validateOrbit(value); if (validated !== undefined) { + const existingOrbit = target[key] as OrbitConfigPrefs | undefined; + if ( + value + && typeof value === 'object' + && !Array.isArray(value) + && !Object.hasOwn(value, 'workspaceScope') + && existingOrbit?.workspaceScope + ) { + // Older clients do not know this field. Editing Orbit time/enabled + // must not silently convert an already-scoped unattended automation + // back into an ambient/unbound one. An explicit null still clears it. + validated.workspaceScope = existingOrbit.workspaceScope; + } target[key] = validated; } else { delete target[key]; diff --git a/apps/daemon/src/automations/workspace-scope.ts b/apps/daemon/src/automations/workspace-scope.ts new file mode 100644 index 00000000000..cd6051e7b21 --- /dev/null +++ b/apps/daemon/src/automations/workspace-scope.ts @@ -0,0 +1,164 @@ +import type { + WorkspaceCollabContext, + WorkspaceDirectoryItem, +} from '@open-design/contracts'; +import type { WorkspaceDirectoryFetchResult } from '../collab/vela-workspace-context.js'; +import { workspaceContextFromDirectoryItem } from '../collab/vela-workspace-context.js'; + +export interface PersistedAutomationWorkspaceScope { + workspaceId: string; + workspaceMemberId: string; +} + +export class AutomationWorkspaceScopeError extends Error { + constructor( + readonly code: + | 'WORKSPACE_AUTHORITY_UNAVAILABLE' + | 'WORKSPACE_ACCESS_DENIED' + | 'WORKSPACE_PROJECT_PERMISSION_DENIED', + message: string, + readonly retryable: boolean, + ) { + super(message); + this.name = 'AutomationWorkspaceScopeError'; + } +} + +export function normalizePersistedAutomationWorkspaceScope( + value: unknown, +): PersistedAutomationWorkspaceScope | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const raw = value as Record; + const workspaceId = typeof raw.workspaceId === 'string' ? raw.workspaceId.trim() : ''; + const workspaceMemberId = + typeof raw.workspaceMemberId === 'string' ? raw.workspaceMemberId.trim() : ''; + return workspaceId && workspaceMemberId ? { workspaceId, workspaceMemberId } : null; +} + +/** + * Persist an automation's exact Workspace billing address on its new project. + * + * This is intentionally not an authorization check. The local membership + * directory is UI/collaboration state and can be stale or temporarily + * unavailable; it must never select a different wallet. At AMR spawn time the + * daemon sends this persisted Workspace id together with the signed-in account + * credentials, and the Vela backend remains the final membership, permission, + * and billing authority. + */ +export function bindProjectToPersistedAutomationWorkspace( + ensureWorkspaceProject: (input: { + projectId: string; + workspaceId: string; + visibility: 'personal'; + resourceState: 'active'; + createdByWorkspaceMemberId: string; + updatedByWorkspaceMemberId: string; + syncState: 'local_only'; + resourceHubResourceId: null; + cloudTombstonedAt: null; + createdAt: number; + updatedAt: number; + }) => unknown, + scope: PersistedAutomationWorkspaceScope | null, + projectId: string, + now: number, +): void { + if (!scope) return; + ensureWorkspaceProject({ + projectId, + workspaceId: scope.workspaceId, + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: scope.workspaceMemberId, + updatedByWorkspaceMemberId: scope.workspaceMemberId, + syncState: 'local_only', + resourceHubResourceId: null, + cloudTombstonedAt: null, + createdAt: now, + updatedAt: now, + }); +} + +async function fetchDirectoryOrThrow( + fetchWorkspaceDirectory: (() => Promise) | undefined, +): Promise { + if (!fetchWorkspaceDirectory) { + throw new AutomationWorkspaceScopeError( + 'WORKSPACE_AUTHORITY_UNAVAILABLE', + 'workspace membership authority is not configured', + true, + ); + } + let directory: WorkspaceDirectoryFetchResult; + try { + directory = await fetchWorkspaceDirectory(); + } catch { + directory = { ok: false, items: [] }; + } + if (!directory.ok) { + throw new AutomationWorkspaceScopeError( + 'WORKSPACE_AUTHORITY_UNAVAILABLE', + 'workspace membership authority is temporarily unavailable', + true, + ); + } + return directory.items; +} + +function activeWritableContext( + context: WorkspaceCollabContext | null, +): WorkspaceCollabContext | null { + return context + && context.memberStatus === 'active' + && context.lifecycleState === 'active' + && context.permissions.canWriteSyncedFiles + ? context + : null; +} + +/** + * Re-authorize a Workspace/member pair captured when an unattended automation + * was configured. No daemon-global current/active Workspace participates. + */ +export async function authorizePersistedAutomationWorkspaceScope( + scope: PersistedAutomationWorkspaceScope, + fetchWorkspaceDirectory: (() => Promise) | undefined, +): Promise { + const items = await fetchDirectoryOrThrow(fetchWorkspaceDirectory); + const item = items.find( + (candidate) => + candidate.workspaceId === scope.workspaceId + && candidate.workspaceMemberId === scope.workspaceMemberId, + ); + const context = activeWritableContext(item ? workspaceContextFromDirectoryItem(item) : null); + if (!context) { + throw new AutomationWorkspaceScopeError( + 'WORKSPACE_ACCESS_DENIED', + 'the automation Workspace is no longer writable by this member', + false, + ); + } + return context; +} + +/** + * Resolve a reused project's persisted binding. The project row chooses the + * Workspace; the signed-in directory supplies the current member and authority. + */ +export async function authorizePersistedProjectWorkspace( + workspaceIdInput: string, + fetchWorkspaceDirectory: (() => Promise) | undefined, +): Promise { + const workspaceId = workspaceIdInput.trim(); + const items = await fetchDirectoryOrThrow(fetchWorkspaceDirectory); + const item = items.find((candidate) => candidate.workspaceId === workspaceId); + const context = activeWritableContext(item ? workspaceContextFromDirectoryItem(item) : null); + if (!context) { + throw new AutomationWorkspaceScopeError( + 'WORKSPACE_PROJECT_PERMISSION_DENIED', + 'the reused project Workspace is no longer writable by this member', + false, + ); + } + return context; +} diff --git a/apps/daemon/src/brand-routes.ts b/apps/daemon/src/brand-routes.ts index d7827bccaf0..16dd9086e59 100644 --- a/apps/daemon/src/brand-routes.ts +++ b/apps/daemon/src/brand-routes.ts @@ -16,6 +16,7 @@ import path from 'node:path'; import type { Application, Request, Response } from 'express'; import { + ensureWorkspaceProject, getProject, listFirstConversationRunStatuses, listConversationsAwaitingInput, @@ -25,6 +26,7 @@ import { listProjectsAwaitingInput, type insertProject, } from './db.js'; +import type { CreatedProjectWorkspaceResolver } from './collab/created-project-workspace.js'; import { resolveProjectDir } from './projects.js'; import { continueBrandExtraction, @@ -49,8 +51,18 @@ export interface BrandRoutesDeps { * `user:` design system, so selecting a brand in the composer reuses * the existing design-system apply flow. */ userDesignSystemsRoot: string; + /** The workspace an extracted brand's design system should be claimed by + * (#145), resolved from the exact request's explicit identity. */ + resolveDesignSystemWorkspaceId?: (req: Request) => Promise; /** `/projects` — backing brand-extraction projects. */ projectsRoot: string; + /** + * Where a brand-extraction project belongs. Verifies an asserted workspace + * identity, then degrades to the daemon's own signed-in workspace rather than + * refusing — see `createdProjectWorkspaceHome` in + * `collab/created-project-workspace.ts`. + */ + resolveCreatedProjectHome?: CreatedProjectWorkspaceResolver; /** Skills root — the agent-driven kit page is rendered from the bundled * `brand-extract` template under here. */ skillsRoot: string; @@ -97,6 +109,72 @@ type ProgrammaticExtractionAbortResult = 'none' | 'settled' | 'timeout'; export function registerBrandRoutes(app: Application, deps: BrandRoutesDeps): void { const { brandsRoot, userDesignSystemsRoot, projectsRoot, skillsRoot, dataDir, db, randomId } = deps; const activeProgrammaticBrandExtractions = new Map(); + const sendWorkspaceScopeError = (res: Response, error: unknown): boolean => { + if ( + !error + || typeof error !== 'object' + || !('status' in error) + || (error.status !== 400 && error.status !== 403 && error.status !== 503) + || !('code' in error) + || typeof error.code !== 'string' + ) { + return false; + } + res.status(error.status).json({ + error: error.code, + message: error instanceof Error ? error.message : String(error.code), + ...('retryable' in error && error.retryable === true ? { retryable: true } : {}), + }); + return true; + }; + + /** + * Bind a freshly started brand-extraction project into the SAME workspace + * the request that created it is acting in — mirroring `POST /api/projects` + * (`routes/project/index.ts`'s `workspaceIdForCreate` handling) and + * `bindDuplicateIntoRequestWorkspace` (the same file's duplicate/design- + * system-copy routes). + * + * `startBrandExtraction` (`brands/index.ts`) inserts its backing project row + * directly and has never called `ensureWorkspaceProject`: the function takes + * a plain options object, not an Express `Request`, so it has no workspace + * headers to bind against, and nothing downstream filled the gap. A project + * with no `workspace_projects` row is not a harmless default — as of the + * POST /api/runs and POST /api/chat workspace-identity gate + * (`enforceWorkspaceResourceMutation`, resourceType 'project'), an unbound + * project is UNCONDITIONALLY denied a run whenever the caller's client + * carries any workspace headers at all (`row === null` short-circuits to + * `canMutate: false` regardless of who created it), not merely "ungated + * either way" as pre-existing call sites assumed. A team member who just + * asked the composer to extract a brand/design system could never get the + * agent to write anything into it — including the very `assets/` write + * spec 04 §9.3's asset sync depends on — because their own first chat turn + * 403s before the agent ever runs. + * + * Scoped to an ACTIVE member only, matching `POST /api/projects`. A + * headerless legacy request remains unbound. An asserted but invalid or + * unavailable identity fails before extraction creates any local state. + */ + function bindBrandProjectIntoRequestWorkspace( + ctx: Awaited>, + projectId: string, + now: number, + ): void { + if (!ctx || ctx.memberStatus !== 'active') return; + ensureWorkspaceProject(db, { + projectId, + workspaceId: ctx.workspaceId, + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: ctx.workspaceMemberId, + updatedByWorkspaceMemberId: ctx.workspaceMemberId, + syncState: 'local_only', + resourceHubResourceId: null, + cloudTombstonedAt: null, + createdAt: now, + updatedAt: now, + }); + } function trackProgrammaticBrandExtraction( brandId: string, @@ -227,6 +305,11 @@ export function registerBrandRoutes(app: Application, deps: BrandRoutesDeps): vo return; } try { + // Resolve before startBrandExtraction reserves a brand/project. An + // explicitly scoped request must never degrade to an unbound artifact. + const createHome = deps.resolveCreatedProjectHome + ? await deps.resolveCreatedProjectHome(req) + : null; const programmaticAbortController = new AbortController(); const backgroundExtractionRef: { current: Promise | null } = { current: null }; const startOptions: Parameters[0] = { @@ -239,6 +322,7 @@ export function registerBrandRoutes(app: Application, deps: BrandRoutesDeps): vo // returning, then harvests + synthesizes + finalizes the design system // in the background. userDesignSystemsRoot, + designSystemWorkspaceId: (await deps.resolveDesignSystemWorkspaceId?.(req)) ?? null, dataDir, programmaticAbortSignal: programmaticAbortController.signal, onBackgroundExtraction: (settled) => { @@ -256,10 +340,12 @@ export function registerBrandRoutes(app: Application, deps: BrandRoutesDeps): vo const transcriptAgent = await deps.resolveTranscriptAgent?.().catch(() => null); if (transcriptAgent) startOptions.transcriptAgent = transcriptAgent; const result = await startBrandExtraction(startOptions); + bindBrandProjectIntoRequestWorkspace(createHome, result.projectId, Date.now()); const backgroundExtraction = backgroundExtractionRef.current; trackProgrammaticBrandExtraction(result.id, programmaticAbortController, backgroundExtraction); res.json(result); } catch (err) { + if (sendWorkspaceScopeError(res, err)) return; const message = err instanceof Error ? err.message : String(err); // A bad URL is the only expected throw; everything else is a 500. const status = /valid http/i.test(message) ? 400 : 500; @@ -412,6 +498,7 @@ export function registerBrandRoutes(app: Application, deps: BrandRoutesDeps): vo id, brandsRoot, userDesignSystemsRoot, + designSystemWorkspaceId: (await deps.resolveDesignSystemWorkspaceId?.(req)) ?? null, projectsRoot, skillsRoot, dataDir, @@ -423,6 +510,7 @@ export function registerBrandRoutes(app: Application, deps: BrandRoutesDeps): vo const result = await finalizeBrand(finalizeOptions); res.json(result); } catch (err) { + if (sendWorkspaceScopeError(res, err)) return; const message = err instanceof Error ? err.message : String(err); const status = /not found/i.test(message) ? 404 : 422; res.status(status).json({ error: message }); diff --git a/apps/daemon/src/brands/index.ts b/apps/daemon/src/brands/index.ts index 78ab551d91d..726466a7485 100644 --- a/apps/daemon/src/brands/index.ts +++ b/apps/daemon/src/brands/index.ts @@ -119,6 +119,11 @@ export interface StartBrandExtractionOptions { * brand stays `extracting` for the agent to drive (the legacy behavior tests * use). */ userDesignSystemsRoot?: string; + /** Workspace to claim the extracted design system for (#145). Design systems + * share one directory, so the claim is what keeps a brand extracted in one + * workspace out of the next workspace's library. Omitted (signed out / + * single-player) leaves it unclaimed and visible everywhere. */ + designSystemWorkspaceId?: string | null; /** Runtime data dir so the programmatically-built design system is sedimented * into memory. Optional. */ dataDir?: string; @@ -351,6 +356,9 @@ export async function startBrandExtraction( sourceUrls: [url], sourceNotes: `Extracting from ${url}`, }, + ...(opts.designSystemWorkspaceId?.trim() + ? { workspaceId: opts.designSystemWorkspaceId.trim() } + : {}), }); draftDesignSystemId = draft.id; meta.designSystemId = draft.id; @@ -1264,6 +1272,10 @@ export interface FinalizeBrandOptions { id: string; brandsRoot: string; userDesignSystemsRoot: string; + /** Workspace to claim a newly registered design system for (#145). A + * re-finalize reuses the draft, whose claim is already preserved, so this + * only matters on the agent-driven path that registers without a draft. */ + designSystemWorkspaceId?: string | null; projectsRoot: string; /** Skills root so the final `brand.html` re-render can read the template. */ skillsRoot: string; @@ -1443,6 +1455,9 @@ async function finalizeBrandCore(opts: FinalizeBrandCoreOptions): Promise Import a local project. od design-systems import-github Import a public GitHub repo. od design-systems import-shadcn Import a shadcn registry item. - od design-systems rebuild-token-contract Start a token contract rebuild review.`; + od design-systems rebuild-token-contract Start a token contract rebuild review. + +Workspace options: + --workspace Exact Workspace for a bound design system. + --workspace-member Exact caller membership for a bound design system. + Pass both together, or omit both for legacy local data.`; // `help`, `--help`, and `-h` all route to the usage text above. Without the // flag forms, `od design-systems --help` falls through to the generic library diff --git a/apps/daemon/src/cli.ts b/apps/daemon/src/cli.ts index 04bb87728fc..0e4ba90c442 100644 --- a/apps/daemon/src/cli.ts +++ b/apps/daemon/src/cli.ts @@ -5,6 +5,7 @@ import { basename } from 'node:path'; import { runDaemonCliStartup, startDaemonRuntime } from './daemon-startup.js'; import { runLiveArtifactsMcpServer } from './mcp-live-artifacts-server.js'; import { runArtifactsCli } from './artifacts-cli.js'; +import { runResource } from './resource-cli.js'; import { runProjectHandoff } from './handoff-cli.js'; import { runConnectorsToolCli } from './tools-connectors-cli.js'; import { runDesignSystemsToolCli } from './tools-design-systems-cli.js'; @@ -61,6 +62,8 @@ const RESUME_CONTINUE_PROMPT = // initialization") and crash every `od media …` invocation. const MEDIA_GENERATE_STRING_FLAGS = new Set([ 'project', + 'workspace', + 'workspace-member', 'surface', 'model', 'prompt', @@ -145,6 +148,11 @@ const PLUGIN_STRING_FLAGS = new Set([ 'host', 'name', ]); +const PLUGIN_PROJECT_RESOURCE_STRING_FLAGS = new Set([ + ...PLUGIN_STRING_FLAGS, + 'workspace', + 'workspace-member', +]); const PLUGIN_BOOLEAN_FLAGS = new Set([ 'help', 'h', @@ -158,6 +166,8 @@ const UI_STRING_FLAGS = new Set([ 'daemon-url', 'run', 'project', + 'workspace', + 'workspace-member', 'value', 'value-json', 'plugin', @@ -189,12 +199,15 @@ const DAEMON_STRING_FLAGS = new Set([ const DAEMON_BOOLEAN_FLAGS = new Set([ 'help', 'h', 'json', 'headless', 'serve-web', 'no-open', ]); -const LIBRARY_STRING_FLAGS = new Set(['daemon-url', 'query', 'tag']); +const LIBRARY_STRING_FLAGS = new Set([ + 'daemon-url', 'query', 'tag', 'workspace', 'workspace-member', +]); const LIBRARY_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']); // `od library …` (OD Library asset registry). Hoisted so the dispatcher can // parse flags without hitting a temporal-dead-zone on these sets. const LIBRARY_ASSET_STRING_FLAGS = new Set([ 'daemon-url', 'kind', 'tag', 'source', 'date', 'query', 'project', 'label', 'out', 'dir', + 'workspace', 'workspace-member', ]); const LIBRARY_ASSET_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']); const DIAGNOSTICS_STRING_FLAGS = new Set(['daemon-url', 'output']); @@ -203,6 +216,11 @@ const CONFIG_STRING_FLAGS = new Set(['daemon-url', 'value', 'value-json']); const CONFIG_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']); const AMR_STRING_FLAGS = new Set(['daemon-url']); const AMR_BOOLEAN_FLAGS = new Set(['help', 'h', 'json', 'refresh']); +const COLLAB_STRING_FLAGS = new Set([ + 'daemon-url', 'project', 'member', 'name', 'role', 'design-system', + 'workspace', 'workspace-member', +]); +const COLLAB_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']); const MESSAGE_CENTER_STRING_FLAGS = new Set([ 'daemon-url', 'locale', @@ -219,7 +237,19 @@ const PROJECT_STRING_FLAGS = new Set([ 'title', 'label', 'against', 'seed-from', 'fork-after', 'mode', 'source', ]); +const PROJECT_RESOURCE_STRING_FLAGS = new Set([ + ...PROJECT_STRING_FLAGS, + 'workspace', + 'workspace-member', +]); const PROJECT_BOOLEAN_FLAGS = new Set(['help', 'h', 'json', 'follow']); +const WORKSPACE_STRING_FLAGS = new Set([ + 'daemon-url', 'workspace', 'view', 'visibility', 'owner', 'project', + 'member', 'role', 'email', 'app-user', 'lifecycle-state', + 'member-status', 'can-share-projects', 'can-write-synced-files', + 'workspace-type', +]); +const WORKSPACE_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']); // `od templates …` mirrors NewProjectPanel / ExamplesTab. Same surface, // same /api/templates store. The CLI form is the embeddability contract: // external agents (hermes-agent, openclaw, ...) can snapshot, list, or @@ -234,6 +264,7 @@ const TEMPLATES_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']); const DEPLOY_STRING_FLAGS = new Set([ 'daemon-url', 'file', 'provider', 'target', 'cf-zone-id', 'cf-zone-name', 'cf-domain-prefix', + 'workspace', 'workspace-member', ]); const DEPLOY_BOOLEAN_FLAGS = new Set(['help', 'h', 'json']); // `od automation …` mirrors the Automations tab. Same surface, same @@ -280,6 +311,11 @@ const SHARE_BOOLEAN_FLAGS = new Set([ const FIGMA_STRING_FLAGS = new Set([ 'daemon-url', 'project', 'file', 'figma-url', 'notes', 'prompt', 'prompt-file', ]); +const FIGMA_PROJECT_RESOURCE_STRING_FLAGS = new Set([ + ...FIGMA_STRING_FLAGS, + 'workspace', + 'workspace-member', +]); const FIGMA_BOOLEAN_FLAGS = new Set([ 'help', 'h', 'json', 'build', ]); @@ -336,6 +372,7 @@ const SUBCOMMAND_MAP = { mcp: runMcp, byok: runByok, amr: runAmr, + collab: runCollab, 'message-center': runMessageCenter, research: runResearch, plugin: runPlugin, @@ -345,6 +382,7 @@ const SUBCOMMAND_MAP = { brand: runBrand, brands: runBrand, project: runProject, + workspace: runWorkspace, automation: runAutomation, automations: runAutomation, memory: runMemory, @@ -356,8 +394,10 @@ const SUBCOMMAND_MAP = { deploy: runDeploy, daemon: runDaemon, atoms: runAtoms, + skill: runSkills, skills: runSkills, 'design-systems': runDesignSystems, + resource: runResource, craft: runCraft, diagnostics: runDiagnostics, export: runExport, @@ -372,6 +412,7 @@ const SUBCOMMAND_MAP = { const EXPORT_STRING_FLAGS = new Set([ 'daemon-url', 'project', 'format', 'out', 'output', 'image-format', 'title', 'file', + 'workspace', 'workspace-member', ]); const EXPORT_BOOLEAN_FLAGS = new Set(['help', 'h', 'json', 'deck', 'page', 'no-deck']); // EXPORT_FORMATS / EXPORT_IMAGE_FORMATS are the shared contract DTO (single @@ -396,6 +437,8 @@ Options: --deck Treat the artifact as a multi-slide deck --page, --no-deck Treat the artifact as a normal scrollable page --title Title used for metadata / default filename + --workspace <id> Explicit Workspace id for a bound project + --workspace-member <id> Explicit Workspace member id for a bound project --json Print a machine-readable result envelope --daemon-url <url> Override daemon URL @@ -438,6 +481,7 @@ async function runExport(args) { process.exit(2); } const base = await cliDaemonBaseUrl(flags); + const workspaceHeaders = workspaceHeadersFromExplicitFlags(flags) ?? {}; // All three formats rasterize through the desktop screenshot renderer so the // CLI matches the UI exactly. In particular `pdf` uses `/export/pdf-image` // (one raster page per deck slide / per viewport for a page) — NOT the generic @@ -467,7 +511,7 @@ async function runExport(args) { try { resp = await fetch(`${base}/api/projects/${encodeURIComponent(projectId)}/${exportPath}`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', ...workspaceHeaders }, body: JSON.stringify(requestBody), }); } catch (err) { @@ -519,7 +563,9 @@ if (first && SUBCOMMAND_MAP[first]) { const idx = argv.indexOf(first); const rest = [...argv.slice(0, idx), ...argv.slice(idx + 1)]; await SUBCOMMAND_MAP[first](rest); - process.exit(0); + // Respect a non-zero exit code a handler set via process.exitCode (e.g. a + // failed `od resource get`); default to 0 when it left it unset. + process.exit(process.exitCode ?? 0); } if (argv[0] === 'tools' && argv[1] === 'live-artifacts') { @@ -804,6 +850,10 @@ Options: const account = merged?.user?.email ?? merged?.user?.id ?? '-'; console.log(`AMR account\t${account}`); console.log(`Profile\t${merged?.profile ?? '-'}`); + // Only present when this build was given a vela web console origin + // (OD_VELA_WEB_URL); printing it makes "which backend is this app + // pointed at" answerable without reading the packaged config. + if (merged?.consoleOrigin) console.log(`Console\t${merged.consoleOrigin}`); if (merged?.account?.plan) console.log(`Plan\t${merged.account.plan}`); if (merged?.account?.balanceUsd) { console.log(`Wallet balance\t$${merged.account.balanceUsd}`); @@ -825,6 +875,278 @@ Options: } // --------------------------------------------------------------------------- +// Subcommand: od collab … (team-edition collaboration) +// --------------------------------------------------------------------------- + +function workspaceHeadersFromExplicitFlags(flags, required = false) { + const workspaceId = + typeof flags?.workspace === 'string' ? flags.workspace.trim() : ''; + const workspaceMemberId = + typeof flags?.['workspace-member'] === 'string' + ? flags['workspace-member'].trim() + : typeof flags?.member === 'string' + ? flags.member.trim() + : ''; + if (workspaceId && workspaceMemberId) { + return { + 'x-od-workspace-id': workspaceId, + 'x-od-workspace-member-id': workspaceMemberId, + }; + } + if (required || workspaceId || workspaceMemberId) { + exitWithStructuredError({ + code: 'workspace-context-required', + message: 'pass --workspace <id> and --workspace-member <id>', + }); + } + return null; +} + +function printCollabHelp() { + console.log(`Usage: + od collab status <projectId> --workspace <id> --workspace-member <id> [--json] + od collab presence <projectId> --workspace <id> --workspace-member <id> [--json] + od collab heartbeat <projectId> --workspace <id> --workspace-member <id> --member <id> [--name <name>] [--role owner|admin|member] [--json] + od collab leave <projectId> --workspace <id> --workspace-member <id> --member <id> [--json] + od collab changed <projectId> --workspace <id> --workspace-member <id> [--json] + od collab publish <projectId> --workspace <id> --workspace-member <id> [--json] + od collab share <projectId> --workspace <id> --workspace-member <id> [--json] + od collab pull <projectId> --workspace <id> --workspace-member <id> [--json] + od collab share-resource <design-systems|plugins|skills> <id> --workspace <id> --workspace-member <id> [--json] + od collab team-resources <design-systems|plugins|skills> --workspace <id> --workspace-member <id> [--json] + od collab share-design-system <designSystemId> --workspace <id> --workspace-member <id> [--json] + od collab team-design-systems --workspace <id> --workspace-member <id> [--json] + +Team-edition collaboration: presence overlay + sync trigger. The +client is authoritative about whether it is in a shared context, so it drives +the trigger; the daemon coalesces author edits and flushes at a run boundary, +advancing the published head version members poll to learn when to pull. +\`share\` is the team-share intent: it requests the project be published so +members can pull it, and reports the sync state (local_only / pending_upload / +synced / sync_failed). \`share-resource <kind> <id>\` promotes a personal design +system, plugin, or skill into the team scope through the resource hub, and +\`team-resources <kind>\` lists the ones already shared (the \`*-design-system\` +forms are kept as aliases). + +Options: + --project <id> Project id (alternative to the positional argument). + --design-system <id> Design system id for share-design-system. + --workspace <id> Explicit workspace id for request authorization. + --workspace-member <id> Explicit workspace member id for request authorization. + --member <id> Member id for the presence heartbeat / leave. + --name <name> Display name attached to a heartbeat. + --role <role> owner | admin | member. + --json Emit raw JSON. + --daemon-url <url> Override daemon URL. + +Examples: + od collab presence p1 --workspace team-1 --workspace-member m-42 --json + od collab heartbeat p1 --workspace team-1 --workspace-member m-42 --member m-42 --name "Ma Shu" --role member + od collab publish p1 --workspace team-1 --workspace-member m-42 + od collab share-resource plugins my-plugin --workspace team-1 --workspace-member m-42 --json + od collab team-resources skills --workspace team-1 --workspace-member m-42 --json + od collab share-design-system user:palette-x --workspace team-1 --workspace-member m-42 --json + od collab status p1 --workspace team-1 --workspace-member m-42 --json`); +} + +async function runCollab(args) { + const sub = args[0]; + if (!sub || sub === 'help' || args.includes('--help') || args.includes('-h')) { + printCollabHelp(); + process.exit(!sub ? 2 : 0); + } + const rest = args.slice(1); + let flags; + try { + flags = parseFlags(rest, { string: COLLAB_STRING_FLAGS, boolean: COLLAB_BOOLEAN_FLAGS }); + } catch (err) { + console.error(err.message); + process.exit(2); + } + // Team resource sharing (design systems / plugins / skills) is workspace-scoped + // — it takes a resource id, not a project id — so it runs before the project-id + // requirement below. `share-resource <kind> <id>` / `team-resources <kind>` are + // the generic forms; the design-system aliases are kept for compatibility. + const RESOURCE_BASE_PATHS = new Set(['design-systems', 'plugins', 'skills']); + if ( + sub === 'share-resource' || + sub === 'team-resources' || + sub === 'share-design-system' || + sub === 'team-design-systems' + ) { + const base = await cliDaemonBaseUrl(flags); + const workspaceHeaders = workspaceHeadersFromExplicitFlags(flags, true); + const emit = (payload, plain) => + flags.json ? process.stdout.write(JSON.stringify(payload, null, 2) + '\n') : plain(); + const wsRequest = async (method, path, body) => { + let resp; + try { + resp = await fetch(`${base}${path}`, { + method, + headers: { + ...workspaceHeaders, + ...(body !== undefined ? { 'content-type': 'application/json' } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + } catch (err) { + surfaceFetchError(err, base); + process.exit(3); + } + if (!resp.ok) return structuredHttpFailure(resp); + return resp.json(); + }; + + // Resolve the resource kind (URL base path), whether this lists or shares, + // and the target id — from either the aliases or the generic <kind> <id>. + const positionals = positionalArgs(rest, COLLAB_STRING_FLAGS); + let basePath; + let isList; + let resourceId; + if (sub === 'team-design-systems') { + basePath = 'design-systems'; + isList = true; + } else if (sub === 'share-design-system') { + basePath = 'design-systems'; + isList = false; + resourceId = flags['design-system'] || positionals[0]; + } else { + basePath = positionals[0]; + if (!RESOURCE_BASE_PATHS.has(basePath)) { + console.error('kind must be one of: design-systems | plugins | skills'); + process.exit(2); + } + isList = sub === 'team-resources'; + resourceId = positionals[1]; + } + + if (isList) { + const body = await wsRequest('GET', `/api/workspace/${basePath}/team`); + return emit(body, () => { + const ids = Array.isArray(body?.ids) ? body.ids : []; + if (ids.length === 0) return console.log(`no shared ${basePath}`); + for (const id of ids) console.log(id); + }); + } + if (!resourceId) { + console.error('missing <id>'); + process.exit(2); + } + const body = await wsRequest( + 'POST', + `/api/workspace/${basePath}/${encodeURIComponent(resourceId)}/share`, + ); + return emit(body, () => + console.log(`shared=${body?.shared ?? false}\tversion=${body?.version ?? '-'}`), + ); + } + + const projectId = + flags.project || positionalArgs(rest, COLLAB_STRING_FLAGS)[0] || process.env.OD_PROJECT_ID; + if (!projectId) { + console.error('missing <projectId> (positional, --project, or OD_PROJECT_ID)'); + process.exit(2); + } + const base = await cliDaemonBaseUrl(flags); + const encoded = encodeURIComponent(projectId); + const workspaceHeaders = workspaceHeadersFromExplicitFlags(flags, true); + + const request = async (method, path, body) => { + let resp; + try { + resp = await fetch(`${base}/api/projects/${encoded}${path}`, { + method, + headers: { + ...workspaceHeaders, + ...(body !== undefined ? { 'content-type': 'application/json' } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + } catch (err) { + surfaceFetchError(err, base); + process.exit(3); + } + if (!resp.ok) return structuredHttpFailure(resp); + return resp.json(); + }; + + const emit = (payload, plain) => { + if (flags.json) return process.stdout.write(JSON.stringify(payload, null, 2) + '\n'); + return plain(); + }; + + switch (sub) { + case 'status': { + const body = await request('GET', '/collab/status'); + return emit(body, () => { + console.log(`publishedVersion\t${body?.publishedVersion ?? '-'}`); + console.log(`materializedVersion\t${body?.materializedVersion ?? '-'}`); + // Whether this daemon's local files are the project's content at all: + // true means it holds only an unmaterialized shared-project + // placeholder, so an `od files list` here would report an empty + // project that is really still downloading. + console.log(`awaitingFirstMaterialization\t${body?.awaitingFirstMaterialization === true}`); + console.log(`syncState\t${body?.syncState ?? '-'}`); + }); + } + case 'share': { + // Team-share intent: request the project be published so members can pull. + const body = await request('POST', '/collab/sync-intent', { + event: 'project_team_share_requested', + projectId, + }); + return emit(body, () => console.log(`ok\tsyncState=${body?.syncState ?? '-'}`)); + } + case 'pull': { + // Member pull: fetch the published head (E extracts the bytes behind C's trigger). + const body = await request('POST', '/collab/pull'); + return emit(body, () => console.log(`pulled\tversion=${body?.version ?? '-'}`)); + } + case 'presence': { + const body = await request('GET', '/presence'); + return emit(body, () => { + const present = Array.isArray(body?.present) ? body.present : []; + if (present.length === 0) return console.log('no members present'); + for (const m of present) console.log(`${m.memberId}\t${m.name ?? '-'}\t${m.role ?? '-'}`); + }); + } + case 'heartbeat': { + if (!flags.member) { + console.error('missing --member <id>'); + process.exit(2); + } + const memberBody = { + memberId: flags.member, + ...(flags.name ? { name: flags.name } : {}), + ...(flags.role ? { role: flags.role } : {}), + }; + const body = await request('POST', '/presence/heartbeat', memberBody); + return emit(body, () => { + const present = Array.isArray(body?.present) ? body.present : []; + console.log(`ok\t${present.length} present`); + }); + } + case 'leave': { + if (!flags.member) { + console.error('missing --member <id>'); + process.exit(2); + } + const body = await request('POST', '/presence/leave', { memberId: flags.member }); + return emit(body, () => console.log('left')); + } + case 'changed': { + const body = await request('POST', '/collab/changed'); + return emit(body, () => console.log('change queued')); + } + case 'publish': { + const body = await request('POST', '/collab/publish'); + return emit(body, () => console.log('publish requested')); + } + default: + console.error(`unknown subcommand: od collab ${sub}`); + process.exit(2); + } +} // Subcommand: od message-center … // --------------------------------------------------------------------------- @@ -1106,6 +1428,9 @@ async function runMediaGenerate(rawArgs) { const daemonUrl = await cliDaemonUrl(flags); const projectId = flags.project || process.env.OD_PROJECT_ID; const token = process.env.OD_TOOL_TOKEN; + const workspaceHeaders = token + ? {} + : workspaceHeadersFromExplicitFlags(flags) ?? {}; if (!projectId && !token) { console.error( 'project id required. Pass --project <id> or set OD_PROJECT_ID. The daemon injects this when it spawns the code agent.', @@ -1156,6 +1481,7 @@ async function runMediaGenerate(rawArgs) { headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}), + ...workspaceHeaders, }, body: JSON.stringify(body), }); @@ -1177,20 +1503,23 @@ async function runMediaGenerate(rawArgs) { console.error(`task ${taskId} queued (${accepted.status || 'queued'})`); await pollUntilDoneOrBudget(daemonUrl, taskId, 0, { stillRunningExitCode: 0, + requestHeaders: token + ? { authorization: `Bearer ${token}` } + : workspaceHeaders, }); } async function runMediaWait(rawArgs) { - const taskId = rawArgs.find((a) => a && !a.startsWith('--')); - if (!taskId) { - console.error('usage: od media wait <taskId> [--since <n>] [--daemon-url <url>]'); - process.exit(2); - } - const flagsOnly = rawArgs.filter((a) => a !== taskId); + const stringFlags = new Set([ + 'since', + 'daemon-url', + 'workspace', + 'workspace-member', + ]); let flags; try { - flags = parseFlags(flagsOnly, { - string: new Set(['since', 'daemon-url']), + flags = parseFlags(rawArgs, { + string: stringFlags, boolean: new Set(['help', 'h']), }); } catch (err) { @@ -1198,11 +1527,24 @@ async function runMediaWait(rawArgs) { printMediaHelp(); process.exit(2); } + const taskId = positionalArgs(rawArgs, stringFlags)[0]; + if (!taskId) { + console.error( + 'usage: od media wait <taskId> [--since <n>] [--workspace <id> --workspace-member <id>] [--daemon-url <url>]', + ); + process.exit(2); + } const daemonUrl = await cliDaemonUrl(flags); const since = Number.isFinite(Number(flags.since)) ? Number(flags.since) : 0; - await pollUntilDoneOrBudget(daemonUrl, taskId, since, { totalBudgetMs: 120_000 }); + const token = process.env.OD_TOOL_TOKEN; + await pollUntilDoneOrBudget(daemonUrl, taskId, since, { + totalBudgetMs: 120_000, + requestHeaders: token + ? { authorization: `Bearer ${token}` } + : workspaceHeadersFromExplicitFlags(flags) ?? {}, + }); } async function pollUntilDoneOrBudget(daemonUrl, taskId, sinceStart, options = {}) { @@ -1212,6 +1554,10 @@ async function pollUntilDoneOrBudget(daemonUrl, taskId, sinceStart, options = {} typeof options.stillRunningExitCode === 'number' ? options.stillRunningExitCode : 2; + const requestHeaders = + options.requestHeaders && typeof options.requestHeaders === 'object' + ? options.requestHeaders + : {}; const startedAt = Date.now(); const url = `${daemonUrl.replace(/\/$/, '')}/api/media/tasks/${encodeURIComponent(taskId)}/wait`; @@ -1225,7 +1571,7 @@ async function pollUntilDoneOrBudget(daemonUrl, taskId, sinceStart, options = {} try { resp = await fetch(url, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', ...requestHeaders }, body: JSON.stringify({ since, timeoutMs: callTimeout }), }); } catch (err) { @@ -1404,6 +1750,26 @@ function positionalArgs(argv, stringFlags = new Set()) { return out; } +function repeatableFlagValues(argv, name) { + const values = []; + const prefix = `--${name}=`; + for (let i = 0; i < argv.length; i++) { + const item = argv[i]; + if (typeof item !== 'string') continue; + if (item.startsWith(prefix)) { + const value = item.slice(prefix.length).trim(); + if (value) values.push(value); + continue; + } + if (item === `--${name}`) { + const value = typeof argv[i + 1] === 'string' ? argv[i + 1].trim() : ''; + if (value && !value.startsWith('--')) values.push(value); + i++; + } + } + return values; +} + async function cliDaemonUrl(flags) { return resolveDaemonUrl({ flagUrl: flags?.['daemon-url'] }); } @@ -1420,6 +1786,8 @@ Required: --surface image | video | audio --model Model id from /api/media/models (e.g. gpt-image-2, seedance-2, suno-v5). --project Project id. Auto-resolved from OD_PROJECT_ID when invoked by the daemon. + --workspace <id> Explicit Workspace id for a bound project. + --workspace-member <id> Explicit Workspace member id for a bound project. Common options: --prompt "<text>" Generation prompt. ElevenLabs SFX prompts must stay under 450 characters. @@ -1449,6 +1817,8 @@ Output: a single line of JSON: {"file": { name, size, kind, mime, ... }} a successful queued handoff, not a failure. Poll with \`media wait\`: exit 0 = done ({"file": ...} on stdout), exit 2 = still running (re-run the wait command stderr prints, carrying forward nextSince), 5 = failed. + Standalone wait calls accept the same Workspace pair. Tool-token calls retain + their injected authorization proof automatically through every poll. Worked generate→wait loop (POSIX bash — do NOT translate to PowerShell; parse JSON with python3, not jq): @@ -2025,9 +2395,15 @@ Exit codes: if (!flags['no-daemon']) { const base = (await libraryDaemonUrl(flags)).replace(/\/$/, ''); try { + const designSystemWorkspaceHeaders = + workspaceHeadersFromExplicitFlags(flags); const [skillsResp, dsResp, atomsResp] = await Promise.all([ fetch(`${base}/api/skills`).catch(() => null), - fetch(`${base}/api/design-systems`).catch(() => null), + fetch(`${base}/api/design-systems`, { + ...(designSystemWorkspaceHeaders + ? { headers: designSystemWorkspaceHeaders } + : {}), + }).catch(() => null), fetch(`${base}/api/atoms`).catch(() => null), ]); const skills = (skillsResp?.ok ? (await skillsResp.json())?.skills : []) ?? []; @@ -2595,14 +2971,24 @@ async function runPluginSnapshots(args) { const sub = args[0]; if (!sub || sub === 'help' || args.includes('--help') || args.includes('-h')) { console.log(`Usage: - od plugin snapshots list [--project <id>] List applied plugin snapshots. + od plugin snapshots list [--project <id>] [--workspace <id> --workspace-member <id>] + List applied plugin snapshots. od plugin snapshots show <snapshotId> [--json] Print one snapshot's full contents. od plugin snapshots diff <id-a> <id-b> [--json] Compare two snapshots field-by-field. od plugin snapshots prune [--before <unix-ms>] Delete expired (or older-than-cutoff) snapshots.`); process.exit(args.length === 0 ? 2 : 0); } - const flags = parseFlags(args.slice(1), { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS }); + const snapshotStringFlags = + sub === 'list' ? PLUGIN_PROJECT_RESOURCE_STRING_FLAGS : PLUGIN_STRING_FLAGS; + const flags = parseFlags(args.slice(1), { + string: snapshotStringFlags, + boolean: PLUGIN_BOOLEAN_FLAGS, + }); const base = (await pluginDaemonUrl(flags)).replace(/\/$/, ''); + const workspaceHeaders = + sub === 'list' + ? workspaceHeadersFromExplicitFlags(flags) ?? {} + : {}; if (sub === 'show') { const positional = args.slice(1).filter((a) => !a.startsWith('-')); const id = positional[0]; @@ -2677,7 +3063,7 @@ async function runPluginSnapshots(args) { const url = flags.project ? `${base}/api/projects/${encodeURIComponent(flags.project)}/applied-plugins` : `${base}/api/applied-plugins`; - const resp = await fetch(url); + const resp = await fetch(url, { headers: workspaceHeaders }); if (!resp.ok) { console.error(`GET ${url} failed: ${resp.status} ${await resp.text()}`); process.exit(1); @@ -2714,19 +3100,11 @@ async function runPluginSnapshots(args) { // wrapper around `od plugin apply` + `POST /api/runs` so a code agent // can drive the apply→start→follow loop without two hops. async function runPluginRun(rest) { - const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS }); - const id = rest.find((a) => !a.startsWith('-') - && a !== flags['daemon-url'] - && a !== flags.source - && a !== flags.inputs - && a !== flags.project - && a !== flags.conversation - && a !== flags.message - && a !== flags.agent - && a !== flags.model - && a !== flags['snapshot-id'] - && a !== flags.capabilities - && a !== flags['grant-caps']); + const flags = parseFlags(rest, { + string: PLUGIN_PROJECT_RESOURCE_STRING_FLAGS, + boolean: PLUGIN_BOOLEAN_FLAGS, + }); + const id = positionalArgs(rest, PLUGIN_PROJECT_RESOURCE_STRING_FLAGS)[0]; if (!id) { console.error('Usage: od plugin run <id> --project <projectId> [--inputs <json>] [--agent <id>] [--message "<text>"] [--grant-caps a,b] [--follow]'); process.exit(2); @@ -2740,10 +3118,11 @@ async function runPluginRun(rest) { ? flags['grant-caps'].split(',').map((c) => c.trim()).filter(Boolean) : []; const base = (await pluginDaemonUrl(flags)).replace(/\/$/, ''); + const workspaceHeaders = workspaceHeadersFromExplicitFlags(flags) ?? {}; // 1. Apply (returns ApplyResult + manifestSourceDigest). const applyResp = await fetch(`${base}/api/plugins/${encodeURIComponent(id)}/apply`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', ...workspaceHeaders }, body: JSON.stringify({ inputs, grantCaps, projectId: flags.project }), }); const applyData = await applyResp.json().catch(() => ({})); @@ -2755,7 +3134,7 @@ async function runPluginRun(rest) { // snapshot to the run object. const runResp = await fetch(`${base}/api/runs`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', ...workspaceHeaders }, body: JSON.stringify({ projectId: flags.project, pluginId: id, @@ -2781,12 +3160,12 @@ async function runPluginRun(rest) { } if (flags.json) { process.stdout.write(JSON.stringify({ apply: applyData, run: runData }, null, 2) + '\n'); - if (flags.follow) await streamRunEvents(base, runData.runId); + if (flags.follow) await streamRunEvents(base, runData.runId, workspaceHeaders); return; } console.log(`[run] started run ${runData.runId} (snapshot ${runData.appliedPluginSnapshotId ?? applyData?.appliedPlugin?.snapshotId ?? 'n/a'})`); if (flags.follow) { - await streamRunEvents(base, runData.runId); + await streamRunEvents(base, runData.runId, workspaceHeaders); } } @@ -4046,15 +4425,25 @@ function coerceCliValue(raw) { async function runPluginCandidates(rest) { const sub = rest[0]; const args = rest.slice(1); + const candidateStringFlags = new Set([ + 'daemon-url', + 'project', + 'action', + 'workspace', + 'workspace-member', + ]); const flags = parseFlags(args, { - string: new Set(['daemon-url', 'project', 'action']), + string: candidateStringFlags, boolean: new Set(['help', 'h', 'json', 'include-dismissed']), }); - if (!sub || flags.help || flags.h) { + if (!sub || sub === 'help' || flags.help || flags.h) { console.log(`Usage: od plugin candidates list --project <projectId> [--json] [--include-dismissed] + --workspace <id> --workspace-member <id> od plugin candidates draft <candidateId> --project <projectId> [--json] + --workspace <id> --workspace-member <id> od plugin candidates dismiss <candidateId> --project <projectId> [--json] + --workspace <id> --workspace-member <id> Lists and formalizes persisted skill-to-plugin candidates.`); process.exit(!sub ? 2 : 0); @@ -4065,9 +4454,13 @@ Lists and formalizes persisted skill-to-plugin candidates.`); process.exit(2); } const base = (await pluginDaemonUrl(flags)).replace(/\/$/, ''); + const workspaceHeaders = workspaceHeadersFromExplicitFlags(flags) ?? {}; if (sub === 'list') { const qs = flags['include-dismissed'] ? '?includeDismissed=true' : ''; - const resp = await fetch(`${base}/api/projects/${encodeURIComponent(projectId)}/plugin-candidates${qs}`); + const resp = await fetch( + `${base}/api/projects/${encodeURIComponent(projectId)}/plugin-candidates${qs}`, + { headers: workspaceHeaders }, + ); const data = await resp.json().catch(() => null); if (!resp.ok) { console.error(`GET plugin candidates failed: ${resp.status} ${JSON.stringify(data)}`); @@ -4084,7 +4477,7 @@ Lists and formalizes persisted skill-to-plugin candidates.`); } return; } - const candidateId = args.find((a) => !a.startsWith('-') && a !== flags.project && a !== flags.action); + const candidateId = positionalArgs(args, candidateStringFlags)[0]; if (!candidateId) { console.error(`candidate id is required for ${sub}`); process.exit(2); @@ -4092,7 +4485,7 @@ Lists and formalizes persisted skill-to-plugin candidates.`); if (sub === 'draft') { const resp = await fetch(`${base}/api/projects/${encodeURIComponent(projectId)}/plugin-candidates/${encodeURIComponent(candidateId)}/draft`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...workspaceHeaders }, body: '{}', }); const data = await resp.json().catch(() => null); @@ -4109,7 +4502,7 @@ Lists and formalizes persisted skill-to-plugin candidates.`); if (sub === 'dismiss') { const resp = await fetch(`${base}/api/projects/${encodeURIComponent(projectId)}/plugin-candidates/${encodeURIComponent(candidateId)}/dismiss`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...workspaceHeaders }, body: '{}', }); const data = await resp.json().catch(() => null); @@ -5008,6 +5401,13 @@ async function uiDaemonUrl(flags) { return cliDaemonUrl(flags); } +function uiRequestHeaders(flags, json = false) { + return { + ...(json ? { 'content-type': 'application/json' } : {}), + ...(workspaceHeadersFromExplicitFlags(flags) ?? {}), + }; +} + async function runUiList(rest) { const flags = parseFlags(rest, { string: UI_STRING_FLAGS, boolean: UI_BOOLEAN_FLAGS }); const base = (await uiDaemonUrl(flags)).replace(/\/$/, ''); @@ -5018,7 +5418,7 @@ async function runUiList(rest) { console.error('Usage: od ui list --run <runId> | --project <projectId>'); process.exit(2); } - const resp = await fetch(url); + const resp = await fetch(url, { headers: uiRequestHeaders(flags) }); if (!resp.ok) { console.error(`GET ${url} failed: ${resp.status} ${await resp.text()}`); process.exit(1); @@ -5044,6 +5444,8 @@ async function runUiShow(rest) { && a !== flags['daemon-url'] && a !== flags.run && a !== flags.project + && a !== flags.workspace + && a !== flags['workspace-member'] && a !== flags.value && a !== flags['value-json'] && a !== flags.plugin @@ -5057,7 +5459,7 @@ async function runUiShow(rest) { process.exit(2); } const url = `${(await uiDaemonUrl(flags)).replace(/\/$/, '')}/api/runs/${encodeURIComponent(runId)}/genui/${encodeURIComponent(surfaceId)}`; - const resp = await fetch(url); + const resp = await fetch(url, { headers: uiRequestHeaders(flags) }); if (!resp.ok) { console.error(`GET ${url} failed: ${resp.status} ${await resp.text()}`); process.exit(1); @@ -5080,6 +5482,8 @@ async function runUiRespond(rest) { && a !== flags['daemon-url'] && a !== flags.run && a !== flags.project + && a !== flags.workspace + && a !== flags['workspace-member'] && a !== flags.value && a !== flags['value-json'] && a !== flags.plugin @@ -5109,7 +5513,7 @@ async function runUiRespond(rest) { const url = `${(await uiDaemonUrl(flags)).replace(/\/$/, '')}/api/runs/${encodeURIComponent(runId)}/genui/${encodeURIComponent(surfaceId)}/respond`; const resp = await fetch(url, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: uiRequestHeaders(flags, true), body: JSON.stringify({ value, respondedBy: 'user' }), }); const data = await resp.json().catch(() => ({})); @@ -5130,6 +5534,8 @@ async function runUiRevoke(rest) { && a !== flags['daemon-url'] && a !== flags.run && a !== flags.project + && a !== flags.workspace + && a !== flags['workspace-member'] && a !== flags.value && a !== flags['value-json'] && a !== flags.plugin @@ -5143,7 +5549,10 @@ async function runUiRevoke(rest) { process.exit(2); } const url = `${(await uiDaemonUrl(flags)).replace(/\/$/, '')}/api/projects/${encodeURIComponent(projectId)}/genui/${encodeURIComponent(surfaceId)}/revoke`; - const resp = await fetch(url, { method: 'POST' }); + const resp = await fetch(url, { + method: 'POST', + headers: uiRequestHeaders(flags), + }); const data = await resp.json().catch(() => ({})); if (!resp.ok) { console.error(`POST ${url} failed: ${resp.status} ${JSON.stringify(data)}`); @@ -5162,6 +5571,8 @@ async function runUiPrefill(rest) { && a !== flags['daemon-url'] && a !== flags.run && a !== flags.project + && a !== flags.workspace + && a !== flags['workspace-member'] && a !== flags.value && a !== flags['value-json'] && a !== flags.plugin @@ -5187,7 +5598,7 @@ async function runUiPrefill(rest) { const url = `${(await uiDaemonUrl(flags)).replace(/\/$/, '')}/api/projects/${encodeURIComponent(projectId)}/genui/prefill`; const resp = await fetch(url, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: uiRequestHeaders(flags, true), body: JSON.stringify({ snapshotId, surfaceId, @@ -5222,6 +5633,9 @@ function printUiHelp() { Common options: --daemon-url <url> Open Design daemon HTTP base (default OD_DAEMON_URL, OD_SIDECAR_IPC_PATH discovery, or http://127.0.0.1:7456). + --workspace <id> Explicit Workspace id for a bound project or run. + --workspace-member <id> + Explicit Workspace member id for a bound project or run. --json Emit raw JSON (suitable for scripts) instead of human-readable output.`); } @@ -5254,13 +5668,17 @@ function printPluginHelp() { od plugin diff <a> <b> [--json] Compare two installed plugins by id. od plugin replay <runId> --snapshot-id <id> Re-emit the immutable snapshot a run launched against. + od plugin run <id> --project <id> [--workspace <id> --workspace-member <id>] + Apply a plugin and start a project run. + od plugin snapshots list --project <id> [--workspace <id> --workspace-member <id>] + List snapshots applied to a project. od plugin trust <id> --capabilities a,b Stage a capability grant (full mutation lands Phase 3). od plugin validate <folder> [--json] Lint a plugin folder before installing (manifest parse + atom + ref checks). od plugin pack <folder> [--out <path>] Build a .tgz archive of a plugin folder for distribution. - od plugin candidates list --project <id> + od plugin candidates list --project <id> [--workspace <id> --workspace-member <id>] List persisted skill-to-plugin candidates. od plugin publish-repo <folder> Create/update the author's public GitHub repo for a plugin folder. @@ -5402,6 +5820,9 @@ Flags: --build After import, start a run that builds the webpage. --prompt / --prompt-file Override the build prompt (file or - for stdin). --daemon-url <url> Open Design daemon HTTP base. + --workspace <id> Explicit Workspace id for the bound project. + --workspace-member <id> + Explicit Workspace member id for the bound project. --json Emit raw JSON.`); } @@ -5418,8 +5839,12 @@ async function runFigma(args) { } const idx = args.indexOf(sub); const rest = [...args.slice(0, idx), ...args.slice(idx + 1)]; - const flags = parseFlags(rest, { string: FIGMA_STRING_FLAGS, boolean: FIGMA_BOOLEAN_FLAGS }); + const flags = parseFlags(rest, { + string: FIGMA_PROJECT_RESOURCE_STRING_FLAGS, + boolean: FIGMA_BOOLEAN_FLAGS, + }); const base = (await cliDaemonUrl(flags)).replace(/\/$/, ''); + const workspaceHeaders = workspaceHeadersFromExplicitFlags(flags) ?? {}; if (!flags.project) { console.error('--project <id> is required'); @@ -5442,7 +5867,7 @@ async function runFigma(args) { }; const runResp = await fetch(`${base}/api/runs`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', ...workspaceHeaders }, body: JSON.stringify(runBody), }); const runData = await runResp.json().catch(() => ({})); @@ -5468,6 +5893,7 @@ async function runFigma(args) { if (flags.notes) form.append('notes', String(flags.notes)); const resp = await fetch(`${base}/api/projects/${encodeURIComponent(flags.project)}/figma/import`, { method: 'POST', + headers: workspaceHeaders, body: form, }); if (!resp.ok) return structuredHttpFailure(resp); @@ -5489,7 +5915,7 @@ async function runFigma(args) { const message = override || data.suggestedPrompt; const runResp = await fetch(`${base}/api/runs`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', ...workspaceHeaders }, body: JSON.stringify({ projectId: flags.project, message }), }); const runData = await runResp.json().catch(() => ({})); @@ -6044,8 +6470,8 @@ async function postJsonToDaemon(base, route, body, headers = {}) { return data; } -async function postImportFolderToDaemon(base, body, baseDir) { - const headers = {}; +async function postImportFolderToDaemon(base, body, baseDir, workspaceHeaders = {}) { + const headers = { ...workspaceHeaders }; const importToken = await mintCliImportToken(baseDir); if (importToken != null) { headers['x-od-desktop-import-token'] = importToken; @@ -6082,6 +6508,9 @@ async function runProject(args) { Common options: --daemon-url <url> Open Design daemon HTTP base. + --workspace <id> Exact Workspace for bound project requests. + --workspace-member <id> + Exact caller membership for bound project requests. --json Emit raw JSON.`); process.exit(args.length === 0 ? 2 : 0); } @@ -6097,11 +6526,18 @@ Common options: if (exitCode !== 0) process.exit(exitCode); return; } - const flags = parseFlags(rest, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS }); + const flags = parseFlags(rest, { + string: PROJECT_RESOURCE_STRING_FLAGS, + boolean: PROJECT_BOOLEAN_FLAGS, + }); const base = (await projectDaemonUrl(flags)).replace(/\/$/, ''); + const workspaceHeaders = + workspaceHeadersFromExplicitFlags(flags) ?? {}; switch (sub) { case 'list': { - const resp = await fetch(`${base}/api/projects`); + const resp = await fetch(`${base}/api/projects`, { + headers: workspaceHeaders, + }); if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); @@ -6114,12 +6550,14 @@ Common options: return; } case 'info': { - const id = rest.find((a) => !a.startsWith('-')); + const id = positionalArgs(rest, PROJECT_RESOURCE_STRING_FLAGS)[0]; if (!id) { console.error('Usage: od project info <id>'); process.exit(2); } - const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}`); + const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}`, { + headers: workspaceHeaders, + }); if (!resp.ok) return structuredHttpFailure(resp, 'project-not-found'); const data = await resp.json(); process.stdout.write(JSON.stringify(data, null, 2) + '\n'); @@ -6157,7 +6595,7 @@ Common options: } const resp = await fetch(`${base}/api/projects`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', ...workspaceHeaders }, body: JSON.stringify(body), }); const data = await resp.json().catch(() => ({})); @@ -6177,7 +6615,7 @@ Common options: return; } case 'create-design-system': { - const sourceProjectId = positionalArgs(rest, PROJECT_STRING_FLAGS)[0]; + const sourceProjectId = positionalArgs(rest, PROJECT_RESOURCE_STRING_FLAGS)[0]; if (!sourceProjectId) { console.error('Usage: od project create-design-system <id> [--name "<title>"] [--prompt-file <path|->] [--json]'); process.exit(2); @@ -6190,6 +6628,7 @@ Common options: base, `/api/projects/${encodeURIComponent(sourceProjectId)}/design-system-copy`, body, + workspaceHeaders, ); if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); console.log( @@ -6199,7 +6638,7 @@ Common options: return; } case 'duplicate': { - const sourceProjectId = positionalArgs(rest, PROJECT_STRING_FLAGS)[0]; + const sourceProjectId = positionalArgs(rest, PROJECT_RESOURCE_STRING_FLAGS)[0]; if (!sourceProjectId) { console.error('Usage: od project duplicate <id> [--name "<title>"] [--json]'); process.exit(2); @@ -6210,6 +6649,7 @@ Common options: base, `/api/projects/${encodeURIComponent(sourceProjectId)}/duplicate`, body, + workspaceHeaders, ); if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); console.log( @@ -6219,7 +6659,7 @@ Common options: return; } case 'import': { - const [baseDir] = positionalArgs(rest, PROJECT_STRING_FLAGS); + const [baseDir] = positionalArgs(rest, PROJECT_RESOURCE_STRING_FLAGS); const importBaseDir = typeof baseDir === 'string' ? baseDir.trim() : ''; if (!importBaseDir) { console.error('Usage: od project import <baseDir> [--name "<title>"]'); @@ -6231,7 +6671,7 @@ Common options: if (typeof flags['design-system'] === 'string' && flags['design-system'].length > 0) { body.designSystemId = flags['design-system']; } - const headers = { 'content-type': 'application/json' }; + const headers = { 'content-type': 'application/json', ...workspaceHeaders }; const importToken = await mintCliImportToken(importBaseDir); if (importToken != null) { headers['x-od-desktop-import-token'] = importToken; @@ -6248,7 +6688,7 @@ Common options: return; } case 'import-folder': { - const parts = collectCliPositionals(rest, PROJECT_STRING_FLAGS); + const parts = collectCliPositionals(rest, PROJECT_RESOURCE_STRING_FLAGS); const folderArg = flags.path ?? flags.dir ?? parts[0]; if (!folderArg) { console.error('Usage: od project import-folder <path> [--skill <id>] [--design-system <id>]'); @@ -6263,18 +6703,26 @@ Common options: skillId: flags.skill ?? null, designSystemId: flags['design-system'] ?? null, }; - const data = await postImportFolderToDaemon(base, body, folderPath); + const data = await postImportFolderToDaemon( + base, + body, + folderPath, + workspaceHeaders, + ); if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); console.log(`[project] imported ${data.project?.id ?? '-'} from ${folderPath} (conversation ${data.conversationId ?? '-'})`); return; } case 'delete': { - const id = rest.find((a) => !a.startsWith('-')); + const id = positionalArgs(rest, PROJECT_RESOURCE_STRING_FLAGS)[0]; if (!id) { console.error('Usage: od project delete <id>'); process.exit(2); } - const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}`, { method: 'DELETE' }); + const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers: workspaceHeaders, + }); if (!resp.ok) return structuredHttpFailure(resp, 'project-not-found'); console.log(`[project] deleted ${id}`); return; @@ -6292,7 +6740,7 @@ Common options: return; } case 'open-in': { - const id = rest.find((a) => !a.startsWith('-')); + const id = positionalArgs(rest, PROJECT_RESOURCE_STRING_FLAGS)[0]; if (!id) { console.error('Usage: od project open-in <id> --editor <slug>'); process.exit(2); @@ -6304,7 +6752,7 @@ Common options: } const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/open-in`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', ...workspaceHeaders }, body: JSON.stringify({ editorId: editor }), }); const data = await resp.json().catch(() => ({})); @@ -6323,12 +6771,295 @@ Common options: } } +async function runWorkspace(args) { + if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) { + console.log(`Usage: + od workspace invite --workspace <id> --member <id> --email <addr> [--role admin|member] [--json] + od workspace projects team --workspace <id> --member <id> [--json] + od workspace projects list --workspace <id> --member <id> [--view recent|drafts|team|all] [--json] + od workspace projects move <projectId> --workspace <id> --member <id> --visibility personal|team [--json] + od workspace projects batch-delete --workspace <id> --member <id> --project <id> [--project <id> ...] [--json] + od workspace projects batch-move --workspace <id> --member <id> --visibility personal|team --project <id> [--project <id> ...] [--json] + od workspace members list --workspace <id> --member <id> [--json] + od workspace billing [--workspace-type personal|team] [--workspace <id>] [--json] + +Common options: + --daemon-url <url> Open Design daemon HTTP base. + --member <id> Workspace member id for route-level authorization. + --role <role> Workspace role: owner, admin, or member. + --workspace-type <t> personal or team. A team share is refused in a personal + workspace, which has no team plane to share into. + --json Emit raw JSON.`); + process.exit(args.length === 0 ? 2 : 0); + } + const area = args[0]; + if (!['invite', 'projects', 'members', 'billing'].includes(area)) { + console.error(`unknown subcommand: od workspace ${area}`); + process.exit(2); + } + const sub = args[1] ?? 'list'; + const rest = area === 'invite' || area === 'billing' ? args.slice(1) : args.slice(2); + const flags = parseFlags(rest, { string: WORKSPACE_STRING_FLAGS, boolean: WORKSPACE_BOOLEAN_FLAGS }); + const base = (await projectDaemonUrl(flags)).replace(/\/$/, ''); + + async function workspaceContextRequest(path, init) { + const needsExplicitWorkspace = + path === '/api/workspace/invite' + || path === '/api/workspace/members' + || path === '/api/workspace/projects/team'; + const workspaceHeaders = needsExplicitWorkspace + ? workspaceHeadersFromExplicitFlags(flags, true) + : {}; + const resp = await fetch(`${base}${path}`, { + ...init, + headers: { + ...workspaceHeaders, + ...(init?.headers ?? {}), + }, + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + console.error(`${init?.method ?? 'GET'} ${path} failed: ${resp.status} ${JSON.stringify(data)}`); + process.exit(1); + } + return data; + } + + if (area === 'invite') { + const emails = repeatableFlagValues(rest, 'email'); + const role = String(flags.role ?? 'member'); + if (emails.length === 0 || !['admin', 'member'].includes(role)) { + console.error('Usage: od workspace invite --email <addr> [--role admin|member] [--json]'); + process.exit(2); + } + const body = emails.length === 1 + ? { email: emails[0], role } + : { invites: emails.map((email) => ({ email, role })) }; + const data = await workspaceContextRequest('/api/workspace/invite', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); + const results = Array.isArray(data?.results) ? data.results : []; + for (const result of results) { + console.log(`${result.email}\t${result.ok ? 'invited' : `failed:${result.error ?? 'unknown'}`}`); + } + return; + } + + // Dual-track parity for the account menu's credits card. Billing scope is an + // explicit CLI argument, never daemon active-workspace state: account is the + // compatibility default; team requires both type + workspace id. + if (area === 'billing') { + const workspaceType = + typeof flags['workspace-type'] === 'string' + ? flags['workspace-type'].trim().toLowerCase() + : ''; + const workspaceId = + typeof flags.workspace === 'string' ? flags.workspace.trim() : ''; + if ( + (workspaceType && workspaceType !== 'personal' && workspaceType !== 'team') || + (workspaceType === 'team' && !workspaceId) || + (workspaceType === 'personal' && workspaceId) || + (!workspaceType && workspaceId) + ) { + console.error( + 'Usage: od workspace billing [--workspace-type personal|team] [--workspace <id>] [--json]', + ); + process.exit(2); + } + const billingPath = + workspaceType === 'team' + ? `/api/workspace/billing?scope=workspace&workspaceId=${encodeURIComponent(workspaceId)}` + : '/api/workspace/billing?scope=account'; + const data = await workspaceContextRequest(billingPath); + if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); + const summary = data?.summary ?? null; + const workspaceBalance = data?.workspaceBalance ?? null; + if (!summary && !workspaceBalance) { + console.log('No billing summary (no vela session or CLI unavailable).'); + return; + } + if (workspaceBalance) { + console.log(`Workspace:\t${workspaceBalance.workspaceId}`); + } + if (summary) { + console.log(`Account plan:\t${summary.membershipTier || 'free'}`); + console.log(`Subscription:\t${summary.subscriptionStatus || 'none'}`); + console.log(`Account credits:\t${summary.totalAvailableCredits}`); + console.log(` Account plan credits:\t${summary.subscriptionCredits}`); + console.log(` Account top-up credits:\t${summary.rechargeCredits}`); + } + const balanceUsd = workspaceBalance?.balanceUsd ?? summary?.balanceUsd; + if (balanceUsd != null) { + console.log(`${workspaceBalance ? 'Workspace' : 'Account'} balance (USD):\t${balanceUsd}`); + } + return; + } + + if (area === 'members') { + if (sub !== 'list') { + console.error(`unknown subcommand: od workspace members ${sub}`); + process.exit(2); + } + const data = await workspaceContextRequest('/api/workspace/members'); + if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); + const members = Array.isArray(data?.members) ? data.members : []; + if (members.length === 0) { + console.log('No workspace members.'); + return; + } + for (const member of members) { + console.log(`${member.memberId}\t${member.displayName ?? '-'}\t${member.role ?? '-'}`); + } + return; + } + + if (sub === 'team') { + const data = await workspaceContextRequest('/api/workspace/projects/team'); + if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); + const projects = Array.isArray(data?.projects) ? data.projects : []; + if (projects.length === 0) { + console.log('No shared team projects.'); + return; + } + for (const project of projects) { + console.log(`${project.projectId ?? project.id}\t${project.displayName ?? project.name ?? '-'}`); + } + return; + } + + const workspaceId = typeof flags.workspace === 'string' && flags.workspace.trim() ? flags.workspace.trim() : ''; + if (!workspaceId) { + console.error('--workspace <id> is required'); + process.exit(2); + } + const projectIds = repeatableFlagValues(rest, 'project'); + const workspaceMemberId = typeof flags.member === 'string' && flags.member.trim() ? flags.member.trim() : ''; + if (!workspaceMemberId) { + console.error('--member <id> is required'); + process.exit(2); + } + const workspaceHeaders = { + 'x-od-workspace-id': workspaceId, + // Only sent when the caller actually says which kind of workspace this is. + // The daemon reads an explicit `personal` as the caller ASSERTING there is + // no team plane here and refuses a team share on the strength of it (see + // collab/team-share-scope.ts), so defaulting the header to 'personal' would + // have made `--visibility team` impossible from the CLI. Absent still reads + // as personal everywhere it only affects view filtering. + ...(typeof flags['workspace-type'] === 'string' && flags['workspace-type'].trim() + ? { 'x-od-workspace-type': flags['workspace-type'].trim() } + : {}), + 'x-od-workspace-member-id': workspaceMemberId, + ...(typeof flags.role === 'string' && flags.role.trim() ? { 'x-od-workspace-role': flags.role.trim() } : {}), + ...(typeof flags['app-user'] === 'string' && flags['app-user'].trim() ? { 'x-od-app-user-id': flags['app-user'].trim() } : {}), + ...(typeof flags['lifecycle-state'] === 'string' && flags['lifecycle-state'].trim() + ? { 'x-od-workspace-lifecycle-state': flags['lifecycle-state'].trim() } + : {}), + ...(typeof flags['member-status'] === 'string' && flags['member-status'].trim() + ? { 'x-od-workspace-member-status': flags['member-status'].trim() } + : {}), + ...(typeof flags['can-share-projects'] === 'string' && flags['can-share-projects'].trim() + ? { 'x-od-workspace-can-share-projects': flags['can-share-projects'].trim() } + : {}), + ...(typeof flags['can-write-synced-files'] === 'string' && flags['can-write-synced-files'].trim() + ? { 'x-od-workspace-can-write-synced-files': flags['can-write-synced-files'].trim() } + : {}), + }; + async function request(path, init) { + const resp = await fetch(`${base}${path}`, { + ...init, + headers: { + ...workspaceHeaders, + ...(init?.headers ?? {}), + }, + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + console.error(`${init?.method ?? 'GET'} ${path} failed: ${resp.status} ${JSON.stringify(data)}`); + process.exit(1); + } + return data; + } + switch (sub) { + case 'list': { + const params = new URLSearchParams(); + if (flags.view) params.set('view', String(flags.view)); + if (flags.visibility) params.set('visibility', String(flags.visibility)); + if (flags.owner) params.set('owner', String(flags.owner)); + const suffix = params.toString() ? `?${params}` : ''; + const data = await request(`/api/workspaces/${encodeURIComponent(workspaceId)}/projects${suffix}`); + if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); + const projects = data?.projects ?? []; + if (projects.length === 0) { + console.log('No workspace projects.'); + return; + } + for (const p of projects) { + console.log(`${p.id}\t${p.visibility}\t${p.resourceState}\t${p.name}`); + } + return; + } + case 'move': { + const projectId = positionalArgs(rest, WORKSPACE_STRING_FLAGS)[0]; + const visibility = String(flags.visibility ?? ''); + if (!projectId || !['personal', 'team'].includes(visibility)) { + console.error('Usage: od workspace projects move <projectId> --workspace <id> --visibility personal|team [--json]'); + process.exit(2); + } + const data = await request(`/api/workspaces/${encodeURIComponent(workspaceId)}/projects/${encodeURIComponent(projectId)}/move`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ visibility }), + }); + if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); + console.log(`[workspace] moved ${projectId} to ${visibility}`); + return; + } + case 'batch-delete': { + if (projectIds.length === 0) { + console.error('Usage: od workspace projects batch-delete --workspace <id> --project <id> [--project <id> ...] [--json]'); + process.exit(2); + } + const data = await request(`/api/workspaces/${encodeURIComponent(workspaceId)}/projects/batch-delete`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ projectIds }), + }); + if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); + console.log(`[workspace] deleted ${projectIds.length} project(s)`); + return; + } + case 'batch-move': { + const visibility = String(flags.visibility ?? ''); + if (projectIds.length === 0 || !['personal', 'team'].includes(visibility)) { + console.error('Usage: od workspace projects batch-move --workspace <id> --visibility personal|team --project <id> [--project <id> ...] [--json]'); + process.exit(2); + } + const data = await request(`/api/workspaces/${encodeURIComponent(workspaceId)}/projects/batch-move`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ projectIds, visibility }), + }); + if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); + console.log(`[workspace] moved ${projectIds.length} project(s) to ${visibility}`); + return; + } + default: + console.error(`unknown subcommand: od workspace projects ${sub}`); + process.exit(2); + } +} + async function runRun(args) { if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) { console.log(`Usage: od run start --project <projectId> [--conversation <id>] [--message "<text>"] [--plugin <id>] [--inputs <json>] [--grant-caps a,b] - [--agent claude|codex|opencode] [--model <id>] [--service-tier <id>] [--follow] [--json] + [--agent claude|codex|opencode] [--model <id>] [--service-tier <id>] + [--workspace <id> --workspace-member <id>] [--follow] [--json] od run redesign [--path <folder>] [--message "<text>" | --prompt-file <path|->] [--agent claude] [--model <id>] [--service-tier <id>] [--follow] [--json] od run watch <runId> ND-JSON event stream on stdout. @@ -6340,20 +7071,26 @@ async function runRun(args) { provenance without applying them. Common options: - --daemon-url <url> Open Design daemon HTTP base. - --json Emit raw JSON.`); + --daemon-url <url> Open Design daemon HTTP base. + --workspace <id> Explicit Workspace id for a bound project. + --workspace-member <id> Explicit Workspace member id for a bound project. + --json Emit raw JSON.`); process.exit(args.length === 0 ? 2 : 0); } const sub = args[0]; const rest = args.slice(1); - const flags = parseFlags(rest, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS }); + const flags = parseFlags(rest, { + string: PROJECT_RESOURCE_STRING_FLAGS, + boolean: PROJECT_BOOLEAN_FLAGS, + }); const base = (await projectDaemonUrl(flags)).replace(/\/$/, ''); + const workspaceHeaders = workspaceHeadersFromExplicitFlags(flags) ?? {}; switch (sub) { case 'list': { const url = flags.project ? `${base}/api/runs?projectId=${encodeURIComponent(flags.project)}` : `${base}/api/runs`; - const resp = await fetch(url); + const resp = await fetch(url, { headers: workspaceHeaders }); if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); @@ -6364,24 +7101,28 @@ Common options: return; } case 'info': { - const id = rest.find((a) => !a.startsWith('-')); + const id = positionalArgs(rest, PROJECT_RESOURCE_STRING_FLAGS)[0]; if (!id) { console.error('Usage: od run info <runId>'); process.exit(2); } - const resp = await fetch(`${base}/api/runs/${encodeURIComponent(id)}`); + const resp = await fetch(`${base}/api/runs/${encodeURIComponent(id)}`, { + headers: workspaceHeaders, + }); if (!resp.ok) return structuredHttpFailure(resp, 'run-not-found'); const data = await resp.json(); process.stdout.write(JSON.stringify(data, null, 2) + '\n'); return; } case 'result-package': { - const id = rest.find((a) => !a.startsWith('-')); + const id = positionalArgs(rest, PROJECT_RESOURCE_STRING_FLAGS)[0]; if (!id) { console.error('Usage: od run result-package <runId> [--json]'); process.exit(2); } - const resp = await fetch(`${base}/api/runs/${encodeURIComponent(id)}/result-package`); + const resp = await fetch(`${base}/api/runs/${encodeURIComponent(id)}/result-package`, { + headers: workspaceHeaders, + }); if (!resp.ok) return structuredHttpFailure(resp, 'run-not-found'); const data = await resp.json(); if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); @@ -6400,23 +7141,28 @@ Common options: return; } case 'cancel': { - const id = rest.find((a) => !a.startsWith('-')); + const id = positionalArgs(rest, PROJECT_RESOURCE_STRING_FLAGS)[0]; if (!id) { console.error('Usage: od run cancel <runId>'); process.exit(2); } - const resp = await fetch(`${base}/api/runs/${encodeURIComponent(id)}/cancel`, { method: 'POST' }); + const resp = await fetch(`${base}/api/runs/${encodeURIComponent(id)}/cancel`, { + method: 'POST', + headers: workspaceHeaders, + }); if (!resp.ok) return structuredHttpFailure(resp, 'run-not-found'); console.log(`[run] cancelled ${id}`); return; } case 'continue': { - const id = positionalArgs(rest, PROJECT_STRING_FLAGS)[0]; + const id = positionalArgs(rest, PROJECT_RESOURCE_STRING_FLAGS)[0]; if (!id) { console.error('Usage: od run continue <runId> [--message "<text>"] [--follow] [--json]'); process.exit(2); } - const statusResp = await fetch(`${base}/api/runs/${encodeURIComponent(id)}`); + const statusResp = await fetch(`${base}/api/runs/${encodeURIComponent(id)}`, { + headers: workspaceHeaders, + }); if (!statusResp.ok) return structuredHttpFailure(statusResp, 'run-not-found'); const status = await statusResp.json(); if (status?.resumable !== true) { @@ -6449,7 +7195,7 @@ Common options: analyticsHints: { entryFrom: 'resume_continue' }, ...(status.agentId ? { agentId: status.agentId } : {}), }; - const data = await postJsonToDaemon(base, '/api/runs', body); + const data = await postJsonToDaemon(base, '/api/runs', body, workspaceHeaders); if (flags.json && !flags.follow) { return process.stdout.write(JSON.stringify({ ...data, @@ -6457,20 +7203,20 @@ Common options: }, null, 2) + '\n'); } console.log(`[run] continued ${id} as ${data.runId}`); - if (flags.follow) await streamRunEvents(base, data.runId); + if (flags.follow) await streamRunEvents(base, data.runId, workspaceHeaders); return; } case 'watch': { - const id = rest.find((a) => !a.startsWith('-')); + const id = positionalArgs(rest, PROJECT_RESOURCE_STRING_FLAGS)[0]; if (!id) { console.error('Usage: od run watch <runId>'); process.exit(2); } - await streamRunEvents(base, id); + await streamRunEvents(base, id, workspaceHeaders); return; } case 'redesign': { - const parts = collectCliPositionals(rest, PROJECT_STRING_FLAGS); + const parts = collectCliPositionals(rest, PROJECT_RESOURCE_STRING_FLAGS); const promptFromArgs = parts.join(' ').trim(); const defaultMessage = 'Use the redesign-existing-projects skill. Audit the current UI first, then redesign it to premium quality without breaking functionality. Preserve the existing product structure, routes, and behavior.'; @@ -6493,7 +7239,7 @@ Common options: : await basenameForCli(folderPath), skillId, designSystemId, - }, folderPath); + }, folderPath, workspaceHeaders); projectId = imported.project?.id; conversationId = conversationId ?? imported.conversationId; if (!projectId) { @@ -6515,7 +7261,7 @@ Common options: ...(flags.model ? { model: flags.model } : {}), ...(flags['service-tier'] ? { serviceTier: flags['service-tier'] } : {}), }; - const data = await postJsonToDaemon(base, '/api/runs', body); + const data = await postJsonToDaemon(base, '/api/runs', body, workspaceHeaders); if (flags.json && !flags.follow) { return process.stdout.write(JSON.stringify({ ...data, @@ -6524,7 +7270,7 @@ Common options: }, null, 2) + '\n'); } console.log(`[run] started ${data.runId}`); - if (flags.follow) await streamRunEvents(base, data.runId); + if (flags.follow) await streamRunEvents(base, data.runId, workspaceHeaders); return; } case 'start': { @@ -6554,7 +7300,7 @@ Common options: if (flags['snapshot-id']) body.appliedPluginSnapshotId = flags['snapshot-id']; const resp = await fetch(`${base}/api/runs`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', ...workspaceHeaders }, body: JSON.stringify(body), }); const data = await resp.json().catch(() => ({})); @@ -6580,7 +7326,7 @@ Common options: return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); } console.log(`[run] started ${data.runId}`); - if (flags.follow) await streamRunEvents(base, data.runId); + if (flags.follow) await streamRunEvents(base, data.runId, workspaceHeaders); return; } default: @@ -6592,9 +7338,9 @@ Common options: // Stream the SSE events at /api/runs/:id/events as ND-JSON on stdout. // Each line is one event: { event, data } so a code agent can parse it // without needing an SSE library. -async function streamRunEvents(base, runId) { +async function streamRunEvents(base, runId, workspaceHeaders = {}) { const resp = await fetch(`${base}/api/runs/${encodeURIComponent(runId)}/events`, { - headers: { accept: 'text/event-stream' }, + headers: { accept: 'text/event-stream', ...workspaceHeaders }, }); if (!resp.ok || !resp.body) { console.error(`run watch failed: ${resp.status}`); @@ -6644,12 +7390,16 @@ Common options: (does not attach).`); process.exit(args.length === 0 ? 2 : 0); } - const flags = parseFlags(args, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS }); + const flags = parseFlags(args, { + string: PROJECT_RESOURCE_STRING_FLAGS, + boolean: PROJECT_BOOLEAN_FLAGS, + }); if (!flags.project) { console.error('--project <projectId> is required'); process.exit(2); } const base = (await projectDaemonUrl(flags)).replace(/\/$/, ''); + const workspaceHeaders = workspaceHeadersFromExplicitFlags(flags) ?? {}; const body = {}; if (flags.shell) body.shell = flags.shell; if (process.stdout.columns) body.cols = process.stdout.columns; @@ -6658,7 +7408,7 @@ Common options: `${base}/api/projects/${encodeURIComponent(flags.project)}/terminals`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', ...workspaceHeaders }, body: JSON.stringify(body), }, ); @@ -6672,13 +7422,13 @@ Common options: console.error('terminal create returned no id'); process.exit(1); } - await attachTerminal(base, flags.project, terminalId); + await attachTerminal(base, flags.project, terminalId, workspaceHeaders); } // Bridge a local TTY to a remote PTY session: SSE `data` events → stdout, // local stdin bytes → POST /stdin, terminal resize → POST /resize. Resolves // when the remote shell emits its `exit` event. -async function attachTerminal(base, projectId, terminalId) { +async function attachTerminal(base, projectId, terminalId, workspaceHeaders = {}) { const termPath = `${base}/api/projects/${encodeURIComponent(projectId)}/terminals/${encodeURIComponent(terminalId)}`; const isRawTty = Boolean(process.stdin.isTTY && process.stdin.setRawMode); if (isRawTty) process.stdin.setRawMode(true); @@ -6687,7 +7437,7 @@ async function attachTerminal(base, projectId, terminalId) { const onInput = (chunk) => { fetch(`${termPath}/stdin`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', ...workspaceHeaders }, body: JSON.stringify({ data: chunk.toString('utf8') }), }).catch(() => {}); }; @@ -6696,7 +7446,7 @@ async function attachTerminal(base, projectId, terminalId) { const onResize = () => { fetch(`${termPath}/resize`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', ...workspaceHeaders }, body: JSON.stringify({ cols: process.stdout.columns, rows: process.stdout.rows }), }).catch(() => {}); }; @@ -6712,7 +7462,9 @@ async function attachTerminal(base, projectId, terminalId) { }; try { - const resp = await fetch(`${termPath}/stream`, { headers: { accept: 'text/event-stream' } }); + const resp = await fetch(`${termPath}/stream`, { + headers: { accept: 'text/event-stream', ...workspaceHeaders }, + }); if (!resp.ok || !resp.body) { console.error(`shell attach failed: ${resp.status}`); process.exit(1); @@ -6776,6 +7528,9 @@ async function runFiles(args) { Common options: --daemon-url <url> Open Design daemon HTTP base. + --workspace <id> Exact Workspace for bound project requests. + --workspace-member <id> + Exact caller membership for bound project requests. --prompt-file <path|-> Read a version prompt from file/stdin where supported. --source <ai|manual|restore> Version provenance where supported. @@ -6784,16 +7539,23 @@ Common options: } const sub = args[0]; const rest = args.slice(1); - const flags = parseFlags(rest, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS }); + const flags = parseFlags(rest, { + string: PROJECT_RESOURCE_STRING_FLAGS, + boolean: PROJECT_BOOLEAN_FLAGS, + }); const base = (await projectDaemonUrl(flags)).replace(/\/$/, ''); + const workspaceHeaders = + workspaceHeadersFromExplicitFlags(flags) ?? {}; switch (sub) { case 'list': { - const id = rest.find((a) => !a.startsWith('-')); + const id = positionalArgs(rest, PROJECT_RESOURCE_STRING_FLAGS)[0]; if (!id) { console.error('Usage: od files list <projectId>'); process.exit(2); } - const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/files`); + const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/files`, { + headers: workspaceHeaders, + }); if (!resp.ok) return structuredHttpFailure(resp, 'project-not-found'); const data = await resp.json(); if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); @@ -6802,21 +7564,23 @@ Common options: return; } case 'read': { - const positional = rest.filter((a) => !a.startsWith('-')); + const positional = positionalArgs(rest, PROJECT_RESOURCE_STRING_FLAGS); const [id, rel] = positional; if (!id || !rel) { console.error('Usage: od files read <projectId> <relpath>'); process.exit(2); } - const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/files/${rel.split('/').map(encodeURIComponent).join('/')}`); + const resp = await fetch( + `${base}/api/projects/${encodeURIComponent(id)}/files/${rel.split('/').map(encodeURIComponent).join('/')}`, + { headers: workspaceHeaders }, + ); if (!resp.ok) return structuredHttpFailure(resp, 'project-not-found'); const buf = Buffer.from(await resp.arrayBuffer()); process.stdout.write(buf); return; } case 'upload': { - const positional = rest.filter((a) => !a.startsWith('-') - && a !== flags.as); + const positional = positionalArgs(rest, PROJECT_RESOURCE_STRING_FLAGS); const [id, localPath] = positional; if (!id || !localPath) { console.error('Usage: od files upload <projectId> <localpath> [--as <relpath>]'); @@ -6828,7 +7592,7 @@ Common options: : basename(localPath); const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/files`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', ...workspaceHeaders }, body: JSON.stringify({ name: desiredName, content: buf.toString('base64'), @@ -6843,7 +7607,7 @@ Common options: return; } case 'write': { - const positional = rest.filter((a) => !a.startsWith('-')); + const positional = positionalArgs(rest, PROJECT_RESOURCE_STRING_FLAGS); const [id, rel] = positional; if (!id || !rel) { console.error('Usage: od files write <projectId> <relpath> [< stdin]'); @@ -6861,7 +7625,7 @@ Common options: const body = Buffer.concat(chunks); const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/files`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', ...workspaceHeaders }, body: JSON.stringify({ name: rel, content: body.toString('utf8'), @@ -6876,37 +7640,40 @@ Common options: return; } case 'delete': { - const positional = rest.filter((a) => !a.startsWith('-')); + const positional = positionalArgs(rest, PROJECT_RESOURCE_STRING_FLAGS); const [id, name] = positional; if (!id || !name) { console.error('Usage: od files delete <projectId> <name>'); process.exit(2); } - const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/files/${encodeURIComponent(name)}`, { method: 'DELETE' }); + const resp = await fetch( + `${base}/api/projects/${encodeURIComponent(id)}/files/${encodeURIComponent(name)}`, + { method: 'DELETE', headers: workspaceHeaders }, + ); if (!resp.ok) return structuredHttpFailure(resp); console.log(`[files] deleted ${name}`); return; } case 'diff': { - const positional = positionalArgs(rest, PROJECT_STRING_FLAGS); + const positional = positionalArgs(rest, PROJECT_RESOURCE_STRING_FLAGS); const [id, relA, relB] = positional; const against = typeof flags.against === 'string' ? flags.against : null; if (!id || !relA || (!relB && !against) || (relB && against)) { console.error('Usage: od files diff <projectId> <relpathA> [<relpathB> | --against -]'); process.exit(2); } - const left = await fetchProjectFileText(base, id, relA); + const left = await fetchProjectFileText(base, id, relA, workspaceHeaders); const rightLabel = against ?? relB; const right = against === '-' ? await readStdinUtf8() - : await fetchProjectFileText(base, id, rightLabel); + : await fetchProjectFileText(base, id, rightLabel, workspaceHeaders); const diff = createUnifiedDiff(`a/${relA}`, `b/${rightLabel}`, left, right); if (flags.json) return process.stdout.write(JSON.stringify({ diff }, null, 2) + '\n'); process.stdout.write(diff); return; } case 'versions': { - const positional = positionalArgs(rest, PROJECT_STRING_FLAGS); + const positional = positionalArgs(rest, PROJECT_RESOURCE_STRING_FLAGS); const [id, rel] = positional; if (!id || !rel) { console.error('Usage: od files versions <projectId> <relpath>'); @@ -6914,6 +7681,7 @@ Common options: } const resp = await fetch( `${base}/api/projects/${encodeURIComponent(id)}/files/${encodeProjectRelpath(rel)}/versions`, + { headers: workspaceHeaders }, ); if (!resp.ok) return structuredHttpFailure(resp, 'project-not-found'); const data = await resp.json(); @@ -6932,7 +7700,7 @@ Common options: return; } case 'version-read': { - const positional = positionalArgs(rest, PROJECT_STRING_FLAGS); + const positional = positionalArgs(rest, PROJECT_RESOURCE_STRING_FLAGS); const [id, rel, versionId] = positional; if (!id || !rel || !versionId) { console.error('Usage: od files version-read <projectId> <relpath> <versionId>'); @@ -6940,6 +7708,7 @@ Common options: } const resp = await fetch( `${base}/api/projects/${encodeURIComponent(id)}/files/${encodeProjectRelpath(rel)}/versions/${encodeURIComponent(versionId)}`, + { headers: workspaceHeaders }, ); if (!resp.ok) return structuredHttpFailure(resp, 'project-not-found'); const data = await resp.json(); @@ -6948,7 +7717,7 @@ Common options: return; } case 'version-create': { - const positional = positionalArgs(rest, PROJECT_STRING_FLAGS); + const positional = positionalArgs(rest, PROJECT_RESOURCE_STRING_FLAGS); const [id, rel] = positional; if (!id || !rel) { console.error('Usage: od files version-create <projectId> <relpath> [--prompt <text> | --prompt-file <path|->] [--label <text>] [--source <ai|manual|restore>]'); @@ -6964,7 +7733,7 @@ Common options: `${base}/api/projects/${encodeURIComponent(id)}/files/${encodeProjectRelpath(rel)}/versions`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', ...workspaceHeaders }, body: JSON.stringify(body), }, ); @@ -6975,7 +7744,7 @@ Common options: return; } case 'version-restore': { - const positional = positionalArgs(rest, PROJECT_STRING_FLAGS); + const positional = positionalArgs(rest, PROJECT_RESOURCE_STRING_FLAGS); const [id, rel, versionId] = positional; if (!id || !rel || !versionId) { console.error('Usage: od files version-restore <projectId> <relpath> <versionId> [--prompt <text> | --prompt-file <path|->]'); @@ -6988,7 +7757,7 @@ Common options: `${base}/api/projects/${encodeURIComponent(id)}/files/${encodeProjectRelpath(rel)}/versions/${encodeURIComponent(versionId)}/restore`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', ...workspaceHeaders }, body: JSON.stringify(body), }, ); @@ -7009,9 +7778,10 @@ function encodeProjectRelpath(rel) { return String(rel).split('/').map(encodeURIComponent).join('/'); } -async function fetchProjectFileText(base, id, rel) { +async function fetchProjectFileText(base, id, rel, headers = {}) { const resp = await fetch( `${base}/api/projects/${encodeURIComponent(id)}/files/${encodeProjectRelpath(rel)}`, + { headers }, ); if (!resp.ok) return structuredHttpFailure(resp, 'project-not-found'); const buf = Buffer.from(await resp.arrayBuffer()); @@ -7322,17 +8092,30 @@ async function runConversation(args) { od conversation info <conversationId> Print one conversation. Common options: - --daemon-url <url> Open Design daemon HTTP base. - --json Emit raw JSON.`); + --daemon-url <url> Open Design daemon HTTP base. + --workspace <id> Explicit Workspace id for a bound project. + --workspace-member <id> Explicit Workspace member id for a bound project. + --json Emit raw JSON.`); process.exit(args.length === 0 ? 2 : 0); } const sub = args[0]; const rest = args.slice(1); - const flags = parseFlags(rest, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS }); + const conversationStringFlags = + sub === 'new' || sub === 'list' + ? PROJECT_RESOURCE_STRING_FLAGS + : PROJECT_STRING_FLAGS; + const flags = parseFlags(rest, { + string: conversationStringFlags, + boolean: PROJECT_BOOLEAN_FLAGS, + }); const base = (await projectDaemonUrl(flags)).replace(/\/$/, ''); + const workspaceHeaders = + sub === 'new' || sub === 'list' + ? workspaceHeadersFromExplicitFlags(flags) ?? {} + : {}; switch (sub) { case 'new': { - const [id] = positionalArgs(rest, PROJECT_STRING_FLAGS); + const [id] = positionalArgs(rest, conversationStringFlags); if (!id) { console.error('Usage: od conversation new <projectId> [--title "<title>"] [--seed-from <cid>] [--fork-after <mid>]'); process.exit(2); @@ -7353,7 +8136,7 @@ Common options: } const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/conversations`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', ...workspaceHeaders }, body: JSON.stringify(body), }); if (!resp.ok) return structuredHttpFailure(resp, 'project-not-found'); @@ -7364,12 +8147,14 @@ Common options: return; } case 'list': { - const id = rest.find((a) => !a.startsWith('-')); + const id = positionalArgs(rest, conversationStringFlags)[0]; if (!id) { console.error('Usage: od conversation list <projectId>'); process.exit(2); } - const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/conversations`); + const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/conversations`, { + headers: workspaceHeaders, + }); if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); process.stdout.write(JSON.stringify(data, null, 2) + '\n'); @@ -7415,21 +8200,27 @@ async function runChat(args) { message. Common options: - --daemon-url <url> Open Design daemon HTTP base. - --json Emit raw JSON.`); + --daemon-url <url> Open Design daemon HTTP base. + --workspace <id> Explicit Workspace id for the bound project. + --workspace-member <id> Explicit Workspace member id for the bound project. + --json Emit raw JSON.`); process.exit(args.length === 0 ? 2 : 0); } const sub = args[0]; const rest = args.slice(1); - const flags = parseFlags(rest, { string: PROJECT_STRING_FLAGS, boolean: PROJECT_BOOLEAN_FLAGS }); + const flags = parseFlags(rest, { + string: PROJECT_RESOURCE_STRING_FLAGS, + boolean: PROJECT_BOOLEAN_FLAGS, + }); const base = (await projectDaemonUrl(flags)).replace(/\/$/, ''); + const workspaceHeaders = workspaceHeadersFromExplicitFlags(flags) ?? {}; switch (sub) { case 'new': { // Accept --project for parity with the rest of the project-scoped CLI, // or a bare positional id for convenience. const id = typeof flags.project === 'string' && flags.project ? flags.project - : positionalArgs(rest, PROJECT_STRING_FLAGS)[0]; + : positionalArgs(rest, PROJECT_RESOURCE_STRING_FLAGS)[0]; if (!id) { console.error('Usage: od chat new --project <id> [--seed-from <cid>] [--fork-after <mid>] [--title "<title>"]'); process.exit(2); @@ -7450,7 +8241,7 @@ Common options: } const resp = await fetch(`${base}/api/projects/${encodeURIComponent(id)}/conversations`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', ...workspaceHeaders }, body: JSON.stringify(body), }); if (!resp.ok) return structuredHttpFailure(resp, 'project-not-found'); @@ -7810,6 +8601,8 @@ Options: --date <YYYY-MM-DD> Filter by archive date. --project <id> Target project for apply. --dir <subdir> Subdirectory inside the project for apply (default: library). + --workspace <id> Explicit Workspace id for a bound target project. + --workspace-member <id> Explicit Workspace member id for a bound target project. --out <file> Write the figma export to a file (default: stdout).`); } @@ -7950,9 +8743,10 @@ async function runLibrary(args) { } const body = { projectId: flags.project }; if (flags.dir) body.dir = flags.dir; + const workspaceHeaders = workspaceHeadersFromExplicitFlags(flags) ?? {}; const resp = await fetch(`${base}/api/library/assets/${encodeURIComponent(id)}/apply`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', ...workspaceHeaders }, body: JSON.stringify(body), }); if (!resp.ok) return structuredHttpFailure(resp); @@ -8043,9 +8837,16 @@ async function runLibraryList(name, args) { const flags = parseFlags(rest, { string: LIBRARY_STRING_FLAGS, boolean: LIBRARY_BOOLEAN_FLAGS }); const base = (await libraryDaemonUrl(flags)).replace(/\/$/, ''); const apiPath = name === 'design-systems' ? '/api/design-systems' : `/api/${name}`; + const designSystemWorkspaceHeaders = name === 'design-systems' + ? workspaceHeadersFromExplicitFlags(flags) ?? {} + : undefined; switch (sub) { case 'list': { - const resp = await fetch(`${base}${apiPath}`); + const resp = await fetch(`${base}${apiPath}`, { + ...(designSystemWorkspaceHeaders + ? { headers: designSystemWorkspaceHeaders } + : {}), + }); if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n'); @@ -8057,12 +8858,16 @@ async function runLibraryList(name, args) { return; } case 'show': { - const id = rest.find((a) => !a.startsWith('-')); + const id = positionalArgs(rest, LIBRARY_STRING_FLAGS)[0]; if (!id) { console.error(`Usage: od ${name} show <id>`); process.exit(2); } - const resp = await fetch(`${base}${apiPath}/${encodeURIComponent(id)}`); + const resp = await fetch(`${base}${apiPath}/${encodeURIComponent(id)}`, { + ...(designSystemWorkspaceHeaders + ? { headers: designSystemWorkspaceHeaders } + : {}), + }); if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json(); process.stdout.write(JSON.stringify(data, null, 2) + '\n'); @@ -8074,7 +8879,90 @@ async function runLibraryList(name, args) { } } -async function runSkills(args) { return runLibraryList('skills', args); } +// `od skills` lists; `od skills uninstall <id>` removes a user-installed skill. +// The uninstall arm exists because the Extensions page grew a 卸载 action, and a +// capability that only one surface can reach is not shippable (AGENTS.md, +// "Capability exposure (UI/CLI dual-track)"). Bundled skills are refused by the +// route, not here — the daemon owns that judgement. +async function runSkills(args) { + if (!args[0] || args[0] === 'help' || args.includes('--help') || args.includes('-h')) { + console.log(`Usage: + od skill install <https://github.com/owner/repo|github:owner/repo|https://…tar.gz|https://…tgz> [--json] + od skill list + od skill show <id> + od skill uninstall <id> + +\`od skills …\` remains an alias for compatibility.`); + process.exit(args[0] ? 0 : 2); + } + if (args[0] === 'install' || args[0] === 'add') return runSkillInstall(args.slice(1)); + if (args[0] === 'uninstall' || args[0] === 'remove') return runSkillUninstall(args.slice(1)); + return runLibraryList('skills', args); +} + +async function runSkillInstall(rest) { + const flags = parseFlags(rest, { + string: LIBRARY_STRING_FLAGS, + boolean: LIBRARY_BOOLEAN_FLAGS, + }); + const source = positionalArgs(rest, LIBRARY_STRING_FLAGS)[0]; + if (!source) { + console.error( + 'Usage: od skill install <https://github.com/owner/repo|github:owner/repo|https://…tar.gz|https://…tgz> [--json] [--daemon-url <url>]', + ); + process.exit(2); + } + const base = (await libraryDaemonUrl(flags)).replace(/\/$/, ''); + try { + const resp = await fetch(`${base}/api/skills/install`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ source }), + }); + const body = await resp.json().catch(() => ({})); + if (!resp.ok) { + const message = body?.error ?? `Skill install failed (${resp.status})`; + if (flags.json) { + console.log(JSON.stringify({ + ok: false, + status: resp.status, + code: body?.code ?? null, + error: message, + })); + } else { + console.error(`POST /api/skills/install failed: ${resp.status} ${message}`); + } + process.exit(1); + } + if (flags.json) return process.stdout.write(JSON.stringify(body, null, 2) + '\n'); + console.log(`[install] ${body?.skill?.id ?? body?.skill?.name ?? source}`); + } catch (err) { + surfaceFetchError(err, base); + process.exit(3); + } +} + +async function runSkillUninstall(rest) { + const flags = parseFlags(rest, { string: LIBRARY_STRING_FLAGS, boolean: LIBRARY_BOOLEAN_FLAGS }); + const id = positionalArgs(rest, LIBRARY_STRING_FLAGS)[0]; + if (!id) { + console.error('Usage: od skills uninstall <id> [--json] [--daemon-url <url>]'); + process.exit(2); + } + const base = (await libraryDaemonUrl(flags)).replace(/\/$/, ''); + const resp = await fetch(`${base}/api/skills/${encodeURIComponent(id)}`, { method: 'DELETE' }); + const body = await resp.json().catch(() => ({})); + if (!resp.ok) { + if (flags.json) { + console.log(JSON.stringify({ ok: false, id, status: resp.status, error: body?.error ?? null })); + } else { + console.error(`DELETE /api/skills/${id} failed: ${resp.status} ${body?.error ?? ''}`.trim()); + } + process.exit(1); + } + if (flags.json) console.log(JSON.stringify({ ok: true, id })); + else console.log(`[uninstall] ${id} removed`); +} async function runCraft(args) { return runLibraryList('craft', args); } async function runDesignSystems(args) { @@ -8111,6 +8999,7 @@ generated SKILLS.md usage guide). } const stringFlags = new Set([...LIBRARY_STRING_FLAGS, 'out']); const flags = parseFlags(args, { string: stringFlags, boolean: LIBRARY_BOOLEAN_FLAGS }); + const workspaceHeaders = workspaceHeadersFromExplicitFlags(flags) ?? {}; const id = positionalArgs(args, stringFlags)[0]; if (!id) { console.error('Usage: od design-systems download <id> [--out <path>]'); @@ -8119,7 +9008,9 @@ generated SKILLS.md usage guide). const base = (await libraryDaemonUrl(flags)).replace(/\/$/, ''); let resp; try { - resp = await fetch(`${base}/api/design-systems/${encodeURIComponent(id)}/archive`); + resp = await fetch(`${base}/api/design-systems/${encodeURIComponent(id)}/archive`, { + headers: workspaceHeaders, + }); } catch (err) { surfaceFetchError(err, base); process.exit(3); @@ -8234,9 +9125,10 @@ function designSystemImportRequestBody(flags, baseBody) { async function postDesignSystemImport(flags, endpoint, body) { const base = (await libraryDaemonUrl(flags)).replace(/\/$/, ''); + const workspaceHeaders = workspaceHeadersFromExplicitFlags(flags) ?? {}; const resp = await fetch(`${base}${endpoint}`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...workspaceHeaders }, body: JSON.stringify(body), }); if (!resp.ok) return structuredHttpFailure(resp); @@ -8277,9 +9169,10 @@ Starts a review-gated TOKEN_SCHEMA token contract rebuild for an editable import process.exit(2); } const base = (await libraryDaemonUrl(flags)).replace(/\/$/, ''); + const workspaceHeaders = workspaceHeadersFromExplicitFlags(flags) ?? {}; const resp = await fetch(`${base}/api/design-systems/${encodeURIComponent(id)}/token-contract/rebuild-jobs`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...workspaceHeaders }, body: JSON.stringify({ force: flags.force === true }), }); if (!resp.ok) return structuredHttpFailure(resp); @@ -8350,9 +9243,10 @@ Renames an editable (user-created) design system. Built-in systems are read-only boolean: LIBRARY_BOOLEAN_FLAGS, }); const base = (await libraryDaemonUrl(flags)).replace(/\/$/, ''); + const workspaceHeaders = workspaceHeadersFromExplicitFlags(flags) ?? {}; const resp = await fetch(`${base}/api/design-systems/${encodeURIComponent(parsed.id)}`, { method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...workspaceHeaders }, body: JSON.stringify({ title: parsed.title }), }); if (!resp.ok) return structuredHttpFailure(resp); @@ -8551,9 +9445,14 @@ or the daemon cannot be reached.`); // Library inventory try { + const designSystemWorkspaceHeaders = null; const [skillsResp, dsResp, atomsResp] = await Promise.all([ fetch(`${base}/api/skills`), - fetch(`${base}/api/design-systems`), + fetch(`${base}/api/design-systems`, { + ...(designSystemWorkspaceHeaders + ? { headers: designSystemWorkspaceHeaders } + : {}), + }), fetch(`${base}/api/atoms`), ]); if (skillsResp.ok) { @@ -10342,6 +11241,8 @@ Options: --cf-zone-id <id> Cloudflare Pages: zone id. --cf-zone-name <name> Cloudflare Pages: zone name. --cf-domain-prefix <prefix> Cloudflare Pages: domain prefix. + --workspace <id> Explicit Workspace id for a bound project. + --workspace-member <id> Explicit Workspace member id for a bound project. --json Emit raw JSON response. --daemon-url <url> Open Design daemon HTTP base.`); return; @@ -10386,11 +11287,12 @@ Options: } const base = await cliDaemonBaseUrl(flags); + const workspaceHeaders = workspaceHeadersFromExplicitFlags(flags) ?? {}; let resp; try { resp = await fetch(`${base}/api/projects/${encodeURIComponent(projectId)}/deploy`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', ...workspaceHeaders }, body: JSON.stringify(body), }); } catch (err) { diff --git a/apps/daemon/src/collab/active-workspace-selection.ts b/apps/daemon/src/collab/active-workspace-selection.ts new file mode 100644 index 00000000000..da333a3fde0 --- /dev/null +++ b/apps/daemon/src/collab/active-workspace-selection.ts @@ -0,0 +1,113 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +interface ActiveWorkspaceSelectionFile { + workspaceId?: unknown; +} + +export interface ActiveWorkspaceSelectionStore { + get(): string | null; + snapshot(): { workspaceId: string | null; generation: number }; + set(workspaceId: string): Promise<void>; + clear(): Promise<void>; + subscribe(listener: (workspaceId: string | null) => void): () => void; +} + +interface AuthorizationWorkspaceContextSnapshot { + context: { + workspaceId: string; + teamId?: string | undefined; + workspaceMemberId: string; + workspaceType: string; + memberStatus: string; + lifecycleState: string; + } | null; + generation: number; +} + +export function resolveAuthorizedActiveTeamWorkspaceSnapshot( + selection: { workspaceId: string | null; generation: number }, + observed: AuthorizationWorkspaceContextSnapshot, +): { workspaceId: string | null; generation: number } { + const context = observed.context; + const activeTeamWorkspaceId = + context?.workspaceType === 'team' && + context.memberStatus === 'active' && + context.lifecycleState === 'active' && + Boolean(context.teamId?.trim()) && + Boolean(context.workspaceMemberId.trim()) + ? context.workspaceId + : null; + const pinMatches = + selection.workspaceId == null || + selection.workspaceId === activeTeamWorkspaceId; + return { + workspaceId: pinMatches ? activeTeamWorkspaceId : null, + generation: selection.generation + observed.generation, + }; +} + +export function createActiveWorkspaceSelectionStore( + dataDir: string, +): ActiveWorkspaceSelectionStore { + const filePath = path.join(dataDir, 'workspace-selection.json'); + let cached: string | null | undefined; + let generation = 0; + const listeners = new Set<(workspaceId: string | null) => void>(); + + const read = (): string | null => { + if (cached !== undefined) return cached; + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(raw) as ActiveWorkspaceSelectionFile; + cached = typeof parsed.workspaceId === 'string' && parsed.workspaceId.trim() + ? parsed.workspaceId.trim() + : null; + } catch { + cached = null; + } + return cached; + }; + + const notify = (workspaceId: string | null) => { + for (const listener of listeners) { + try { + listener(workspaceId); + } catch { + // Selection persistence must not fail because one observer did. + } + } + }; + + return { + get: read, + snapshot() { + return { workspaceId: read(), generation }; + }, + async set(workspaceId: string) { + const next = workspaceId.trim(); + if (!next) throw new Error('workspaceId is required'); + cached = next; + generation += 1; + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + await fs.promises.writeFile( + filePath, + JSON.stringify({ workspaceId: next }, null, 2), + 'utf8', + ); + notify(next); + }, + async clear() { + cached = null; + generation += 1; + await fs.promises.rm(filePath, { force: true }); + notify(null); + }, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/apps/daemon/src/collab/authorized-team-project-pull.ts b/apps/daemon/src/collab/authorized-team-project-pull.ts new file mode 100644 index 00000000000..628676fca2c --- /dev/null +++ b/apps/daemon/src/collab/authorized-team-project-pull.ts @@ -0,0 +1,327 @@ +import { lstat, mkdtemp, readdir, rename, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +import { + runVelaCommand, + velaWorkspaceCommandOptions, +} from '../integrations/vela-command.js'; +import { projectResourceIdFor } from '../integrations/vela-team-projects.js'; +import type { TeamMirrorPullScope } from '../routes/collab-sync.js'; + +const AUTHORIZED_PULL_TIMEOUT_MS = 30_000; +const RECEIPT_MAX_AGE_MS = 2_000; +const MANIFEST_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u; + +class AuthorizedTeamProjectPullReceiptExpiredError extends Error { + readonly code = 'AUTHORIZED_TEAM_PROJECT_PULL_RECEIPT_EXPIRED'; +} + +export function isAuthorizedTeamProjectPullReceiptExpired( + error: unknown, +): boolean { + return error instanceof AuthorizedTeamProjectPullReceiptExpiredError; +} + +export interface AuthorizedTeamProjectPullReceipt { + schemaVersion: 1; + workspaceId: string; + resourceTeamId: string; + viewerMemberId: string; + ownerMemberId: string; + projectId: string; + resourceId: string; + ref: 'published'; + version: number; + versionId: string; + manifestDigest: string; + lifecycleState: 'active'; + authorizedAt: string; + expiresAt: string; +} + +export interface AuthorizedTeamProjectPullRunOptions { + signal?: AbortSignal; + timeoutMs: number; +} + +export type RunAuthorizedTeamProjectPull = ( + args: string[], + workspaceId: string, + options: AuthorizedTeamProjectPullRunOptions, +) => Promise<string>; + +export interface StageAuthorizedTeamProjectPullInput { + projectId: string; + liveDir: string; + scope: TeamMirrorPullScope; + expectedVersion: number; + signal?: AbortSignal; + run?: RunAuthorizedTeamProjectPull; + now?: () => number; + /** Test-only race seam. Production callers leave this unset. */ + cleanupHooks?: { + beforeQuarantineRename?: (stageDir: string) => void | Promise<void>; + }; +} + +export interface AuthorizedTeamProjectStageIdentity { + dev: string; + ino: string; +} + +export interface StagedAuthorizedTeamProjectPull { + stageDir: string; + identity: AuthorizedTeamProjectStageIdentity; + receipt: AuthorizedTeamProjectPullReceipt; + cleanup(): Promise<void>; +} + +interface ReceiptValidationInput { + projectId: string; + scope: TeamMirrorPullScope; + expectedVersion: number; + nowMs?: number; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function requiredString( + record: Record<string, unknown>, + key: string, +): string { + const value = record[key]; + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`authorized pull receipt has invalid ${key}`); + } + return value; +} + +function parseReceipt(stdout: string): AuthorizedTeamProjectPullReceipt { + let parsed: unknown; + try { + parsed = JSON.parse(stdout.trim()); + } catch { + throw new Error('authorized pull response is not valid JSON'); + } + if (!isRecord(parsed)) { + throw new Error('authorized pull response must be an object'); + } + const version = parsed.version; + if (!Number.isSafeInteger(version) || Number(version) < 0) { + throw new Error('authorized pull receipt has invalid version'); + } + return { + schemaVersion: parsed.schemaVersion as 1, + workspaceId: requiredString(parsed, 'workspaceId'), + resourceTeamId: requiredString(parsed, 'resourceTeamId'), + viewerMemberId: requiredString(parsed, 'viewerMemberId'), + ownerMemberId: requiredString(parsed, 'ownerMemberId'), + projectId: requiredString(parsed, 'projectId'), + resourceId: requiredString(parsed, 'resourceId'), + ref: parsed.ref as 'published', + version: Number(version), + versionId: requiredString(parsed, 'versionId'), + manifestDigest: requiredString(parsed, 'manifestDigest'), + lifecycleState: parsed.lifecycleState as 'active', + authorizedAt: requiredString(parsed, 'authorizedAt'), + expiresAt: requiredString(parsed, 'expiresAt'), + }; +} + +export function validateAuthorizedTeamProjectPullReceipt( + receipt: AuthorizedTeamProjectPullReceipt, + input: ReceiptValidationInput, +): void { + const expectedResourceId = projectResourceIdFor(input.projectId, { + teamId: input.scope.resourceTeamId, + memberId: input.scope.ownerMemberId, + role: 'member', + lifecycleState: 'active', + workspaceType: 'team', + }); + if (receipt.schemaVersion !== 1) { + throw new Error('authorized pull receipt has unsupported schemaVersion'); + } + if ( + receipt.workspaceId !== input.scope.workspaceId || + receipt.resourceTeamId !== input.scope.resourceTeamId || + receipt.viewerMemberId !== input.scope.viewerMemberId || + receipt.ownerMemberId !== input.scope.ownerMemberId || + receipt.projectId !== input.projectId || + receipt.resourceId !== expectedResourceId || + receipt.ref !== 'published' || + receipt.version !== input.expectedVersion + ) { + throw new Error('authorized pull receipt binding does not match the pull'); + } + if ( + !receipt.versionId.trim() || + !MANIFEST_DIGEST_PATTERN.test(receipt.manifestDigest) || + receipt.lifecycleState !== 'active' || + receipt.ownerMemberId === receipt.viewerMemberId + ) { + throw new Error('authorized pull receipt binding is incomplete'); + } + const authorizedAt = Date.parse(receipt.authorizedAt); + const expiresAt = Date.parse(receipt.expiresAt); + const nowMs = input.nowMs ?? Date.now(); + if ( + !Number.isFinite(authorizedAt) || + !Number.isFinite(expiresAt) || + expiresAt <= authorizedAt || + expiresAt - authorizedAt > RECEIPT_MAX_AGE_MS + ) { + throw new Error('authorized pull receipt is stale'); + } + if (nowMs >= expiresAt) { + throw new AuthorizedTeamProjectPullReceiptExpiredError( + 'authorized pull receipt is stale', + ); + } +} + +export function isAuthorizedTeamProjectPullUnavailable( + error: unknown, +): boolean { + const message = error instanceof Error ? error.message : String(error); + return /unknown command ["']?pull["']?.*team-projects/iu.test(message) || + /unknown command ["']?team-projects["']?/iu.test(message) || + /unknown flag:\s*--(?:expected-version|live-dir|ref|json)\b/iu.test(message); +} + +function fileIdentity( + value: Awaited<ReturnType<typeof lstat>>, +): AuthorizedTeamProjectStageIdentity { + return { dev: String(value.dev), ino: String(value.ino) }; +} + +function sameIdentity( + left: AuthorizedTeamProjectStageIdentity, + right: Awaited<ReturnType<typeof lstat>>, +): boolean { + return left.dev === String(right.dev) && left.ino === String(right.ino); +} + +async function cleanupOwnedStage( + stageDir: string, + identity: AuthorizedTeamProjectStageIdentity, + hooks?: StageAuthorizedTeamProjectPullInput['cleanupHooks'], +): Promise<void> { + let current: Awaited<ReturnType<typeof lstat>>; + try { + current = await lstat(stageDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw error; + } + if ( + current.isSymbolicLink() || + !current.isDirectory() || + !sameIdentity(identity, current) + ) { + throw new Error('authorized pull stage identity changed; refusing cleanup'); + } + await hooks?.beforeQuarantineRename?.(stageDir); + const quarantine = `${stageDir}.cleanup-${process.pid}-${randomUUID()}`; + await rename(stageDir, quarantine); + const quarantined = await lstat(quarantine); + if (!sameIdentity(identity, quarantined)) { + try { + await rename(quarantine, stageDir); + } catch { + // Preserve the raced-in directory at quarantine when its original path + // was concurrently recreated. Never delete an inode we did not create. + } + throw new Error('authorized pull stage identity changed during cleanup'); + } + await rm(quarantine, { recursive: true, force: false }); +} + +const defaultRun: RunAuthorizedTeamProjectPull = ( + args, + workspaceId, + options, +) => { + const workspaceOptions = velaWorkspaceCommandOptions(workspaceId); + return runVelaCommand(['team-projects', ...args], { + ...workspaceOptions, + timeoutMs: options.timeoutMs, + ...(options.signal ? { signal: options.signal } : {}), + }); +}; + +export async function stageAuthorizedTeamProjectPull( + input: StageAuthorizedTeamProjectPullInput, +): Promise<StagedAuthorizedTeamProjectPull> { + if ( + !input.projectId.trim() || + !Number.isSafeInteger(input.expectedVersion) || + input.expectedVersion < 0 + ) { + throw new Error('authorized pull requires a project and exact version'); + } + const liveDir = path.resolve(input.liveDir); + const parentDir = path.dirname(liveDir); + const stageDir = await mkdtemp( + path.join(parentDir, `.${path.basename(liveDir)}.od-pull-stage-`), + ); + let identity = fileIdentity(await lstat(stageDir)); + let retained = false; + const cleanup = async (): Promise<void> => { + if (!retained) return; + await cleanupOwnedStage(stageDir, identity, input.cleanupHooks); + retained = false; + }; + try { + if ((await readdir(stageDir)).length !== 0) { + throw new Error('authorized pull stage must start empty'); + } + const stdout = await (input.run ?? defaultRun)( + [ + 'pull', + input.projectId, + stageDir, + '--live-dir', + liveDir, + '--ref', + 'published', + '--expected-version', + String(input.expectedVersion), + '--json', + ], + input.scope.workspaceId, + { + timeoutMs: AUTHORIZED_PULL_TIMEOUT_MS, + ...(input.signal ? { signal: input.signal } : {}), + }, + ); + const materializedIdentity = await lstat(stageDir); + if (materializedIdentity.isSymbolicLink() || !materializedIdentity.isDirectory()) { + throw new Error('authorized pull stage is not a real directory'); + } + // Vela atomically replaces the initially-empty stage with the materialized + // snapshot. Its successful command completion transfers ownership of that + // exact replacement inode to this caller. + identity = fileIdentity(materializedIdentity); + const receipt = parseReceipt(stdout); + validateAuthorizedTeamProjectPullReceipt(receipt, { + projectId: input.projectId, + scope: input.scope, + expectedVersion: input.expectedVersion, + nowMs: input.now?.() ?? Date.now(), + }); + retained = true; + return { stageDir, identity, receipt, cleanup }; + } catch (error) { + await cleanupOwnedStage(stageDir, identity, input.cleanupHooks).catch((cleanupError) => { + throw new AggregateError( + [error, cleanupError], + 'authorized pull failed and stage cleanup was not confirmed', + ); + }); + throw error; + } +} diff --git a/apps/daemon/src/collab/collab-cloud-service.ts b/apps/daemon/src/collab/collab-cloud-service.ts new file mode 100644 index 00000000000..25a29e510be --- /dev/null +++ b/apps/daemon/src/collab/collab-cloud-service.ts @@ -0,0 +1,378 @@ +// Collab-cloud orchestration (C-lane §D2.5 / §D4): ties the collab-cloud client +// to the one workspace context so a single signed-in identity drives member +// registration, comment push, and the pull+merge poller. Kept OUT of +// collab/runtime.ts (which #5383 is also editing) so the surfaces do not collide. +// +// Everything degrades to a no-op off-team: when the workspace context has no +// team identity, registration/push/poll all short-circuit. The client itself is +// only constructed when OD_COLLAB_CLOUD_URL is set (see createCollabCloudClientFromEnv), +// so an unconfigured daemon never even reaches here. + +import type { + CollabCloudComment, + CollabCloudMemberDirectoryEntry, + PreviewComment, + WorkspaceCollabContext, +} from '@open-design/contracts'; +import type { CollabCloudClient } from '../integrations/collab-cloud.js'; +import type { WorkspaceContextProvider } from './workspace-context.js'; + +/** The daemon-local seams the service needs; injected so this file stays free of + * SQLite and the poller is unit-testable with fakes. */ +export interface CollabCloudServiceDeps { + client: CollabCloudClient; + /** + * Legacy construction seam retained for compatibility with isolated callers. + * Project operations never read it: ambient active-workspace state is not + * data-plane authority. + */ + workspaceContext?: WorkspaceContextProvider; + /** Local project ids to poll for inbound comments. */ + listProjectIds: () => string[]; + /** Resolve the exact persisted + directory-verified scope for one project. */ + resolveProjectWorkspaceContext?: ( + projectId: string, + ) => Promise<WorkspaceCollabContext | null>; + /** + * Resolve a LOCAL conversation id to re-home synced comments onto (conversation + * ids do not cross daemons, and preview_comments has a conversation FK). Null + * when the project has no local conversation yet — the poller then skips it. + */ + resolveLocalConversationId: (projectId: string) => string | null; + /** + * Merge one pulled comment into local storage, idempotently by comment id. + * Returns true when a new row was inserted (false when it already existed). + */ + mergeComment: (input: { + projectId: string; + conversationId: string; + comment: CollabCloudComment; + }) => boolean; + /** Poll cadence; defaults to the spec's foreground 5s (§D4.5). */ + pollIntervalMs?: number; + onError?: (error: unknown) => void; + onMerged?: (input: { projectId: string; inserted: number }) => void; +} + +const DEFAULT_POLL_INTERVAL_MS = 5_000; + +/** + * Map a locally-stored preview comment to the cloud sync unit. Carries the full + * anchoring payload + drift-ladder fields so the comment keeps pointing at the + * same element on the receiver. `memberId` is the AUTHOR (who wrote it), taken + * from the comment's authorMemberId, falling back to the sharing member. + */ +export function previewCommentToCloud( + comment: PreviewComment, + fallbackMemberId: string, +): CollabCloudComment { + const cloud: CollabCloudComment = { + id: comment.id, + projectId: comment.projectId, + conversationId: comment.conversationId, + memberId: comment.authorMemberId ?? fallbackMemberId, + seq: 0, + note: comment.note, + filePath: comment.filePath, + elementId: comment.elementId, + selector: comment.selector, + label: comment.label, + text: comment.text, + htmlHint: comment.htmlHint, + position: comment.position, + status: comment.status, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + }; + // Copy optional fields only when present (exactOptionalPropertyTypes-safe). + if (comment.style !== undefined) cloud.style = comment.style; + if (comment.selectionKind !== undefined) cloud.selectionKind = comment.selectionKind; + if (comment.memberCount !== undefined) cloud.memberCount = comment.memberCount; + if (comment.podMembers !== undefined) cloud.podMembers = comment.podMembers; + if (comment.slideIndex !== undefined) cloud.slideIndex = comment.slideIndex; + if (comment.attachments !== undefined) cloud.attachments = comment.attachments; + if (comment.anchorState !== undefined) cloud.anchorState = comment.anchorState; + if (comment.anchoredVersion !== undefined) cloud.anchoredVersion = comment.anchoredVersion; + if (comment.lastGoodPosition !== undefined) cloud.lastGoodPosition = comment.lastGoodPosition; + return cloud; +} + +export interface CollabCloudService { + /** PUT the current member's directory entry (best-effort; no-op off-team). */ + registerSelf(context?: WorkspaceCollabContext): Promise<void>; + /** + * Push a created OR edited comment to the cloud (best-effort; no-op off-team). + * The relay upserts by id and receivers apply the newest by `updatedAt`, so the + * same call carries both the initial create and any later edit/status change. + * + * Resolves with the cloud-assigned `seq` for THIS push (or `null` off-team / + * on failure) so the caller can reconcile a new comment's provisional + * `pin_seq` — see `confirmPreviewCommentPinSeq` in db.ts and the + * recvq5BVsolIxi design note above `previewCommentToCloud`. The value is + * safe to feed into that reconciliation from EITHER a create or an edit + * push: the guard there only ever applies once per comment, so whichever + * push resolves first (in practice almost always the create) wins and a + * later resolution is a no-op. + */ + pushComment( + comment: PreviewComment, + context: WorkspaceCollabContext, + ): Promise<{ seq: number } | null>; + /** + * Push a delete as a tombstone (best-effort; no-op off-team). Receivers remove + * the comment by id. Stamps a fresh `updatedAt` so the tombstone is not treated + * as a stale edit if it races an in-flight update. + */ + pushCommentDeletion( + comment: PreviewComment, + context: WorkspaceCollabContext, + ): Promise<void>; + /** The explicitly scoped team's member directory (empty off-team / on error). */ + listMembers( + context: WorkspaceCollabContext, + ): Promise<CollabCloudMemberDirectoryEntry[]>; + /** Resolve one member id to its directory entry, or null. */ + resolveMember( + memberId: string, + context: WorkspaceCollabContext, + ): Promise<CollabCloudMemberDirectoryEntry | null>; + /** Run one poll cycle (register + pull + merge across all local projects). */ + pollOnce(): Promise<void>; + /** + * Pull + merge ONE project's comments now, regardless of whether it has a + * live events subscriber. This is the hub push-channel consumer: a + * `comment-changed` dirty mark must be redeemable even when the project is + * not in `listProjectIds()` (the poll loop's open-projects scope) — the + * poll loop otherwise never covers it and the mark would be consumed for + * nothing. Errors land on `onError`; never throws. + * + * Resolves `true` only when a pull actually ran against the relay for this + * project (a legitimately-empty/not-modified result still counts). Resolves + * `false` when it no-oped (no team identity yet, no local conversation to + * merge into) or the pull failed — the caller must then treat its consumed + * dirty mark as UNREDEEMED and restore it, otherwise a transient miss + * silently loses the one signal a single comment ever gets. + */ + pullProject( + projectId: string, + context: WorkspaceCollabContext, + ): Promise<boolean>; + /** Start the background poller. */ + start(): void; + /** Stop the poller. */ + dispose(): void; +} + +export function createCollabCloudService(deps: CollabCloudServiceDeps): CollabCloudService { + const pollIntervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + // Per-project pull cursor + last ETag, so each poll only fetches new comments + // and a 304 costs nothing. + const cursors = new Map<string, number>(); + const etags = new Map<string, string | null>(); + let timer: NodeJS.Timeout | null = null; + let running = false; + // The identity we last pushed to the member directory. Re-registering only + // when this changes keeps `pollOnce` from spawning a `vela member register` + // process on every 5s tick (see pollOnce). + let lastRegisteredKey: string | null = null; + + function explicitTeamIdentity(context: WorkspaceCollabContext): { + teamId: string; + memberId: string; + role: 'owner' | 'admin' | 'member'; + displayName: string; + } | null { + if ( + context.workspaceType !== 'team' + || context.memberStatus !== 'active' + || context.lifecycleState === 'deleted' + ) { + return null; + } + const teamId = context.teamId?.trim() || context.workspaceId.trim(); + const memberId = context.workspaceMemberId.trim(); + if (!teamId || !memberId) return null; + return { + teamId, + memberId, + role: context.role, + displayName: context.displayName?.trim() || memberId, + }; + } + + async function registerSelf( + context?: WorkspaceCollabContext, + ): Promise<void> { + const identity = context ? explicitTeamIdentity(context) : null; + if (!identity) return; + await deps.client.registerMember(identity.teamId, identity.memberId, { + displayName: identity.displayName, + role: identity.role, + }); + } + + async function pushComment( + comment: PreviewComment, + context: WorkspaceCollabContext, + ): Promise<{ seq: number } | null> { + const identity = explicitTeamIdentity(context); + if (!identity) return null; + const cloud = previewCommentToCloud(comment, identity.memberId); + const result = await deps.client.pushComment(identity.teamId, comment.projectId, cloud); + return result ?? null; + } + + async function pushCommentDeletion( + comment: PreviewComment, + context: WorkspaceCollabContext, + ): Promise<void> { + const identity = explicitTeamIdentity(context); + if (!identity) return; + const cloud = previewCommentToCloud(comment, identity.memberId); + cloud.deleted = true; + // The tombstone's own event time — newer than the comment's last content + // edit so it can't be mistaken for a stale record on the relay/receiver. + cloud.updatedAt = Date.now(); + await deps.client.pushComment(identity.teamId, comment.projectId, cloud); + } + + async function listMembersForTeamId( + teamId: string, + ): Promise<CollabCloudMemberDirectoryEntry[]> { + if (!teamId) return []; + try { + return await deps.client.listMembers(teamId); + } catch (error) { + deps.onError?.(error); + return []; + } + } + + async function listMembers( + context: WorkspaceCollabContext, + ): Promise<CollabCloudMemberDirectoryEntry[]> { + const identity = explicitTeamIdentity(context); + return identity ? listMembersForTeamId(identity.teamId) : []; + } + + async function resolveMember( + memberId: string, + context: WorkspaceCollabContext, + ): Promise<CollabCloudMemberDirectoryEntry | null> { + const identity = explicitTeamIdentity(context); + if (!identity) return null; + const members = await listMembersForTeamId(identity.teamId); + return members.find((m) => m.memberId === memberId) ?? null; + } + + /** Resolves `true` when a pull ran (even if it returned nothing new), + * `false` when there was no local conversation to merge into. */ + async function pollProject( + teamId: string, + scopeKey: string, + projectId: string, + ): Promise<boolean> { + const conversationId = deps.resolveLocalConversationId(projectId); + // No local conversation to attach to yet (e.g. a member who pulled the + // project but has not opened a chat) — nothing to merge into. + if (!conversationId) return false; + const cursorKey = `${scopeKey}:${projectId}`; + const sinceSeq = cursors.get(cursorKey) ?? 0; + const result = await deps.client.pullComments( + teamId, + projectId, + sinceSeq, + etags.get(cursorKey), + ); + etags.set(cursorKey, result.etag); + if (result.notModified) return true; + let inserted = 0; + for (const comment of result.comments) { + if (deps.mergeComment({ projectId, conversationId, comment })) inserted += 1; + } + cursors.set(cursorKey, result.latestSeq); + if (inserted > 0) deps.onMerged?.({ projectId, inserted }); + return true; + } + + async function pullProject( + projectId: string, + context: WorkspaceCollabContext, + ): Promise<boolean> { + const identity = explicitTeamIdentity(context); + if (!identity) return false; + try { + return await pollProject( + identity.teamId, + `${context.workspaceId}:${identity.memberId}`, + projectId, + ); + } catch (error) { + deps.onError?.(error); + return false; + } + } + + async function pollOnce(): Promise<void> { + for (const projectId of deps.listProjectIds()) { + try { + const context = + await deps.resolveProjectWorkspaceContext?.(projectId) ?? null; + const identity = context ? explicitTeamIdentity(context) : null; + if (!context || !identity) continue; + // Refresh the exact project's member directory entry only when its + // immutable workspace/member identity changes. Never borrow the + // daemon's ambient active workspace. + const identityKey = + `${context.workspaceId}:${identity.teamId}:${identity.memberId}:` + + `${identity.role}:${identity.displayName}`; + if (identityKey !== lastRegisteredKey) { + await deps.client.registerMember(identity.teamId, identity.memberId, { + displayName: identity.displayName, + role: identity.role, + }); + lastRegisteredKey = identityKey; + } + await pollProject( + identity.teamId, + `${context.workspaceId}:${identity.memberId}`, + projectId, + ); + } catch (error) { + deps.onError?.(error); + } + } + } + + function tick(): void { + if (running) return; + running = true; + void pollOnce() + .catch((error) => deps.onError?.(error)) + .finally(() => { + running = false; + }); + } + + return { + registerSelf, + pushComment, + pushCommentDeletion, + listMembers, + resolveMember, + pollOnce, + pullProject, + start() { + if (timer) return; + timer = setInterval(tick, pollIntervalMs); + // Do not keep the event loop alive solely for polling. + timer.unref?.(); + }, + dispose() { + if (timer) { + clearInterval(timer); + timer = null; + } + }, + }; +} diff --git a/apps/daemon/src/collab/collab-publish-watcher.ts b/apps/daemon/src/collab/collab-publish-watcher.ts new file mode 100644 index 00000000000..024b887c23f --- /dev/null +++ b/apps/daemon/src/collab/collab-publish-watcher.ts @@ -0,0 +1,136 @@ +// Author-side file-change → publish TRIGGER (C spec §D1: C owns *when* to +// publish; the resource hub owns the mechanism). This subscribes to file-change +// events for the projects this daemon's member OWNS and has shared to the team, +// and coalesces every edit into a debounced publish through the scheduler. +// +// Read-only gate (loop-safe): a project is watched ONLY when this daemon's member +// is its single writer (team-shared AND owner === me). A member's pulled read-only +// copy (owned by someone else) is never watched here, so materializing an inbound +// pull can never loop back into a publish. It also keeps the member — who must +// stay read-only — from ever publishing edits to someone else's project. +// +// Kept OUT of runtime.ts (which #5383 is also editing) so the surfaces do not +// collide; server.ts wires it to the runtime's scheduler + the project watchers. + +import type { ResourceHubPrincipal } from './resource-principal.js'; + +export interface PublishWatchSubscription { + unsubscribe: () => Promise<void> | void; +} + +export interface CollabPublishWatcherDeps { + /** Coalesce every file edit into a debounced publish (the scheduler owns the window). */ + notifyChanged: ( + projectId: string, + principal?: ResourceHubPrincipal, + ) => void; + /** Local project ids to consider watching. */ + listProjectIds: () => string[]; + /** + * Whether THIS daemon should publish edits to `projectId`: it is team-shared + * AND this daemon's member is its owner (the single writer). Async because it + * consults the team hub + the workspace context. + */ + shouldPublish: ( + projectId: string, + ) => Promise<boolean | ResourceHubPrincipal>; + /** Subscribe to file-change events for a project's content dir. */ + subscribeFiles: (projectId: string, onChange: () => void) => PublishWatchSubscription; + /** Reconcile cadence (ms): how often to (re)discover owned+shared projects. */ + reconcileMs?: number; + onError?: (error: unknown) => void; +} + +export interface CollabPublishWatcher { + /** Reconcile once immediately (exposed for tests / eager first pass). */ + reconcile: () => Promise<void>; + start: () => void; + dispose: () => void; +} + +const DEFAULT_RECONCILE_MS = 10_000; + +export function createCollabPublishWatcher(deps: CollabPublishWatcherDeps): CollabPublishWatcher { + const reconcileMs = deps.reconcileMs ?? DEFAULT_RECONCILE_MS; + const subs = new Map< + string, + { + subscription: PublishWatchSubscription; + principal?: ResourceHubPrincipal; + } + >(); + let timer: ReturnType<typeof setInterval> | null = null; + let reconciling = false; + + async function reconcile(): Promise<void> { + if (reconciling) return; + reconciling = true; + try { + const ids = new Set(deps.listProjectIds()); + // Drop watchers for projects that no longer exist locally. + for (const [projectId, watched] of subs) { + if (!ids.has(projectId)) { + void Promise.resolve(watched.subscription.unsubscribe()).catch(() => {}); + subs.delete(projectId); + } + } + // Add watchers for owned + team-shared projects not yet watched. + for (const projectId of ids) { + if (subs.has(projectId)) continue; + let publishScope: boolean | ResourceHubPrincipal = false; + try { + publishScope = await deps.shouldPublish(projectId); + } catch (error) { + deps.onError?.(error); + continue; + } + if (!publishScope) continue; + const principal = + typeof publishScope === 'object' ? publishScope : undefined; + const sub = deps.subscribeFiles(projectId, () => { + // Every edit → a debounced publish; the scheduler collapses bursts so a + // half-written intermediate state never reaches members. + if (principal) deps.notifyChanged(projectId, principal); + else deps.notifyChanged(projectId); + }); + subs.set(projectId, { + subscription: sub, + ...(principal ? { principal } : {}), + }); + // Publish the CURRENT content once on first watch. The file watcher uses + // `ignoreInitial`, so files already on disk when watching begins (e.g. + // documents uploaded to a project before it was shared, or before the + // owner-check resolved) never fire a change event and would otherwise + // stay stranded at the initial share version — members would see the + // shared project but pull an empty/stale copy. Gated on `shouldPublish` + // (team-shared AND owned by me) above, so this only republishes a + // single-writer's own project and is loop-safe. Fires once per project + // per watch session (reconcile only subscribes not-yet-watched ids). + if (principal) deps.notifyChanged(projectId, principal); + else deps.notifyChanged(projectId); + } + } finally { + reconciling = false; + } + } + + return { + reconcile, + start() { + if (timer) return; + void reconcile().catch((error) => deps.onError?.(error)); + timer = setInterval(() => void reconcile().catch((error) => deps.onError?.(error)), reconcileMs); + timer.unref?.(); + }, + dispose() { + if (timer) { + clearInterval(timer); + timer = null; + } + for (const watched of subs.values()) { + void Promise.resolve(watched.subscription.unsubscribe()).catch(() => {}); + } + subs.clear(); + }, + }; +} diff --git a/apps/daemon/src/collab/created-project-workspace.ts b/apps/daemon/src/collab/created-project-workspace.ts new file mode 100644 index 00000000000..60d20eb6a09 --- /dev/null +++ b/apps/daemon/src/collab/created-project-workspace.ts @@ -0,0 +1,258 @@ +import type { ApiErrorResponse } from '@open-design/contracts'; +import type { Response } from 'express'; +import { + isWorkspaceResourceLocked, + workspaceResourceContextFromRequest, + type WorkspaceResourceContext, +} from './workspace-resource-mutation.js'; +import { + workspaceContextFromDirectoryItem, + type WorkspaceDirectoryFetchResult, +} from './vela-workspace-context.js'; +import { sendApiError } from '../http/api-errors.js'; + +export type CreatedProjectWorkspaceResolution = + | { ok: true; context: WorkspaceResourceContext | null } + | { + ok: false; + status: 400 | 403 | 503; + code: + | 'WORKSPACE_CONTEXT_INCOMPLETE' + | 'WORKSPACE_PROJECT_PERMISSION_DENIED' + | 'WORKSPACE_AUTHORITY_UNAVAILABLE'; + message: string; + retryable?: true; + }; + +export type CreatedProjectWorkspaceError = Extract< + CreatedProjectWorkspaceResolution, + { ok: false } +>; + +export function sendCreatedProjectWorkspaceError( + res: Response, + error: CreatedProjectWorkspaceError, +): Response<ApiErrorResponse> { + return sendApiError( + res, + error.status, + error.code, + error.message, + error.retryable ? { retryable: true } : {}, + ); +} + +/** + * Resolve the workspace authority for a route that creates a project. + * + * A completely headerless request is a legal legacy/anonymous caller and + * intentionally leaves the new project unbound. Once either workspace + * identity header is present, however, the request is a workspace-aware + * caller: partial, removed, locked, or non-writing identities must fail + * closed instead of silently creating an unbound orphan. + */ +export function resolveCreatedProjectWorkspace( + req: unknown, +): CreatedProjectWorkspaceResolution { + const context = workspaceResourceContextFromRequest(req); + if (context === null) return { ok: true, context: null }; + if (context === 'missing') { + return { + ok: false, + status: 400, + code: 'WORKSPACE_CONTEXT_INCOMPLETE', + message: 'workspace project creation requires both workspace and member identity', + }; + } + if ( + context.memberStatus !== 'active' + || !context.canWriteSyncedFiles + || isWorkspaceResourceLocked(context) + ) { + return { + ok: false, + status: 403, + code: 'WORKSPACE_PROJECT_PERMISSION_DENIED', + message: 'workspace project creation is not allowed', + }; + } + return { ok: true, context }; +} + +/** + * Authorize an explicitly-scoped project create against the signed-in + * membership directory. The caller-selected workspace/member pair is the + * lookup key; the daemon's ambient active workspace is deliberately absent + * from this contract. + * + * A missing fetcher is the local/dev compatibility path. Production Vela + * mode injects one and therefore fails closed when AMR is unavailable. + */ +export async function authorizeCreatedProjectWorkspace( + req: unknown, + fetchWorkspaceDirectory?: () => Promise<WorkspaceDirectoryFetchResult>, +): Promise<CreatedProjectWorkspaceResolution> { + const claimed = resolveCreatedProjectWorkspace(req); + if (!claimed.ok || claimed.context === null || !fetchWorkspaceDirectory) { + return claimed; + } + const claimedContext = claimed.context; + + let directory: WorkspaceDirectoryFetchResult; + try { + directory = await fetchWorkspaceDirectory(); + } catch { + directory = { ok: false, items: [] }; + } + if (!directory.ok) { + return { + ok: false, + status: 503, + code: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + message: 'workspace membership authority is temporarily unavailable', + retryable: true, + }; + } + + const item = directory.items.find( + (candidate) => + candidate.workspaceId === claimedContext.workspaceId + && candidate.workspaceMemberId === claimedContext.workspaceMemberId, + ); + if (!item) { + return { + ok: false, + status: 403, + code: 'WORKSPACE_PROJECT_PERMISSION_DENIED', + message: 'workspace project creation is not allowed', + }; + } + + const authoritative = workspaceContextFromDirectoryItem(item); + const context: WorkspaceResourceContext = { + workspaceId: authoritative.workspaceId, + workspaceType: authoritative.workspaceType, + workspaceTypeAsserted: authoritative.workspaceType, + appUserId: claimedContext.appUserId, + workspaceMemberId: authoritative.workspaceMemberId, + role: authoritative.role, + memberStatus: authoritative.memberStatus, + lifecycleState: authoritative.lifecycleState, + canShareProjects: authoritative.permissions.canShareProjects, + canWriteSyncedFiles: authoritative.permissions.canWriteSyncedFiles, + }; + if ( + context.memberStatus !== 'active' + || !context.canWriteSyncedFiles + || isWorkspaceResourceLocked(context) + ) { + return { + ok: false, + status: 403, + code: 'WORKSPACE_PROJECT_PERMISSION_DENIED', + message: 'workspace project creation is not allowed', + }; + } + return { ok: true, context }; +} + +/** + * Error thrown by resolver-style creation paths. It preserves the same typed + * 400/403/503 result as direct HTTP creation gates so callers can reject before + * touching the filesystem or database. + */ +export class CreatedProjectWorkspaceResolutionError extends Error { + readonly status: CreatedProjectWorkspaceError['status']; + readonly code: CreatedProjectWorkspaceError['code']; + readonly retryable?: true; + + constructor(error: CreatedProjectWorkspaceError) { + super(error.message); + this.name = 'CreatedProjectWorkspaceResolutionError'; + this.status = error.status; + this.code = error.code; + if (error.retryable) this.retryable = true; + } +} + +/** + * Resolve an exact creation scope. Headerless legacy requests remain unbound. + * Once either identity field is asserted, any incomplete, removed, denied, or + * unavailable authority fails closed; it never degrades to ambient/current or + * silently creates an unbound project. + */ +export async function createdProjectWorkspaceHome( + req: unknown, + fetchWorkspaceDirectory?: () => Promise<WorkspaceDirectoryFetchResult>, +): Promise<WorkspaceResourceContext | null> { + const authorized = await authorizeCreatedProjectWorkspace(req, fetchWorkspaceDirectory); + if (!authorized.ok) throw new CreatedProjectWorkspaceResolutionError(authorized); + return authorized.context; +} + +/** + * A `createdProjectWorkspaceHome` bound to one daemon's authorities, so a route + * module takes a single dep instead of re-threading three. + */ +export type CreatedProjectWorkspaceResolver = ( + req: unknown, +) => Promise<WorkspaceResourceContext | null>; + +export function createCreatedProjectWorkspaceResolver(deps: { + fetchWorkspaceDirectory?: () => Promise<WorkspaceDirectoryFetchResult>; +}): CreatedProjectWorkspaceResolver { + return (req) => + createdProjectWorkspaceHome( + req, + deps.fetchWorkspaceDirectory, + ); +} + +/** + * Write the `workspace_projects` row for a project this daemon just created. + * + * INVARIANT: a project created while this daemon knows a signed-in workspace + * always gets a binding row. A project with no row is not a harmless default — + * `GET /api/projects/:id/workspace-scope` answers `unbound` for it, which strips + * the workspace off the run request (`ProjectView`'s `projectRunWorkspaceContext` + * → an Open Design Cloud run nothing can bill) and blanks the balance/plan area + * while that project is open (`AvatarMenu`). It is also denied a run outright by + * `enforceWorkspaceResourceMutation` the moment the caller carries any workspace + * header, because the two-key lookup comes back empty. + * + * `context` is the caller's exact verified Workspace when the request named + * one. A headerless legacy request supplies null and remains unbound. + */ +export function bindCreatedProjectToWorkspace( + ensureWorkspaceProject: (input: { + projectId: string; + workspaceId: string; + visibility: 'personal'; + resourceState: 'active'; + createdByWorkspaceMemberId: string; + updatedByWorkspaceMemberId: string; + syncState: 'local_only'; + resourceHubResourceId: null; + cloudTombstonedAt: null; + createdAt: number; + updatedAt: number; + }) => unknown, + context: WorkspaceResourceContext | null, + projectId: string, + now: number, +): void { + if (!context) return; + ensureWorkspaceProject({ + projectId, + workspaceId: context.workspaceId, + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: context.workspaceMemberId, + updatedByWorkspaceMemberId: context.workspaceMemberId, + syncState: 'local_only', + resourceHubResourceId: null, + cloudTombstonedAt: null, + createdAt: now, + updatedAt: now, + }); +} diff --git a/apps/daemon/src/collab/hub-events-subscriber.ts b/apps/daemon/src/collab/hub-events-subscriber.ts new file mode 100644 index 00000000000..2efb835588f --- /dev/null +++ b/apps/daemon/src/collab/hub-events-subscriber.ts @@ -0,0 +1,515 @@ +// Hop-1 realtime: cloud hub → daemon thin-event subscriber. +// +// Subscribes to B's `GET /api/v1/collab/events` SSE channel (one connection +// per daemon, authenticated with the same vela control-key session everything +// else uses) and surfaces thin invalidation events. This replaces "poll every +// 5-15s and diff" as the PRIMARY freshness mechanism; the pollers stay alive +// as a lower-frequency safety net and as the sole mechanism while this +// channel is down — the channel's health is exposed through `onStateChange` +// so the caller can switch poll cadences. +// +// Reliability model (mirrors the daemon→web thin-event contract): +// - Events are signals, not payloads; a missed event is closed by the +// reconnect catch-up (`onReconnect` → caller re-runs its pollers once). +// - Exponential backoff 1s→30s, forever; `resolveEndpoint` returning null +// (signed out / no workspace) idles at the max backoff instead of +// hammering. +// - A heartbeat watchdog kills half-dead connections: the server sends a +// frame at least every 15s, so 45s of silence means the TCP stream is +// zombied and we abort + reconnect. + +import type { WorkspaceBillingRevisionClock } from '@open-design/contracts'; + +export type HubResourceStatus = 'shared' | 'retracted'; +export type HubListenerHealth = 'starting' | 'healthy' | 'reconnecting' | 'stopped'; + +export interface HubListenerStatus { + listenerEpoch: string; + listenerHealth: HubListenerHealth; + sourceGap: boolean; +} + +export interface HubReadyFrame { + workspaceId: string; + capabilities: string[]; + listenerStatus: HubListenerStatus | null; +} + +const BILLING_REVISION_CLOCKS_CAPABILITY = 'billing-revision-clocks-v1'; +export const AUTHORITATIVE_PROJECT_PRESENCE_CAPABILITY = + 'authoritative-project-presence-v1'; +const MAX_HANDLED_SOURCE_GAP_EPOCHS = 64; + +export interface HubWorkspaceEvent { + type: + | 'team-projects-changed' + | 'comment-changed' + | 'presence-changed' + | 'workspace-context-changed' + | 'billing-changed' + | 'billing-subscription-changed' + | 'wallet-balance-changed' + | 'project-metadata-changed' + | 'project-content-changed' + | 'team-resources-changed'; + workspaceId?: string; + workspaceMemberId?: string; + revision?: string; + revisionClock?: WorkspaceBillingRevisionClock; + projectId?: string; + resourceId?: string; + /** Set on 'team-resources-changed': `resource_hub.resources.kind` at emit + * time — 'design_system' | 'plugin' | 'skill' | 'project' | any future + * kind. Opaque here by design (mirrors vela's own `WorkspaceEvent. + * resourceKind`); this daemon's `onEvent` handler owns the kind→reconciler + * routing. */ + resourceKind?: string; + /** Set on 'team-resources-changed' alongside resourceKind: whether the hub + * write was a publish (moved the 'published' ref) or a retraction (the + * resource's soft-delete). */ + resourceStatus?: HubResourceStatus; + seq?: number; + version?: number; + at?: string; +} + +const HUB_EVENT_TYPES = new Set<HubWorkspaceEvent['type']>([ + 'team-projects-changed', + 'comment-changed', + 'presence-changed', + 'workspace-context-changed', + 'billing-changed', + 'billing-subscription-changed', + 'wallet-balance-changed', + 'project-metadata-changed', + 'project-content-changed', + 'team-resources-changed', +]); + +const HUB_RESOURCE_STATUSES = new Set<HubResourceStatus>(['shared', 'retracted']); + +export function parseHubWorkspaceEvent(data: string): HubWorkspaceEvent | null { + try { + const parsed = JSON.parse(data) as Record<string, unknown>; + if (typeof parsed.type !== 'string' || !HUB_EVENT_TYPES.has(parsed.type as HubWorkspaceEvent['type'])) { + return null; + } + const event: HubWorkspaceEvent = { type: parsed.type as HubWorkspaceEvent['type'] }; + if (typeof parsed.workspaceId === 'string') event.workspaceId = parsed.workspaceId; + if (typeof parsed.workspaceMemberId === 'string') { + event.workspaceMemberId = parsed.workspaceMemberId; + } + if (typeof parsed.revision === 'string') event.revision = parsed.revision; + const revisionClock = parseRevisionClock(parsed.revisionClock); + if (revisionClock) event.revisionClock = revisionClock; + if (typeof parsed.projectId === 'string') event.projectId = parsed.projectId; + if (typeof parsed.resourceId === 'string') event.resourceId = parsed.resourceId; + if (typeof parsed.resourceKind === 'string') event.resourceKind = parsed.resourceKind; + if ( + typeof parsed.resourceStatus === 'string' && + HUB_RESOURCE_STATUSES.has(parsed.resourceStatus as HubResourceStatus) + ) { + event.resourceStatus = parsed.resourceStatus as HubResourceStatus; + } + if (typeof parsed.seq === 'number') event.seq = parsed.seq; + if (typeof parsed.version === 'number') event.version = parsed.version; + if (typeof parsed.at === 'string') event.at = parsed.at; + return event; + } catch { + return null; + } +} + +export function parseHubReadyFrame(data: string): HubReadyFrame | null { + const parsed = parseJsonRecord(data); + const workspaceId = + typeof parsed?.workspaceId === 'string' ? parsed.workspaceId.trim() : ''; + if (!workspaceId) return null; + const capabilities = Array.isArray(parsed?.capabilities) + ? parsed.capabilities.filter( + (capability): capability is string => + typeof capability === 'string' && Boolean(capability.trim()), + ) + : []; + return { + workspaceId, + capabilities, + listenerStatus: parseHubListenerStatusRecord(parsed), + }; +} + +export function parseHubListenerStatus(data: string): HubListenerStatus | null { + return parseHubListenerStatusRecord(parseJsonRecord(data)); +} + +function parseHubListenerStatusRecord( + parsed: Record<string, unknown> | null, +): HubListenerStatus | null { + if (!parsed) return null; + const listenerEpoch = + typeof parsed.listenerEpoch === 'string' ? parsed.listenerEpoch.trim() : ''; + const listenerHealth = parsed.listenerHealth; + if ( + !listenerEpoch || + ( + listenerHealth !== 'starting' && + listenerHealth !== 'healthy' && + listenerHealth !== 'reconnecting' && + listenerHealth !== 'stopped' + ) || + typeof parsed.sourceGap !== 'boolean' + ) { + return null; + } + return { + listenerEpoch, + listenerHealth, + sourceGap: parsed.sourceGap, + }; +} + +function parseRevisionClock(value: unknown): WorkspaceBillingRevisionClock | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const raw = value as Record<string, unknown>; + const epoch = typeof raw.epoch === 'string' ? raw.epoch.trim() : ''; + const counter = typeof raw.counter === 'string' ? raw.counter.trim() : ''; + if (!epoch || !/^(?:0|[1-9]\d*)$/.test(counter)) return null; + return { epoch, counter }; +} + +function parseJsonRecord(data: string): Record<string, unknown> | null { + try { + const parsed: unknown = JSON.parse(data); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record<string, unknown> + : null; + } catch { + return null; + } +} + +export interface HubEventsEndpoint { + /** Absolute URL of the SSE endpoint. */ + url: string; + /** Auth headers (Bearer control key + x-vela-workspace-id). */ + headers: Record<string, string>; + /** Expected workspace carried by the server's `ready` frame. Catch-up and + * events stay gated until the stream proves this exact scope. */ + workspaceId?: string; +} + +export interface HubEventsSubscriberOptions { + /** + * Resolve the endpoint from the CURRENT vela session + active workspace. + * Returning null (signed out / personal-only) parks the subscriber at the + * max backoff; it keeps re-resolving so a later sign-in picks up. + */ + resolveEndpoint: () => Promise<HubEventsEndpoint | null>; + onEvent: (event: HubWorkspaceEvent) => void; + /** Channel health transitions — drives poll-cadence switching. */ + onStateChange?: (state: 'connected' | 'disconnected') => void; + /** Fired after a `ready` frame verifies the stream workspace. Unlike + * `onReconnect`, this includes the first successful connection. */ + onConnect?: (connection: { + reconnect: boolean; + workspaceId?: string; + capabilities: readonly string[]; + }) => void; + /** One catch-up nudge per healthy producer-listener epoch that reports a gap. */ + onSourceGap?: (gap: { workspaceId?: string; listenerEpoch: string }) => void; + /** Secret-free parser/scope diagnostics. Raw payloads are never exposed. */ + onDrop?: (drop: { + reason: 'invalid-ready' | 'workspace-mismatch' | 'unverified-scope' | 'invalid-payload'; + eventName: string; + expectedWorkspaceId?: string; + actualWorkspaceId?: string; + }) => void; + /** + * Fired on every successful (re)connect AFTER the first, i.e. whenever + * events may have been missed. The caller should run one catch-up cycle + * (its pollers' `pollOnce`). + */ + onReconnect?: () => void; + onError?: (error: unknown) => void; + fetchImpl?: typeof fetch; + /** Abort the stream when no frame (event OR heartbeat) arrives for this long. */ + heartbeatTimeoutMs?: number; + backoffMinMs?: number; + backoffMaxMs?: number; +} + +export interface HubEventsSubscriber { + stop(): void; + connected(): boolean; + /** Re-resolve the endpoint immediately after active-workspace identity changes. */ + refreshEndpoint(): void; +} + +export function startHubEventsSubscriber(options: HubEventsSubscriberOptions): HubEventsSubscriber { + const fetchImpl = options.fetchImpl ?? fetch; + const heartbeatTimeoutMs = options.heartbeatTimeoutMs ?? 45_000; + const backoffMinMs = options.backoffMinMs ?? 1_000; + const backoffMaxMs = options.backoffMaxMs ?? 30_000; + + let stopped = false; + let isConnected = false; + let everConnected = false; + let backoffMs = backoffMinMs; + let abortController: AbortController | null = null; + let wakeSleep: (() => void) | null = null; + let endpointGeneration = 0; + const handledSourceGapEpochs = new Set<string>(); + + const setConnected = (next: boolean) => { + if (isConnected === next) return; + isConnected = next; + options.onStateChange?.(next ? 'connected' : 'disconnected'); + }; + + const sleep = (ms: number) => + new Promise<void>((resolve) => { + const timer = setTimeout(() => { + wakeSleep = null; + resolve(); + }, ms); + timer.unref?.(); + wakeSleep = () => { + clearTimeout(timer); + wakeSleep = null; + resolve(); + }; + }); + + async function consumeStream(endpoint: HubEventsEndpoint): Promise<void> { + abortController = new AbortController(); + let watchdog: NodeJS.Timeout | null = null; + const armWatchdog = () => { + if (watchdog) clearTimeout(watchdog); + watchdog = setTimeout(() => abortController?.abort(), heartbeatTimeoutMs); + watchdog.unref?.(); + }; + + try { + const response = await fetchImpl(endpoint.url, { + headers: { ...endpoint.headers, accept: 'text/event-stream' }, + signal: abortController.signal, + }); + if (!response.ok || !response.body) { + throw new Error(`hub events stream ${response.status}`); + } + // Transport-connected. Content catch-up remains gated on the server's + // `ready` frame proving the expected workspace below. + backoffMs = backoffMinMs; + setConnected(true); + armWatchdog(); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let scopeVerified = false; + let connectionNotified = false; + let verifiedWorkspaceId: string | undefined; + let revisionClocksEnabled = false; + const reportSourceGap = ( + status: HubListenerStatus | null, + suppressCallback = false, + ) => { + const handledKey = + `${verifiedWorkspaceId ?? 'unknown'}\0${status?.listenerEpoch ?? ''}`; + if ( + !status || + status.listenerHealth !== 'healthy' || + !status.sourceGap || + handledSourceGapEpochs.has(handledKey) + ) { + return; + } + handledSourceGapEpochs.add(handledKey); + while (handledSourceGapEpochs.size > MAX_HANDLED_SOURCE_GAP_EPOCHS) { + const oldest = handledSourceGapEpochs.values().next().value as + | string + | undefined; + if (!oldest) break; + handledSourceGapEpochs.delete(oldest); + } + if (!suppressCallback) { + options.onSourceGap?.({ + ...(verifiedWorkspaceId ? { workspaceId: verifiedWorkspaceId } : {}), + listenerEpoch: status.listenerEpoch, + }); + } + }; + const notifyVerifiedConnection = ( + workspaceId: string | undefined, + capabilities: readonly string[], + ) => { + if (connectionNotified) return; + connectionNotified = true; + const reconnect = everConnected; + everConnected = true; + try { + options.onConnect?.({ + reconnect, + ...(workspaceId ? { workspaceId } : {}), + capabilities: [...capabilities], + }); + if (reconnect) options.onReconnect?.(); + } catch (error) { + options.onError?.(error); + } + }; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + armWatchdog(); + buffer += decoder.decode(value, { stream: true }); + // SSE permits CRLF as well as LF. Normalize only after appending so a + // CR/LF pair split across transport chunks is still handled. + buffer = buffer.replace(/\r\n/g, '\n'); + let sep: number; + while ((sep = buffer.indexOf('\n\n')) !== -1) { + const rawEvent = buffer.slice(0, sep); + buffer = buffer.slice(sep + 2); + let eventName = 'message'; + const dataLines: string[] = []; + for (const line of rawEvent.split('\n')) { + if (line.startsWith('event:')) eventName = line.slice(6).trim(); + else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim()); + } + const data = dataLines.join('\n'); + if (eventName === 'ready') { + const ready = parseHubReadyFrame(data); + if (!ready) { + options.onDrop?.({ + reason: 'invalid-ready', + eventName, + ...(endpoint.workspaceId + ? { expectedWorkspaceId: endpoint.workspaceId } + : {}), + }); + abortController?.abort(); + return; + } + const actualWorkspaceId = ready.workspaceId; + if (endpoint.workspaceId && actualWorkspaceId !== endpoint.workspaceId) { + options.onDrop?.({ + reason: 'workspace-mismatch', + eventName, + expectedWorkspaceId: endpoint.workspaceId, + actualWorkspaceId, + }); + abortController?.abort(); + return; + } + scopeVerified = true; + verifiedWorkspaceId = actualWorkspaceId; + revisionClocksEnabled = ready.capabilities.includes( + BILLING_REVISION_CLOCKS_CAPABILITY, + ); + const reconnect = everConnected; + notifyVerifiedConnection(actualWorkspaceId, ready.capabilities); + // Transport reconnect already invokes the broader onReconnect + // catch-up. A first connection has no such callback, so a healthy + // producer-side gap reported in ready must trigger it here. + reportSourceGap(ready.listenerStatus, reconnect); + continue; + } + if (eventName === 'source-status' || eventName === 'heartbeat') { + if (scopeVerified) reportSourceGap(parseHubListenerStatus(data)); + continue; + } + if (eventName !== 'workspace-event') continue; + if (!scopeVerified) { + options.onDrop?.({ + reason: 'unverified-scope', + eventName, + ...(endpoint.workspaceId + ? { expectedWorkspaceId: endpoint.workspaceId } + : {}), + }); + continue; + } + const event = parseHubWorkspaceEvent(data); + if (!event) { + options.onDrop?.({ reason: 'invalid-payload', eventName }); + continue; + } + if ( + endpoint.workspaceId && + event.workspaceId && + event.workspaceId !== endpoint.workspaceId + ) { + options.onDrop?.({ + reason: 'workspace-mismatch', + eventName, + expectedWorkspaceId: endpoint.workspaceId, + actualWorkspaceId: event.workspaceId, + }); + continue; + } + if (!revisionClocksEnabled && event.revisionClock) { + const { revisionClock: _, ...legacyEvent } = event; + options.onEvent(legacyEvent); + } else { + options.onEvent(event); + } + } + } + } finally { + if (watchdog) clearTimeout(watchdog); + abortController = null; + setConnected(false); + } + } + + void (async () => { + while (!stopped) { + const generation = endpointGeneration; + try { + const endpoint = await options.resolveEndpoint(); + if (stopped) break; + // A workspace switch may land while endpoint resolution is in flight. + // Never open the now-stale scope; resolve again from current identity. + if (generation !== endpointGeneration) continue; + if (!endpoint) { + // Signed out / no team workspace — idle at max backoff, keep probing. + await sleep(backoffMaxMs); + continue; + } + await consumeStream(endpoint); + if (generation !== endpointGeneration) { + backoffMs = backoffMinMs; + continue; + } + // Server closed cleanly (deploy/restart) — reconnect promptly. + backoffMs = backoffMinMs; + } catch (error) { + // Deliberately aborting the old workspace stream is not a transport + // failure and must not surface a misleading reconnect warning. + if (!stopped && generation === endpointGeneration) options.onError?.(error); + } + if (stopped) break; + if (generation !== endpointGeneration) { + backoffMs = backoffMinMs; + continue; + } + await sleep(backoffMs); + backoffMs = Math.min(backoffMs * 2, backoffMaxMs); + } + })(); + + return { + stop() { + stopped = true; + abortController?.abort(); + wakeSleep?.(); + }, + connected: () => isConnected, + refreshEndpoint() { + if (stopped) return; + endpointGeneration += 1; + abortController?.abort(); + wakeSleep?.(); + }, + }; +} diff --git a/apps/daemon/src/collab/invite-continue.ts b/apps/daemon/src/collab/invite-continue.ts new file mode 100644 index 00000000000..6cafe884cf7 --- /dev/null +++ b/apps/daemon/src/collab/invite-continue.ts @@ -0,0 +1,78 @@ +import type { WorkspaceCollabContext } from '@open-design/contracts'; +import { readVelaControlApiContext } from '../integrations/vela.js'; +import { mapVelaWorkspaceContext } from './vela-workspace-context.js'; + +// Daemon half of the desktop invite hand-off ("桌面唤起和本地恢复", C's lane in +// the B-C invite contract). The desktop app receives an +// `opendesign://workspace/invite/continue?...&nonce=...` deeplink, parses it, and +// forwards the nonce here. The daemon proves identity with the SAME signed-in vela +// session (never a client-supplied one) and consumes the one-time continuation on +// B, which finalizes the membership and returns the current workspace context so +// the client can switch into the team workspace. Any failure degrades to a typed +// outcome the route maps onto HTTP — it never throws into the caller. + +const DEFAULT_TIMEOUT_MS = 8_000; + +function consumePath(nonce: string): string { + return `/api/v1/workspace-invites/continuations/${encodeURIComponent(nonce)}/consume`; +} + +export type InviteContinueOutcome = + | { ok: true; context: WorkspaceCollabContext | null; workspaceMemberId: string } + | { ok: false; status: number; error: string }; + +export interface ConsumeInviteContinuationOptions { + /** Injectable for tests. */ + fetch?: typeof fetch; + /** Injectable for tests; defaults to reading ~/.amr / env. */ + readSession?: typeof readVelaControlApiContext; + timeoutMs?: number; +} + +/** + * Consume an invite continuation nonce against B using the local vela session, + * returning the mapped workspace context on success. Errors are typed, not + * thrown: `no_session` (401) when the client is not signed in, `continuation_<n>` + * for B's 401/403/409/410 (subject mismatch / already consumed / expired), and + * `continuation_unreachable` (502) on a transport failure. + */ +export async function consumeInviteContinuation( + nonce: string, + options: ConsumeInviteContinuationOptions = {}, +): Promise<InviteContinueOutcome> { + const fetchImpl = options.fetch ?? fetch; + const readSession = options.readSession ?? readVelaControlApiContext; + const trimmed = nonce.trim(); + if (!trimmed) return { ok: false, status: 400, error: 'missing_nonce' }; + + const session = readSession(); + if (!session || !session.controlKey || !session.apiUrl) { + return { ok: false, status: 401, error: 'no_session' }; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS); + try { + const response = await fetchImpl(new URL(consumePath(trimmed), session.apiUrl), { + method: 'POST', + headers: { authorization: `Bearer ${session.controlKey}` }, + signal: controller.signal, + }); + if (!response.ok) { + return { ok: false, status: response.status, error: `continuation_${response.status}` }; + } + const body = (await response.json()) as { + workspaceMemberId?: unknown; + currentWorkspaceContext?: unknown; + }; + return { + ok: true, + context: mapVelaWorkspaceContext(body.currentWorkspaceContext), + workspaceMemberId: typeof body.workspaceMemberId === 'string' ? body.workspaceMemberId : '', + }; + } catch { + return { ok: false, status: 502, error: 'continuation_unreachable' }; + } finally { + clearTimeout(timeout); + } +} diff --git a/apps/daemon/src/collab/invite-create.ts b/apps/daemon/src/collab/invite-create.ts new file mode 100644 index 00000000000..f4a62a4c840 --- /dev/null +++ b/apps/daemon/src/collab/invite-create.ts @@ -0,0 +1,110 @@ +import { + normalizeWorkspaceInviteCreateErrorCode, + type WorkspaceInviteRole, +} from '@open-design/contracts'; +import { readVelaControlApiContext } from '../integrations/vela.js'; + +// Daemon half of the invite CREATE flow (the inviter/host side of the B-C invite +// contract). The team switcher's "邀请同事" dialog collects { email, role } rows +// and POSTs them to `/api/workspace/invite`; that route derives the current +// workspaceId from the workspace context and calls this helper once per invite. +// The helper proves identity with the SAME signed-in vela session the rest of C +// uses (never a client-supplied one) and POSTs to B's create-invite endpoint. +// Any failure degrades to a typed outcome the route maps onto HTTP — it never +// throws into the caller. In particular, B's create endpoint may not exist on a +// local backend yet. Safe, explicitly allowlisted business errors retain their +// typed code; every other non-2xx becomes `create_<status>` instead of leaking +// an arbitrary upstream body or crashing. + +const DEFAULT_TIMEOUT_MS = 8_000; + +function createInvitePath(workspaceId: string): string { + return `/api/v1/workspaces/${encodeURIComponent(workspaceId)}/invites`; +} + +export interface CreateWorkspaceInviteInput { + email: string; + role: WorkspaceInviteRole; + /** The workspace the invite is scoped to (derived from the caller's context). */ + workspaceId: string; +} + +export type CreateInviteOutcome = + | { ok: true; inviteId: string } + | { ok: false; status: number; error: string }; + +export interface CreateWorkspaceInviteOptions { + /** Injectable for tests. */ + fetch?: typeof fetch; + /** Injectable for tests; defaults to reading ~/.amr / env. */ + readSession?: typeof readVelaControlApiContext; + timeoutMs?: number; +} + +/** + * Create a single workspace invite on B using the local vela session. + * + * Errors are typed, not thrown: `no_session` (401) when the client is not signed + * in, `no_workspace` (409) when there is no workspace to scope the invite to, + * safe allowlisted business codes for known B failures, `create_<n>` for every + * other B non-2xx (e.g. `create_404` when the endpoint is absent locally), and + * `create_unreachable` (502) on a transport failure. + */ +export async function createWorkspaceInvite( + input: CreateWorkspaceInviteInput, + options: CreateWorkspaceInviteOptions = {}, +): Promise<CreateInviteOutcome> { + const fetchImpl = options.fetch ?? fetch; + const readSession = options.readSession ?? readVelaControlApiContext; + + const email = input.email.trim(); + if (!email) return { ok: false, status: 400, error: 'missing_email' }; + const workspaceId = input.workspaceId.trim(); + if (!workspaceId) return { ok: false, status: 409, error: 'no_workspace' }; + + const session = readSession(); + if (!session || !session.controlKey || !session.apiUrl) { + return { ok: false, status: 401, error: 'no_session' }; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS); + try { + const response = await fetchImpl(new URL(createInvitePath(workspaceId), session.apiUrl), { + method: 'POST', + headers: { + authorization: `Bearer ${session.controlKey}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ invitedEmail: email, role: input.role }), + signal: controller.signal, + }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as + | { code?: unknown; error?: unknown } + | null; + const typedError = + normalizeWorkspaceInviteCreateErrorCode(body?.code) ?? + normalizeWorkspaceInviteCreateErrorCode(body?.error); + return { + ok: false, + status: response.status, + error: typedError ?? `create_${response.status}`, + }; + } + const body = (await response.json().catch(() => null)) as + | { inviteId?: unknown; id?: unknown } + | null; + const inviteId = + typeof body?.inviteId === 'string' + ? body.inviteId + : typeof body?.id === 'string' + ? body.id + : ''; + return { ok: true, inviteId }; + } catch { + return { ok: false, status: 502, error: 'create_unreachable' }; + } finally { + clearTimeout(timeout); + } +} diff --git a/apps/daemon/src/collab/persisted-team-share.ts b/apps/daemon/src/collab/persisted-team-share.ts new file mode 100644 index 00000000000..c6c2a759a13 --- /dev/null +++ b/apps/daemon/src/collab/persisted-team-share.ts @@ -0,0 +1,39 @@ +import type { ResourceHubPrincipal } from './resource-principal.js'; + +export interface PersistedTeamShareRow { + projectId?: string | null; + workspaceId?: string | null; + createdByWorkspaceMemberId?: string | null; + updatedByWorkspaceMemberId?: string | null; +} + +export interface PersistedTeamShare { + projectId: string; + principal: ResourceHubPrincipal; +} + +/** + * Recover only creator-attributed share ownership after a daemon restart. + * + * `updatedByWorkspaceMemberId` records who last materialized or reconciled the + * local row. On a read-only team mirror that is the current viewer, not the + * remote project's single writer, so it is never evidence of ownership. + * Mirrors without creator attribution deliberately return null here and + * recover their owner through the workspace-scoped authoritative catalog used + * by status and publish gating. + */ +export function recoverPersistedTeamShareOwnership( + row: PersistedTeamShareRow, +): PersistedTeamShare | null { + const ownerMemberId = row.createdByWorkspaceMemberId; + if (!row.projectId || !row.workspaceId || !ownerMemberId) return null; + return { + projectId: row.projectId, + principal: { + memberId: ownerMemberId, + teamId: row.workspaceId, + role: 'member', + lifecycleState: 'active', + }, + }; +} diff --git a/apps/daemon/src/collab/persistent-sync-cache.ts b/apps/daemon/src/collab/persistent-sync-cache.ts new file mode 100644 index 00000000000..906da811628 --- /dev/null +++ b/apps/daemon/src/collab/persistent-sync-cache.ts @@ -0,0 +1,169 @@ +// Persistent half of the workspace sync design (SSE push marks dirty + local +// snapshot + digest token compare). +// +// This layer sits BELOW the in-memory stale-while-revalidate caches in +// server.ts. They answer "the same page asked twice in one second"; this one +// answers "the daemon restarted, or this workspace has not been opened in a +// while" — a cold process still pays a full catalog/member round-trip on first +// paint even though nothing changed upstream. Trading that round-trip for a +// digest GET is the entire win. +// +// The reuse test is three conditions AND-ed, never a heuristic: +// +// 1. a local token exists, +// 2. a local snapshot exists, +// 3. the local token EQUALS the token the cloud reports right now. +// +// Anything else — no digest (signed out, offline, non-vela source), no stored +// row, unparseable payload, different token — falls through to the real fetch. +// Conditions 1 and 2 collapse into a single row read because the store writes +// token and snapshot atomically. +// +// Failure behavior is deliberately asymmetric: a failed digest degrades to a +// real fetch, and a failed real fetch propagates to the caller unchanged while +// leaving the stored snapshot untouched, so a transient outage never destroys a +// snapshot that a later successful digest could still validate. + +import { + tokenForFace, + type SyncDigestFace, + type SyncDigestReader, +} from './sync-digest.js'; +import type { + CollabSyncSnapshotKey, + CollabSyncSnapshotStore, +} from './sync-snapshot-store.js'; + +export interface PersistentSyncCacheOptions<T> { + face: SyncDigestFace; + /** The real (expensive) read this cache is trying to avoid. */ + fetch: () => Promise<T>; + readDigest: SyncDigestReader; + store: CollabSyncSnapshotStore; + /** Validate a decoded snapshot; returning null forces a real fetch. */ + parseSnapshot: (value: unknown) => T | null; + /** + * Is the fetcher's answer authoritative right now? + * + * Both cached readers answer `[]` — not an error — while the workspace + * context has no team identity yet (startup, signed out, personal + * workspace). That empty is indistinguishable from a genuinely empty team, + * so persisting it under a live token would pin an empty catalog/roster + * until something upstream happened to change. When this returns false the + * cache is bypassed entirely: no read, no write, just the real fetch. + */ + shouldCache?: () => boolean | Promise<boolean>; + onError?: (error: unknown) => void; +} + +export interface PersistentSyncCache<T> { + (): Promise<T>; + /** + * Drop the persisted snapshot for the key last used. + * + * Only for the moments we KNOW the payload changed (a local share/unshare, a + * hub event). Correctness does not depend on it — a changed payload moves the + * cloud token, which already defeats reuse — but it keeps an invalidated + * entry from surviving in the database across a restart. + */ + invalidate(): void; +} + +export function createPersistentSyncCache<T>( + options: PersistentSyncCacheOptions<T>, +): PersistentSyncCache<T> { + const { face, store, parseSnapshot } = options; + let lastKey: CollabSyncSnapshotKey | null = null; + // Bumped by invalidate(); a read that started before the bump must not reuse + // a snapshot that was invalidated while its digest was in flight. + let generation = 0; + + const readStoredSnapshot = (key: CollabSyncSnapshotKey, token: string): T | null => { + let record; + try { + record = store.read(key); + } catch (error) { + options.onError?.(error); + return null; + } + if (!record || record.token !== token) return null; + let decoded: unknown; + try { + decoded = JSON.parse(record.snapshotJson); + } catch { + // Corrupt payload: the row can never be validated again, so retire it. + try { + store.drop(key); + } catch (error) { + options.onError?.(error); + } + return null; + } + const parsed = parseSnapshot(decoded); + if (parsed === null) { + try { + store.drop(key); + } catch (error) { + options.onError?.(error); + } + return null; + } + return parsed; + }; + + const read = async (): Promise<T> => { + const startedAt = generation; + if (options.shouldCache) { + let ready = false; + try { + ready = await options.shouldCache(); + } catch (error) { + options.onError?.(error); + } + if (!ready) return options.fetch(); + } + const reading = await options.readDigest(); + // A reading always carries a non-empty account + workspace (the reader + // refuses to report one otherwise), so a key is either fully formed or + // absent — there is no placeholder-keyed cache entry. + const token = reading ? tokenForFace(reading.digest, face) : ''; + const key: CollabSyncSnapshotKey | null = + reading && token ? { face, accountId: reading.accountId, workspaceId: reading.workspaceId } : null; + if (key) lastKey = key; + + if (key && generation === startedAt) { + const snapshot = readStoredSnapshot(key, token); + if (snapshot !== null) return snapshot; + } + + const value = await options.fetch(); + // The same generation guard as the reuse path above, and for the same + // reason. A change that lands while this fetch is open makes the value in + // flight already stale, while `token` still names the pre-change state — so + // writing the pair back would restore exactly the row invalidate() just + // dropped, and the cloud digest has not necessarily recomputed yet to + // defeat it. Skipping the write costs one round-trip next time; making it + // would serve wrong data. + if (key && generation === startedAt) { + try { + store.write(key, { token, snapshotJson: JSON.stringify(value) }); + } catch (error) { + // A cache that cannot be written is still a correct cache. + options.onError?.(error); + } + } + return value; + }; + + return Object.assign(read, { + invalidate() { + generation += 1; + if (!lastKey) return; + try { + store.drop(lastKey); + } catch (error) { + options.onError?.(error); + } + }, + }); +} diff --git a/apps/daemon/src/collab/presence-tracker.ts b/apps/daemon/src/collab/presence-tracker.ts new file mode 100644 index 00000000000..69521aab080 --- /dev/null +++ b/apps/daemon/src/collab/presence-tracker.ts @@ -0,0 +1,92 @@ +// Team collaboration presence — the "presence" overlay: who is currently viewing a +// shared project. Decoupled from the resource/sync layer: it is a +// lightweight, poll-friendly heartbeat set, not a realtime-cursor engine (live +// cursors were cut; content is polled). +// +// Pure and timer-free by design. `present()` computes the live set on demand and +// sweeps anyone whose heartbeat has aged past the TTL, so the orchestrator's +// existing poll loop surfaces departures without a background timer. Explicit +// join/leave fire `onChange` immediately so an active viewer's arrival/exit can +// be broadcast without waiting for the next poll. + +import type { CollabPresenceMember } from '@open-design/contracts'; + +// The presence identity shape is the shared contract DTO; keep the local name +// for existing daemon-side imports. +export type PresenceMember = CollabPresenceMember; + +export interface CollabPresenceTrackerOptions { + /** A member is considered gone this long after their last heartbeat. */ + ttlMs?: number; + now?: () => number; + /** Fired when a project's present set changes via an explicit join or leave. */ + onChange?: (result: { projectId: string; present: PresenceMember[] }) => void; +} + +interface Entry { + member: PresenceMember; + lastSeen: number; +} + +const DEFAULT_TTL_MS = 30_000; + +export class CollabPresenceTracker { + private readonly ttlMs: number; + private readonly now: () => number; + private readonly onChange?: CollabPresenceTrackerOptions['onChange']; + private readonly projects = new Map<string, Map<string, Entry>>(); + + constructor(options: CollabPresenceTrackerOptions = {}) { + this.ttlMs = Math.max(0, options.ttlMs ?? DEFAULT_TTL_MS); + this.now = options.now ?? Date.now; + this.onChange = options.onChange; + } + + /** Mark a member present in a project (call on view + on each poll). */ + heartbeat(projectId: string, member: PresenceMember): void { + const entries = this.ensure(projectId); + const isNew = !entries.has(member.memberId); + entries.set(member.memberId, { member, lastSeen: this.now() }); + if (isNew) this.emit(projectId); + } + + /** Explicit departure (tab closed / left the project). */ + leave(projectId: string, memberId: string): void { + const entries = this.projects.get(projectId); + if (!entries || !entries.delete(memberId)) return; + if (entries.size === 0) this.projects.delete(projectId); + this.emit(projectId); + } + + /** Members present now — sweeps any whose heartbeat aged past the TTL. */ + present(projectId: string): PresenceMember[] { + const entries = this.projects.get(projectId); + if (!entries) return []; + const cutoff = this.now() - this.ttlMs; + for (const [memberId, entry] of entries) { + if (entry.lastSeen < cutoff) entries.delete(memberId); + } + if (entries.size === 0) { + this.projects.delete(projectId); + return []; + } + return Array.from(entries.values(), (entry) => entry.member); + } + + dispose(): void { + this.projects.clear(); + } + + private emit(projectId: string): void { + if (this.onChange) this.onChange({ projectId, present: this.present(projectId) }); + } + + private ensure(projectId: string): Map<string, Entry> { + let entries = this.projects.get(projectId); + if (!entries) { + entries = new Map(); + this.projects.set(projectId, entries); + } + return entries; + } +} diff --git a/apps/daemon/src/collab/proactive-content-pull.ts b/apps/daemon/src/collab/proactive-content-pull.ts new file mode 100644 index 00000000000..c9e17bff0ce --- /dev/null +++ b/apps/daemon/src/collab/proactive-content-pull.ts @@ -0,0 +1,2328 @@ +// Hub push-channel consumer for 'project-content-changed' (recvqmKQRiIlYf): +// a teammate published a new version of a shared project, so THIS daemon +// pulls the content proactively — no open tab required — instead of leaving +// freshness to the member web's ~5s status polling. That polling stays +// running untouched as the fallback (and as the ONLY mechanism while the hub +// channel is down); everything here degrades to it: a guard skip, a failed +// pull, or a revoked project simply leaves freshness to the poll loop. +// +// Guard boundary (all fail CLOSED — an uncertain answer skips the pull): +// - An existing local binding must be a team binding. A newly-shared project +// with no local binding may bootstrap only from an explicitly scoped hub +// event. That exact Workspace is resolved through authoritative membership +// rather than the daemon's mutable global active Workspace. This +// closes the first-publication gap: the catalog can expose the card before +// the member has ever opened/materialized it, and waiting for an open tab +// leaves its files/details absent for tens of seconds. +// - The pull NEVER runs when this daemon's member owns the project. The +// owner's local copy is the single writer (see useProjectCollab.ts's +// member auto-pull gate, which holds the same rule on the web side); +// pulling over it could clobber unpublished edits. The owner's daemon +// receives its own publish echo over the SSE channel, so this guard is +// load-bearing, not defensive. +// - Event workspace, local binding workspace, and the exact resolved +// identity must all agree; a mismatch means the event belongs to a scope +// this daemon must not address with that principal. +// +// Dedup model: a per-project cursor records the version the last successful +// pull materialized, so repeated/out-of-order events for an already-landed +// head are no-ops; an event racing an in-flight pull waits it out and re-runs +// AT MOST once when the cursor is still behind (a newer head arrived while +// pulling). The cursor only advances on a successful pull — failures keep it +// behind so the same-version retry stays possible. + +const WITNESS_MAX_AGE_MS = 5_000; +const issuedAuthorizationWitnesses = new WeakSet<object>(); +const issuedAuthorizedStageInvocations = new WeakSet<object>(); + +export interface ProactiveContentPullEvent { + projectId?: string | undefined; + workspaceId?: string | undefined; + version?: number | undefined; + /** Internal diagnostic origin; never serialized to the hub or web. */ + profileReceivedAtMs?: number | undefined; +} + +export interface ProactiveContentPullProjectRef { + projectId: string; + ownerMemberId: string; +} + +export function activeTeamWorkspaceIdentity(context: { + workspaceId?: string | null; + teamId?: string | null; + workspaceMemberId?: string | null; + workspaceType?: string | null; + memberStatus?: string | null; + lifecycleState?: string | null; +} | null): { + workspaceId: string; + resourceTeamId: string; + workspaceMemberId: string; +} | null { + const workspaceId = context?.workspaceId?.trim() ?? ''; + const resourceTeamId = context?.teamId?.trim() ?? ''; + const workspaceMemberId = context?.workspaceMemberId?.trim() ?? ''; + if ( + context?.workspaceType !== 'team' || + context.memberStatus !== 'active' || + context.lifecycleState !== 'active' || + !workspaceId || + !resourceTeamId || + !workspaceMemberId + ) { + return null; + } + return { workspaceId, resourceTeamId, workspaceMemberId }; +} + +export interface ProactivePullAuthorizationScope { + projectId: string; + workspaceId: string; + resourceTeamId: string; + viewerMemberId: string; + ownerMemberId: string; +} + +export interface ProactivePullAuthorizationWitness + extends ProactivePullAuthorizationScope { + readonly kind: 'proactive-content-pull'; + readonly version: number; + readonly verifiedAtMs: number; +} + +export interface AuthorizedProactivePullInvocation + extends ProactivePullAuthorizationScope { + readonly kind: 'authorized-proactive-stage'; + readonly expectedVersion: number; + /** Opt-in profiling origin only; never participates in authorization. */ + readonly profileReceivedAtMs?: number; + readonly isStillExpected: () => boolean; + readonly signal: AbortSignal; +} + +function issueAuthorizedProactivePullInvocation( + scope: ProactivePullAuthorizationScope, + expectedVersion: number, + profileReceivedAtMs: number | undefined, + isStillExpected: () => boolean, + signal: AbortSignal, +): AuthorizedProactivePullInvocation { + const invocation = Object.freeze({ + kind: 'authorized-proactive-stage' as const, + ...scope, + expectedVersion, + ...(profileReceivedAtMs != null ? { profileReceivedAtMs } : {}), + isStillExpected, + signal, + }); + issuedAuthorizedStageInvocations.add(invocation); + return invocation; +} + +export function isAuthorizedProactivePullInvocation( + invocation: AuthorizedProactivePullInvocation | undefined, + scope: ProactivePullAuthorizationScope, + expectedVersion: number | undefined, +): boolean { + return Boolean( + isBoundProactivePullInvocation(invocation, scope, expectedVersion) && + !invocation!.signal.aborted && + invocation!.isStillExpected() + ); +} + +export function isBoundProactivePullInvocation( + invocation: AuthorizedProactivePullInvocation | undefined, + scope: ProactivePullAuthorizationScope, + expectedVersion: number | undefined, +): boolean { + return Boolean( + invocation && + issuedAuthorizedStageInvocations.has(invocation) && + Number.isSafeInteger(expectedVersion) && + expectedVersion != null && + expectedVersion >= 0 && + invocation.expectedVersion === expectedVersion && + invocation.projectId === scope.projectId && + invocation.workspaceId === scope.workspaceId && + invocation.resourceTeamId === scope.resourceTeamId && + invocation.viewerMemberId === scope.viewerMemberId && + invocation.ownerMemberId === scope.ownerMemberId + ); +} + +function issueProactivePullAuthorizationWitness( + scope: ProactivePullAuthorizationScope, + version: number, + verifiedAtMs = Date.now(), +): ProactivePullAuthorizationWitness { + const witness = Object.freeze({ + kind: 'proactive-content-pull' as const, + ...scope, + version, + verifiedAtMs, + }); + issuedAuthorizationWitnesses.add(witness); + return witness; +} + +export function isFreshProactivePullAuthorizationWitness( + witness: ProactivePullAuthorizationWitness | undefined, + scope: ProactivePullAuthorizationScope, + expectedVersion: number | undefined, + nowMs = Date.now(), +): boolean { + if (!witness || !issuedAuthorizationWitnesses.has(witness)) return false; + const ageMs = nowMs - witness.verifiedAtMs; + return Boolean( + expectedVersion != null && + Number.isSafeInteger(expectedVersion) && + expectedVersion >= 0 && + witness.version === expectedVersion && + Number.isFinite(witness.verifiedAtMs) && + ageMs >= 0 && + ageMs <= WITNESS_MAX_AGE_MS && + witness.projectId === scope.projectId && + witness.workspaceId === scope.workspaceId && + witness.resourceTeamId === scope.resourceTeamId && + witness.viewerMemberId === scope.viewerMemberId && + witness.ownerMemberId === scope.ownerMemberId + ); +} + +/** Outcome contract of the injected pull — structurally the same shape + * `CollabSyncRoutesHandle.pullSharedProject` (routes/collab-sync.ts) + * resolves with, redeclared here so this module stays free of the routes + * layer. */ +export type ProactiveContentPullOutcome = + | { status: 'pulled'; version: number | null } + | { status: 'revoked' } + | { status: 'register_failed' }; + +/** Exact team scope proved by the hub event + active identity + owner lookup. */ +export interface ProactiveContentPullTarget { + projectId: string; + workspaceId: string; + resourceTeamId: string; + viewerMemberId: string; + ownerMemberId: string; + authorizationWitness?: ProactivePullAuthorizationWitness; + authorizedStageInvocation?: AuthorizedProactivePullInvocation; +} + +export interface ProactiveContentPullDeps { + /** This daemon's own `workspace_projects` row for the project, or null when + * it was never bound locally. */ + getLocalBinding: ( + projectId: string, + ) => { workspaceId: string; visibility: 'personal' | 'team' } | null; + /** The authoritative TEAM identity for this exact Workspace (active + * membership only), or null when signed out / personal-only / unavailable. */ + getWorkspaceIdentity: (workspaceId: string) => Promise<{ + workspaceId: string; + resourceTeamId: string; + workspaceMemberId: string; + } | null>; + /** Server-authoritative owner lookup in the same exact Workspace scope. */ + resolveSharedProjectOwner: ( + projectId: string, + workspaceId: string, + ) => Promise<string | null>; + /** The shared pull flow (revocation gate → pull → register → signals) — + * `CollabSyncRoutesHandle.pullSharedProject` in production wiring. */ + pullSharedProject: ( + target: ProactiveContentPullTarget, + expectedVersion?: number, + ) => Promise<ProactiveContentPullOutcome>; + /** + * One workspace-scoped catalog read used only on a verified hub connection + * (first connect and reconnect). Optional so event-driven consumers remain + * usable without a catch-up transport. + */ + listSharedProjects?: ( + workspaceId: string, + ) => Promise<readonly ProactiveContentPullProjectRef[]>; + /** Exact-scope local materialization probe. The broad low-frequency floor + * still selects only missing projects; a project-targeted catalog recovery + * may also compare an existing mirror's durable version with remote head. */ + hasMaterializedProject?: ( + projectId: string, + target: ProactiveContentPullTarget, + ) => boolean | Promise<boolean>; + /** Read one authoritative published head after the single catalog sweep has + * selected a recently changed, non-owned project. Calls are sequential to + * avoid a reconnect request burst. */ + publishedHead?: (target: ProactiveContentPullTarget) => Promise<number | null>; + /** Durable last materialized version, shared with other team-resource + * reconcilers. Seeds the in-memory event cursor after daemon restart. */ + materializedVersion?: (target: ProactiveContentPullTarget) => string | null; + /** Called after a successful, versioned pull has materialized bytes. Used + * to invalidate list-level cover reads that do not subscribe to the + * project's own SSE stream. */ + onPulled?: (target: ProactiveContentPullTarget, version: number) => void | Promise<void>; + /** + * Called when handling the original hub event reaches a terminal decision + * without leaving a retry/recovery lane behind. Successful pulls also call + * this after `onPulled`; consumers should treat it as idempotent. + */ + onEventSettled?: (event: ProactiveContentPullEvent) => void; + /** Opt-in, secret-free timing observer. It must not affect pull behavior. */ + onTiming?: (event: { + phase: + | 'queued' + | 'guard-started' + | 'guard-completed' + | 'invoke' + | 'completed'; + projectId: string; + version?: number; + receivedAtMs?: number; + atMs: number; + status?: string; + }) => void; + /** Secret-free lifecycle diagnostics for the bounded catch-up sweep. */ + onCatchUp?: (event: { + phase: + | 'started' + | 'completed' + | 'skipped' + | 'retry-scheduled' + | 'retry-exhausted'; + mode: 'full' | 'missing-only'; + lane: 'broad' | 'targeted'; + workspaceId?: string; + projectId?: string; + scanned?: number; + candidates?: number; + /** Remote published-head requests issued by this sweep. */ + headChecks?: number; + /** Remote heads that resolved to a published version. */ + heads?: number; + suppressed?: number; + complete?: boolean; + failures?: number; + attempt?: number; + delayMs?: number; + reason?: 'no-active-team' | 'scope-mismatch' | 'unavailable'; + }) => void; + onError?: (error: unknown) => void; + /** Injectable retry clock for deterministic tests. */ + scheduler?: { + setTimeout: ( + callback: () => void | Promise<void>, + delayMs: number, + ) => unknown; + clearTimeout: (handle: unknown) => void; + }; + /** Injectable entropy for equal-jitter retry delays. */ + random?: () => number; + /** Injectable wall clock for deterministic broad-head cooldown tests. */ + now?: () => number; +} + +export interface ProactiveContentPull { + /** + * React to one hub 'project-content-changed' event. Never rejects: every + * failure lands on `onError` and leaves freshness to the polling fallback. + */ + handleContentChanged(event: ProactiveContentPullEvent): Promise<void>; + /** + * Sweep published heads after a verified hub first-connect/reconnect. + * Calls are single-flight with at most one trailing pass. + */ + catchUpPublishedHeads(expectedWorkspaceId?: string): Promise<void>; + /** + * Advance the bounded full-recovery cursor from the existing safety-floor + * heartbeat, additionally healing projects whose local tree is absent. + */ + advanceRecoveryFloor(expectedWorkspaceId?: string): Promise<void>; + /** + * Low-frequency catalog safety floor for the healthy-stream/missed-event + * case. Only remote projects with no local materialization are considered. + */ + materializeMissingProjects( + expectedWorkspaceId?: string, + expectedProjectId?: string, + ): Promise<void>; + /** + * Adopt an exact-scope version that another pull lane durably committed. + * The caller must invoke this only after bytes, mirror binding, and cursor + * persistence all succeed. + */ + observeMaterialized( + target: ProactiveContentPullTarget, + version: number, + ): Promise<void>; + /** Stop background recovery and release every pending retry timer. */ + dispose(): void; +} + +type CatchUpMode = 'full' | 'missing-only'; + +interface CatchUpSweepRequest { + expectedWorkspaceId?: string; + expectedProjectIds: Set<string>; + healMissing: boolean; +} + +interface CatchUpLane { + inFlight: Promise<void> | null; + requestedGeneration: number; + completedGeneration: number; + requestedSweep: CatchUpSweepRequest | null; + retryFailures: number; + retryTimer: unknown | null; +} + +type SuccessfulPullOutcome = Extract< + ProactiveContentPullOutcome, + { status: 'pulled' | 'revoked' } +>; + +interface ProjectPullCompletion { + scopeKey: string; + outcome: ProactiveContentPullOutcome | null; +} + +interface PullIntent { + key: string; + event: ProactiveContentPullEvent; + force: boolean; + /** Live/targeted work owns open-ended transport retries. Broad recovery is + * one-shot per published head and waits for a newer event or safety floor. */ + persistentRetry: boolean; + desiredVersion?: number; + expectedScopeKey?: string; + guardedTarget?: ProactiveContentPullTarget; + skipNextForceProbe?: boolean; + failures: number; + revision: number; + timer: unknown | null; + driving: Promise<boolean> | null; + abortController?: AbortController; + /** A live hub event may reserve priority for one retry. Persistent failures + * continue backing off after this reaches zero, but may no longer starve + * broad reconnect recovery. A newer event refreshes the budget. */ + foregroundRetryBudget?: number; +} + +interface SuppressedBroadHead { + version: number; + failures: number; + retryAt: number; +} + +interface CatalogProjectRetry { + key: string; + workspaceId: string; + projectId: string; + failures: number; + timer: unknown | null; + foregroundRetryBudget?: number; +} + +interface CatchUpSweepOutcome { + retryLane: boolean; + retryProjectIds: string[]; + retryWorkspaceId: string | null; + preemptedByForeground?: boolean; +} + +type PullAttempt = + | { kind: 'satisfied' } + | { kind: 'stopped'; staleGuard?: boolean } + | { kind: 'revoked' } + | { kind: 'failed'; staleGuard?: boolean } + | { kind: 'retry-now' } + | { kind: 'merged'; intent: PullIntent }; + +type PullDecision = + | { kind: 'target'; target: ProactiveContentPullTarget } + | { + kind: 'skip'; + retryable: boolean; + reason: + | 'invalid' + | 'personal' + | 'scope-mismatch' + | 'identity-missing' + | 'identity-error' + | 'owner-missing' + | 'owner-error' + | 'self-owner'; + }; + +const MAX_CATALOG_PROPAGATION_RETRIES = 5; +const FOREGROUND_CATCH_UP_QUIET_MS = 250; +const BROAD_HEAD_CHECK_BUDGET = 4; +const BROAD_HEAD_RETRY_BASE_MS = 15_000; +const BROAD_HEAD_RETRY_MAX_MS = 5 * 60_000; + +export function createProactiveContentPull( + deps: ProactiveContentPullDeps, +): ProactiveContentPull { + /** Last hub version a successful pull materialized, per project + scope. */ + const pulledVersions = new Map<string, number>(); + /** The filesystem is project-scoped, so transport serialization must be + * project-scoped too. Different workspace/owner scopes may never write the + * same project directory concurrently. */ + const projectPulls = new Map<string, Promise<ProjectPullCompletion>>(); + /** Retry state is isolated by the event's logical resource scope. */ + const intents = new Map<string, PullIntent>(); + /** Catalog propagation retries are independent from transport intents: + * one newly-shared project may lag without consuming or overwriting another + * project's backoff state. */ + const catalogProjectRetries = new Map<string, CatalogProjectRetry>(); + /** Project-specific first-share recovery must never wait behind the broad + * missing-project sweep. One lane per project keeps simultaneous new shares + * independent while `projectPulls` still dedupes disk writes. */ + const targetedCatalogRecoveries = new Map<string, Promise<void>>(); + /** Concurrent first-share events in one workspace share the same catalog + * snapshot read, but never share or serialize their per-project pulls. */ + const targetedCatalogReads = new Map< + string, + Promise<readonly ProactiveContentPullProjectRef[]> + >(); + const scheduler = deps.scheduler ?? { + setTimeout: ( + callback: () => void | Promise<void>, + delayMs: number, + ): ReturnType<typeof setTimeout> => + setTimeout(() => { + void callback(); + }, delayMs), + clearTimeout: (handle: unknown): void => + clearTimeout(handle as ReturnType<typeof setTimeout>), + }; + const random = deps.random ?? Math.random; + const now = deps.now ?? Date.now; + let disposed = false; + let foregroundGeneration = 0; + let foregroundEventsInFlight = 0; + let foregroundResumeTimer: unknown | null = null; + const foregroundIntents = new Set<PullIntent>(); + const foregroundCatalogRetries = new Set<CatalogProjectRetry>(); + const foregroundResumeRequests = new Map< + CatchUpMode, + CatchUpSweepRequest + >(); + + const reportTiming = ( + event: Parameters<NonNullable<ProactiveContentPullDeps['onTiming']>>[0], + ): void => { + try { + deps.onTiming?.(event); + } catch { + // Diagnostics are observational and must never affect pull behavior. + } + }; + /** Monotonic completion counter per project scope. A missing-only manifest + * probe captures this before awaiting I/O, then compares it afterwards so + * a successful full-sweep pull cannot disappear in the probe → inFlight + * gap merely because its promise has already been removed. */ + const completionGenerations = new Map<string, number>(); + const successfulCompletions = new Map< + string, + { generation: number; outcome: SuccessfulPullOutcome } + >(); + /** A broad sweep must not turn every historical unavailable project into a + * permanent retry timer. Cool down only the exact guarded scope + head; a + * later safety-floor pass, newer head, or targeted/live request retries. */ + const suppressedBroadHeads = new Map<string, SuppressedBroadHead>(); + /** Resume each broad workspace/mode lane after the last candidate it + * inspected. The safety floor is intentionally timer-free: the existing + * heartbeat supplies the next bounded round. */ + const broadHeadCursors = new Map<string, string>(); + /** + * Full reconnect recovery may spend a long time walking historical heads. + * Keep the missing-only safety floor on its own single-flight lane so a + * newly-shared project can materialize immediately. Both lanes still share + * `projectPulls` and `pulledVersions` above, preserving per-project dedupe. + */ + const catchUpLanes: Record<CatchUpMode, CatchUpLane> = { + full: { + inFlight: null, + requestedGeneration: 0, + completedGeneration: 0, + requestedSweep: null, + retryFailures: 0, + retryTimer: null, + }, + 'missing-only': { + inFlight: null, + requestedGeneration: 0, + completedGeneration: 0, + requestedSweep: null, + retryFailures: 0, + retryTimer: null, + }, + }; + + const pullScopeKey = (target: ProactiveContentPullTarget) => + JSON.stringify([ + target.projectId, + target.workspaceId, + target.resourceTeamId, + target.viewerMemberId, + target.ownerMemberId, + ]); + + const outcomeCoversVersion = ( + outcome: ProactiveContentPullOutcome | null | undefined, + version: number | undefined, + ): outcome is SuccessfulPullOutcome => { + if (outcome?.status === 'revoked') return true; + if (outcome?.status !== 'pulled') return false; + if (outcome.version == null) return false; + if (version == null) return true; + return outcome.version >= version; + }; + + const seedMaterializedVersion = ( + target: ProactiveContentPullTarget, + ): number | null => { + const version = Number(deps.materializedVersion?.(target) ?? NaN); + if (!Number.isSafeInteger(version) || version < 0) return null; + const scopeKey = pullScopeKey(target); + const cursor = pulledVersions.get(scopeKey); + if (cursor == null || version > cursor) { + pulledVersions.set(scopeKey, version); + } + return version; + }; + + const notifyPulled = async ( + target: ProactiveContentPullTarget, + version: number, + ): Promise<void> => { + try { + const callbackTarget = { ...target }; + delete callbackTarget.authorizationWitness; + delete callbackTarget.authorizedStageInvocation; + await deps.onPulled?.(callbackTarget, version); + } catch (error) { + // Content and its durable cursor are already committed. List + // invalidation is recoverable via its polling floor. + deps.onError?.(error); + } + }; + + const observeMaterialized = async ( + target: ProactiveContentPullTarget, + version: number, + ): Promise<void> => { + if (!Number.isSafeInteger(version) || version < 0) return; + const scopeKey = pullScopeKey(target); + const cursor = pulledVersions.get(scopeKey); + const advanced = cursor == null || version > cursor; + if (advanced) pulledVersions.set(scopeKey, version); + const intent = intents.get(scopeKey); + if ( + intent && + intent.desiredVersion != null && + version >= intent.desiredVersion + ) { + clearIntent(intent); + } + if (advanced) await notifyPulled(target, version); + }; + + function readTargetedCatalog( + workspaceId: string, + ): Promise<readonly ProactiveContentPullProjectRef[]> { + const existing = targetedCatalogReads.get(workspaceId); + if (existing) return existing; + const read = Promise.resolve().then(() => + deps.listSharedProjects!(workspaceId)); + targetedCatalogReads.set(workspaceId, read); + const clear = () => { + if (targetedCatalogReads.get(workspaceId) === read) { + targetedCatalogReads.delete(workspaceId); + } + }; + void read.then(clear, clear); + return read; + } + + async function shouldPull( + event: ProactiveContentPullEvent, + ownerHint?: string, + ): Promise<PullDecision> { + const projectId = event.projectId; + if (!projectId) { + return { kind: 'skip', retryable: false, reason: 'invalid' }; + } + const binding = deps.getLocalBinding(projectId); + if (binding?.visibility === 'personal') { + return { kind: 'skip', retryable: false, reason: 'personal' }; + } + const targetWorkspaceId = binding?.workspaceId ?? event.workspaceId; + // An unbound project has no local scope witness, so the hub event must + // carry one. Existing team bindings keep supporting legacy events that + // omit workspaceId. + if (!targetWorkspaceId) { + return { kind: 'skip', retryable: false, reason: 'invalid' }; + } + if (event.workspaceId && targetWorkspaceId !== event.workspaceId) { + return { kind: 'skip', retryable: false, reason: 'scope-mismatch' }; + } + // Identity and ownership are independent, read-only guards. Start both + // cold reads together: in the Vela-backed runtime each may spawn its own + // CLI/network round-trip, so serializing them adds their latencies before + // a pull can even begin. Promise.allSettled keeps every decision + // fail-closed while preserving the existing priority: identity validity + // and workspace scope are established before an owner result is trusted. + const startRead = <T>(read: () => Promise<T>): Promise<T> => { + try { + return Promise.resolve(read()); + } catch (error) { + return Promise.reject(error); + } + }; + const identityRead = startRead(() => + deps.getWorkspaceIdentity(targetWorkspaceId)); + const hintedOwner = ownerHint?.trim(); + const ownerRead = hintedOwner + ? Promise.resolve(hintedOwner) + : startRead(() => + deps.resolveSharedProjectOwner(projectId, targetWorkspaceId)); + const [identityResult, ownerResult] = await Promise.allSettled([ + identityRead, + ownerRead, + ]); + if (identityResult.status === 'rejected') { + deps.onError?.(identityResult.reason); + return { kind: 'skip', retryable: true, reason: 'identity-error' }; + } + const identity = identityResult.value; + if (!identity) { + return { kind: 'skip', retryable: true, reason: 'identity-missing' }; + } + if (identity.workspaceId !== targetWorkspaceId) { + return { kind: 'skip', retryable: false, reason: 'scope-mismatch' }; + } + // Fail-closed ownership: pulling over the single writer's working tree is + // destructive, so an unresolvable owner refuses the pull rather than + // guessing. + if (ownerResult.status === 'rejected') { + return { kind: 'skip', retryable: true, reason: 'owner-error' }; + } + const owner = ownerResult.value; + if (!owner) { + // A first-share content event can arrive before the separate catalog + // upsert exposes its owner row. Only an UNBOUND event may enter catalog + // propagation recovery; an established binding treats authoritative + // owner absence as revocation and stays fail-closed. + return { + kind: 'skip', + retryable: binding == null, + reason: 'owner-missing', + }; + } + if (owner === identity.workspaceMemberId) { + return { kind: 'skip', retryable: false, reason: 'self-owner' }; + } + const scope = { + projectId, + workspaceId: targetWorkspaceId, + resourceTeamId: identity.resourceTeamId, + viewerMemberId: identity.workspaceMemberId, + ownerMemberId: owner, + }; + return { + kind: 'target', + target: { + ...scope, + ...(!hintedOwner && + event.version != null && + Number.isSafeInteger(event.version) && + event.version >= 0 + ? { + authorizationWitness: + issueProactivePullAuthorizationWitness(scope, event.version), + } + : {}), + }, + }; + } + + async function runPull( + target: ProactiveContentPullTarget, + event: ProactiveContentPullEvent, + ): Promise<ProjectPullCompletion> { + const { projectId } = target; + const scopeKey = pullScopeKey(target); + const targetForPull = { ...target }; + if (targetForPull.authorizationWitness?.version !== event.version) { + delete targetForPull.authorizationWitness; + } + if ( + targetForPull.authorizedStageInvocation?.expectedVersion !== + event.version + ) { + delete targetForPull.authorizedStageInvocation; + } + const run = (async (): Promise<ProjectPullCompletion> => { + let outcome: ProactiveContentPullOutcome | null = null; + try { + reportTiming({ + phase: 'invoke', + projectId, + ...(event.version != null ? { version: event.version } : {}), + ...(event.profileReceivedAtMs != null + ? { receivedAtMs: event.profileReceivedAtMs } + : {}), + atMs: Date.now(), + }); + outcome = await deps.pullSharedProject(targetForPull, event.version); + if (outcome?.status !== 'pulled') return { scopeKey, outcome }; + // Advance the cursor only with the version the pull itself reported + // as materialized — trusting the event's number here could mark + // content as landed that never reached disk. + if (outcome.version == null) return { scopeKey, outcome }; + const cursor = pulledVersions.get(scopeKey); + if (cursor == null || outcome.version > cursor) { + pulledVersions.set(scopeKey, outcome.version); + } + await notifyPulled(targetForPull, outcome.version); + return { scopeKey, outcome }; + } catch (error) { + // Silent degradation: the web's status polling keeps pulling as the + // fallback, and the cursor stays behind so the next event retries. + deps.onError?.(error); + return { scopeKey, outcome: null }; + } finally { + reportTiming({ + phase: 'completed', + projectId, + ...(event.version != null ? { version: event.version } : {}), + ...(event.profileReceivedAtMs != null + ? { receivedAtMs: event.profileReceivedAtMs } + : {}), + atMs: Date.now(), + status: outcome?.status ?? 'failed', + }); + const generation = (completionGenerations.get(scopeKey) ?? 0) + 1; + completionGenerations.set(scopeKey, generation); + if (outcomeCoversVersion(outcome, undefined)) { + successfulCompletions.set(scopeKey, { generation, outcome }); + } + } + })(); + projectPulls.set(projectId, run); + try { + return await run; + } finally { + if (projectPulls.get(projectId) === run) projectPulls.delete(projectId); + } + } + + function provisionalIntentKey( + event: ProactiveContentPullEvent, + ): string | null { + if (!event.projectId) return null; + const binding = deps.getLocalBinding(event.projectId); + return JSON.stringify([ + 'pending-guard', + event.projectId, + binding?.workspaceId ?? event.workspaceId ?? null, + ]); + } + + function cancelIntentTimer(intent: PullIntent): void { + if (intent.timer == null) return; + scheduler.clearTimeout(intent.timer); + intent.timer = null; + } + + function markIntentForeground(intent: PullIntent): void { + intent.foregroundRetryBudget = 1; + if (foregroundIntents.has(intent)) { + cancelForegroundResumeTimer(); + return; + } + foregroundIntents.add(intent); + cancelForegroundResumeTimer(); + } + + function releaseIntentForeground(intent: PullIntent): void { + delete intent.foregroundRetryBudget; + if (foregroundIntents.delete(intent)) { + armForegroundResumeTimer(); + } + } + + function transferIntentForeground( + source: PullIntent, + target: PullIntent, + ): void { + if (!foregroundIntents.delete(source)) return; + const sourceBudget = source.foregroundRetryBudget ?? 0; + delete source.foregroundRetryBudget; + target.foregroundRetryBudget = Math.max( + target.foregroundRetryBudget ?? 0, + sourceBudget, + ); + foregroundIntents.add(target); + cancelForegroundResumeTimer(); + } + + function clearIntent(intent: PullIntent): void { + intent.abortController?.abort(); + delete intent.abortController; + cancelIntentTimer(intent); + if (intents.get(intent.key) === intent) intents.delete(intent.key); + releaseIntentForeground(intent); + } + + function createIntent( + key: string, + event: ProactiveContentPullEvent, + force: boolean, + persistentRetry: boolean, + target?: ProactiveContentPullTarget, + ): PullIntent { + return { + key, + event: { ...event }, + force, + persistentRetry, + ...(event.version != null ? { desiredVersion: event.version } : {}), + ...(target + ? { + expectedScopeKey: key, + guardedTarget: target, + } + : {}), + failures: 0, + revision: 0, + timer: null, + driving: null, + }; + } + + function mergeIntentUpdate( + intent: PullIntent, + event: ProactiveContentPullEvent, + force: boolean, + ): boolean { + const incomingVersion = event.version; + const higherVersion = + incomingVersion != null && + (intent.desiredVersion == null || incomingVersion > intent.desiredVersion); + const strongerForce = force && !intent.force; + if (higherVersion) { + intent.abortController?.abort(); + intent.desiredVersion = incomingVersion; + intent.event = { ...event }; + } + if (strongerForce) intent.force = true; + if (higherVersion || strongerForce) intent.revision += 1; + return higherVersion || strongerForce; + } + + function mergeIntentState( + target: PullIntent, + source: PullIntent, + ): boolean { + transferIntentForeground(source, target); + target.persistentRetry ||= source.persistentRetry; + const changed = mergeIntentUpdate(target, source.event, source.force); + target.failures = Math.max(target.failures, source.failures); + return changed; + } + + function retryDelay(failures: number): number { + const ceiling = Math.min(30_000, 1_000 * (2 ** Math.max(0, failures - 1))); + const jitter = Math.min(1, Math.max(0, random())); + return Math.round((ceiling / 2) + ((ceiling / 2) * jitter)); + } + + function broadHeadRetryDelay(failures: number): number { + return Math.min( + BROAD_HEAD_RETRY_MAX_MS, + BROAD_HEAD_RETRY_BASE_MS * (2 ** Math.max(0, failures - 1)), + ); + } + + function scheduleRetry(intent: PullIntent): void { + if (disposed || intents.get(intent.key) !== intent || intent.timer != null) { + return; + } + intent.failures += 1; + const delayMs = retryDelay(intent.failures); + let handle: unknown; + handle = scheduler.setTimeout(async () => { + if (intent.timer !== handle) return; + intent.timer = null; + await driveIntent(intent, true); + }, delayMs); + intent.timer = handle; + ( + handle as { unref?: () => void } | null | undefined + )?.unref?.(); + } + + async function transitionToCatalogRecovery( + intent: PullIntent, + decision: PullDecision, + ): Promise<boolean> { + if ( + decision.kind !== 'skip' || + decision.reason !== 'owner-missing' || + !decision.retryable || + intent.expectedScopeKey != null || + !intent.event.workspaceId || + !intent.event.projectId || + intents.get(intent.key) !== intent + ) { + return false; + } + const { workspaceId, projectId } = intent.event; + // Identity/transport retries are intentionally open-ended, but an unbound + // first share whose owner row has not propagated gets exactly the bounded + // catalog policy. Transfer ownership by removing the provisional intent + // before scheduling the project retry; otherwise both timers can survive. + const foreground = foregroundIntents.has(intent); + clearIntent(intent); + await requestCatalogProjectRecovery( + workspaceId, + projectId, + 'external', + foreground, + ); + return true; + } + + async function attemptIntent(intent: PullIntent): Promise<PullAttempt> { + try { + let target = intent.guardedTarget; + delete intent.guardedTarget; + if (!target) { + const decision = await shouldPull(intent.event); + if (decision.kind === 'skip') { + if (await transitionToCatalogRecovery(intent, decision)) { + return { kind: 'stopped', staleGuard: true }; + } + const establishedScopeGone = + intent.expectedScopeKey != null && + ( + decision.reason === 'identity-missing' || + decision.reason === 'owner-missing' + ); + return { + kind: + !decision.retryable || establishedScopeGone + ? 'stopped' + : 'failed', + staleGuard: true, + }; + } + target = decision.target; + } + const { projectId } = target; + const scopeKey = pullScopeKey(target); + if (intent.expectedScopeKey && intent.expectedScopeKey !== scopeKey) { + return { kind: 'stopped' }; + } + if (!intent.expectedScopeKey) { + const previousKey = intent.key; + const existing = intents.get(scopeKey); + if (existing && existing !== intent) { + const wake = mergeIntentState(existing, intent); + if (intents.get(previousKey) === intent) intents.delete(previousKey); + cancelIntentTimer(intent); + if (wake) { + cancelIntentTimer(existing); + existing.guardedTarget = target; + } + return { kind: 'merged', intent: existing }; + } + if (intents.get(previousKey) === intent) intents.delete(previousKey); + intent.key = scopeKey; + intent.expectedScopeKey = scopeKey; + intents.set(scopeKey, intent); + } + const completionGenerationBeforeProbe = + completionGenerations.get(scopeKey) ?? 0; + if (intent.force && deps.hasMaterializedProject) { + try { + // The missing-only sweep selected this project from an earlier + // manifest probe. A concurrent full sweep may have materialized it + // while owner/scope checks were in flight, so close that window + // before forcing a download. This observes real bytes, unlike the + // durable version cursor that missing-only intentionally ignores. + if (intent.skipNextForceProbe) { + delete intent.skipNextForceProbe; + } else if (await deps.hasMaterializedProject(projectId, target)) { + // The outer missing-only probe can go stale while owner/scope/head + // checks run. Seeing bytes now only withdraws the forced-download + // requirement; the version cursor still has to cover the head. + intent.force = false; + seedMaterializedVersion(target); + } else { + const completedDuringProbe = successfulCompletions.get(scopeKey); + if ( + completedDuringProbe && + completedDuringProbe.generation > completionGenerationBeforeProbe && + outcomeCoversVersion( + completedDuringProbe.outcome, + intent.desiredVersion, + ) + ) { + return { kind: 'satisfied' }; + } + } + } catch (error) { + deps.onError?.(error); + return { kind: 'failed' }; + } + } + if (!intent.force) { + const persistedVersion = Number( + deps.materializedVersion?.(target) ?? NaN, + ); + if ( + Number.isSafeInteger(persistedVersion) && + persistedVersion >= 0 && + ( + intent.desiredVersion == null || + persistedVersion >= intent.desiredVersion + ) + ) { + try { + const hasMaterializedBytes = deps.hasMaterializedProject + ? await deps.hasMaterializedProject(projectId, target) + : true; + if (hasMaterializedBytes) { + const cursor = pulledVersions.get(scopeKey); + if (cursor == null || persistedVersion > cursor) { + pulledVersions.set(scopeKey, persistedVersion); + } + } + } catch (error) { + deps.onError?.(error); + return { kind: 'failed' }; + } + } + } + const cursor = pulledVersions.get(scopeKey); + if ( + !intent.force && + intent.desiredVersion != null && + cursor != null && + cursor >= intent.desiredVersion + ) { + return { kind: 'satisfied' }; + } + const running = projectPulls.get(projectId); + if (running) { + const completion = await running; + // A completion from another resource scope is never proof for this + // intent. Loop through every guard again before deciding what to do. + if (completion.scopeKey !== scopeKey) return { kind: 'retry-now' }; + if (completion.outcome?.status === 'revoked') { + return { kind: 'revoked' }; + } + if ( + outcomeCoversVersion(completion.outcome, intent.desiredVersion) + ) { + return { kind: 'satisfied' }; + } + return { kind: 'retry-now' }; + } + const expectedVersion = intent.desiredVersion; + const abortController = new AbortController(); + intent.abortController = abortController; + const targetForPull: ProactiveContentPullTarget = { + ...target, + ...(expectedVersion != null + ? { + authorizedStageInvocation: + issueAuthorizedProactivePullInvocation( + target, + expectedVersion, + intent.event.profileReceivedAtMs, + () => + intents.get(intent.key) === intent && + intent.desiredVersion === expectedVersion, + abortController.signal, + ), + } + : {}), + }; + let completion: ProjectPullCompletion; + try { + completion = await runPull(targetForPull, { + ...intent.event, + ...(expectedVersion != null ? { version: expectedVersion } : {}), + }); + } finally { + if (intent.abortController === abortController) { + delete intent.abortController; + } + } + if (completion.outcome?.status === 'revoked') { + return { kind: 'revoked' }; + } + return outcomeCoversVersion( + completion.outcome, + intent.desiredVersion, + ) + ? { kind: 'satisfied' } + : { kind: 'failed' }; + } catch (error) { + deps.onError?.(error); + return { kind: 'failed' }; + } + } + + async function runIntent(intent: PullIntent): Promise<boolean> { + while (!disposed && intents.get(intent.key) === intent) { + const revision = intent.revision; + const result = await attemptIntent(intent); + // Revocation is authoritative even when a newer event arrived while the + // pull was in flight. + if (result.kind === 'revoked') { + clearIntent(intent); + return true; + } + if (result.kind === 'merged') { + if ( + result.intent.timer == null && + result.intent.driving == null + ) { + return await driveIntent(result.intent); + } + return result.intent.driving + ? await result.intent.driving + : false; + } + // A freshly verified event can migrate this same provisional intent + // while its old guard is still awaiting identity/owner I/O. Ignore only + // that stale guard's terminal answer and loop into guardedTarget. A + // completed pull that already covers the merged desired version remains + // satisfied; treating every revision change as stale would duplicate the + // full-sweep/missing-only shared pull. + if ( + result.kind === 'stopped' && + result.staleGuard && + intent.revision !== revision + ) { + continue; + } + if (result.kind === 'satisfied' || result.kind === 'stopped') { + suppressedBroadHeads.delete(intent.expectedScopeKey ?? intent.key); + clearIntent(intent); + return true; + } + if (result.kind === 'retry-now' || intent.revision !== revision) { + continue; + } + if (!intent.persistentRetry) { + if ( + intent.expectedScopeKey && + Number.isSafeInteger(intent.desiredVersion) && + intent.desiredVersion != null + ) { + const previous = suppressedBroadHeads.get(intent.expectedScopeKey); + const failures = previous?.version === intent.desiredVersion + ? previous.failures + 1 + : 1; + suppressedBroadHeads.set(intent.expectedScopeKey, { + version: intent.desiredVersion, + failures, + retryAt: now() + broadHeadRetryDelay(failures), + }); + } + clearIntent(intent); + return false; + } + scheduleRetry(intent); + return false; + } + return true; + } + + function driveIntent( + intent: PullIntent, + consumeForegroundRetry = false, + ): Promise<boolean> { + if (disposed || intents.get(intent.key) !== intent) { + return Promise.resolve(true); + } + if (intent.driving) return intent.driving; + if (consumeForegroundRetry && foregroundIntents.has(intent)) { + intent.foregroundRetryBudget = Math.max( + 0, + (intent.foregroundRetryBudget ?? 0) - 1, + ); + } + const run = runIntent(intent); + intent.driving = run; + const clearDriving = () => { + if (intent.driving === run) intent.driving = null; + if ( + foregroundIntents.has(intent) && + (intent.foregroundRetryBudget ?? 0) <= 0 + ) { + releaseIntentForeground(intent); + } + }; + // A `.finally()` call creates a second promise that mirrors rejection; if + // nobody observes that derived promise it can surface as an unhandled + // rejection even when the original `run` is awaited by its caller. + void run.then(clearDriving, clearDriving); + return run; + } + + async function processContentChanged( + event: ProactiveContentPullEvent, + ownerHint?: string, + force = false, + foreground = false, + persistentRetry = true, + ): Promise<boolean> { + try { + const provisionalKey = provisionalIntentKey(event); + if (!provisionalKey) return true; + reportTiming({ + phase: 'guard-started', + projectId: event.projectId!, + ...(event.version != null ? { version: event.version } : {}), + ...(event.profileReceivedAtMs != null + ? { receivedAtMs: event.profileReceivedAtMs } + : {}), + atMs: Date.now(), + }); + const decision = await shouldPull(event, ownerHint); + reportTiming({ + phase: 'guard-completed', + projectId: event.projectId!, + ...(event.version != null ? { version: event.version } : {}), + ...(event.profileReceivedAtMs != null + ? { receivedAtMs: event.profileReceivedAtMs } + : {}), + atMs: Date.now(), + status: decision.kind === 'target' ? 'target' : decision.reason, + }); + if (decision.kind === 'skip') { + if ( + decision.reason === 'owner-missing' && + decision.retryable && + event.workspaceId && + event.projectId + ) { + const pending = intents.get(provisionalKey); + if (pending) clearIntent(pending); + await requestCatalogProjectRecovery( + event.workspaceId, + event.projectId, + 'external', + foreground, + ); + return false; + } + if (!decision.retryable) return true; + if (!persistentRetry) return false; + let pending = intents.get(provisionalKey); + if (!pending) { + pending = createIntent( + provisionalKey, + event, + force, + persistentRetry, + ); + intents.set(provisionalKey, pending); + if (foreground) markIntentForeground(pending); + scheduleRetry(pending); + return false; + } + pending.persistentRetry ||= persistentRetry; + if (foreground) markIntentForeground(pending); + const wake = mergeIntentUpdate(pending, event, force); + if (wake) { + cancelIntentTimer(pending); + scheduleRetry(pending); + } + return pending.driving ? await pending.driving : false; + } + + const { target } = decision; + const key = pullScopeKey(target); + if (persistentRetry) suppressedBroadHeads.delete(key); + let effectiveForce = force; + let forceProbeAlreadyRan = false; + if (force && deps.hasMaterializedProject) { + const generationBeforeProbe = + completionGenerations.get(key) ?? 0; + try { + forceProbeAlreadyRan = true; + if (await deps.hasMaterializedProject(target.projectId, target)) { + effectiveForce = false; + seedMaterializedVersion(target); + } else { + const completedDuringProbe = successfulCompletions.get(key); + if ( + completedDuringProbe && + completedDuringProbe.generation > generationBeforeProbe && + outcomeCoversVersion( + completedDuringProbe.outcome, + event.version, + ) + ) { + return true; + } + } + } catch (error) { + deps.onError?.(error); + return false; + } + } + let intent = intents.get(key); + const pending = intents.get(provisionalKey); + if (foreground) { + if (intent) markIntentForeground(intent); + if (pending) markIntentForeground(pending); + } + let absorbedWake = false; + let migrated = false; + if (pending && pending !== intent) { + if (intent) { + absorbedWake = mergeIntentState(intent, pending); + clearIntent(pending); + } else { + cancelIntentTimer(pending); + if (intents.get(provisionalKey) === pending) { + intents.delete(provisionalKey); + } + pending.key = key; + pending.expectedScopeKey = key; + pending.guardedTarget = target; + // A timer callback may already be awaiting the provisional guard. + // Keep that one drive, but make its eventual result stale so it loops + // and consumes the freshly verified target instead of clearing or + // backing off this migrated intent. + if (pending.driving) pending.revision += 1; + intent = pending; + migrated = true; + intents.set(key, intent); + } + } + if (!intent) { + intent = createIntent( + key, + event, + effectiveForce, + persistentRetry, + target, + ); + if (effectiveForce && forceProbeAlreadyRan) { + intent.skipNextForceProbe = true; + } + intents.set(key, intent); + if (foreground) markIntentForeground(intent); + return await driveIntent(intent); + } + intent.persistentRetry ||= persistentRetry; + if (force && !effectiveForce) { + intent.force = false; + delete intent.skipNextForceProbe; + const cursor = pulledVersions.get(key); + const desired = Math.max( + intent.desiredVersion ?? Number.NEGATIVE_INFINITY, + event.version ?? Number.NEGATIVE_INFINITY, + ); + if (Number.isFinite(desired) && cursor != null && cursor >= desired) { + clearIntent(intent); + return true; + } + } + const mergedWake = mergeIntentUpdate(intent, event, effectiveForce); + const wake = migrated || mergedWake || absorbedWake; + if (effectiveForce && forceProbeAlreadyRan) { + intent.skipNextForceProbe = true; + } + if (!wake) { + return intent.driving ? await intent.driving : false; + } + cancelIntentTimer(intent); + intent.guardedTarget = target; + return await driveIntent(intent); + } catch (error) { + deps.onError?.(error); + return false; + } + } + + async function runCatchUpSweep( + mode: 'full' | 'missing-only', + expectedWorkspaceId?: string, + expectedProjectIds: ReadonlySet<string> = new Set(), + targetedOnly = false, + foreground = false, + healMissing = false, + ): Promise<CatchUpSweepOutcome> { + const foregroundGenerationAtStart = foregroundGeneration; + const lane = targetedOnly ? 'targeted' : 'broad'; + const foregroundShouldPreempt = (): boolean => + !targetedOnly && + ( + foregroundEventsInFlight > 0 || + foregroundIntents.size > 0 || + foregroundCatalogRetries.size > 0 || + foregroundResumeTimer != null || + foregroundGeneration !== foregroundGenerationAtStart + ); + if ( + !deps.listSharedProjects || + !deps.publishedHead || + (mode === 'missing-only' && !deps.hasMaterializedProject) + ) { + deps.onCatchUp?.({ + phase: 'skipped', + mode, + lane, + reason: 'unavailable', + }); + return { + retryLane: false, + retryProjectIds: [], + retryWorkspaceId: null, + }; + } + if (!expectedWorkspaceId) { + deps.onCatchUp?.({ + phase: 'skipped', + mode, + lane, + reason: 'no-active-team', + }); + return { + retryLane: false, + retryProjectIds: [], + retryWorkspaceId: null, + }; + } + const identity = await deps.getWorkspaceIdentity( + expectedWorkspaceId, + ).catch((error) => { + deps.onError?.(error); + return null; + }); + if (!identity) { + deps.onCatchUp?.({ + phase: 'skipped', + mode, + lane, + reason: 'no-active-team', + }); + return { + retryLane: false, + retryProjectIds: [], + retryWorkspaceId: null, + }; + } + if (expectedWorkspaceId && identity.workspaceId !== expectedWorkspaceId) { + deps.onCatchUp?.({ + phase: 'skipped', + mode, + lane, + workspaceId: identity.workspaceId, + reason: 'scope-mismatch', + }); + return { + retryLane: false, + retryProjectIds: [], + retryWorkspaceId: null, + }; + } + + const { workspaceId } = identity; + deps.onCatchUp?.({ phase: 'started', mode, lane, workspaceId }); + + let complete = true; + let retrySweep = false; + let preemptedByForeground = false; + let suppressed = 0; + let heads = 0; + let headChecks = 0; + const retryProjectIds: string[] = []; + const preemptedOutcome = ( + scanned: number, + candidates: number, + ): CatchUpSweepOutcome => { + deps.onCatchUp?.({ + phase: 'completed', + mode, + lane, + workspaceId, + scanned, + candidates, + headChecks, + heads, + suppressed, + complete: false, + }); + return { + retryLane: false, + retryProjectIds, + retryWorkspaceId: workspaceId, + preemptedByForeground: true, + }; + }; + if (foregroundShouldPreempt()) { + return preemptedOutcome(0, 0); + } + + let projects: readonly ProactiveContentPullProjectRef[]; + try { + projects = await (targetedOnly + ? readTargetedCatalog(workspaceId) + : deps.listSharedProjects(workspaceId)); + } catch (error) { + deps.onError?.(error); + if (foregroundShouldPreempt()) { + return preemptedOutcome(0, 0); + } + deps.onCatchUp?.({ + phase: 'completed', + mode, + lane, + workspaceId, + scanned: 0, + candidates: 0, + headChecks: 0, + heads: 0, + complete: false, + }); + return { + retryLane: expectedProjectIds.size === 0, + retryProjectIds: [...expectedProjectIds], + retryWorkspaceId: workspaceId, + }; + } + + if (foregroundShouldPreempt()) { + return preemptedOutcome(projects.length, 0); + } + const visibleProjectIds = new Set(projects.map((project) => project.projectId)); + for (const expectedProjectId of expectedProjectIds) { + if (visibleProjectIds.has(expectedProjectId)) { + clearCatalogProjectRetry(workspaceId, expectedProjectId); + continue; + } + const binding = deps.getLocalBinding(expectedProjectId); + // An existing local binding plus authoritative absence is an unshare, + // not propagation lag. Only an unseen project gets the bounded retry + // window needed by first-time sharing. + if (!binding) retryProjectIds.push(expectedProjectId); + else clearCatalogProjectRetry(workspaceId, expectedProjectId); + } + const candidates: Array<{ + project: ProactiveContentPullProjectRef; + forcePull: boolean; + }> = []; + const projectsToInspect = targetedOnly + ? projects.filter((project) => expectedProjectIds.has(project.projectId)) + : projects; + for (const project of projectsToInspect) { + if (project.ownerMemberId === identity.workspaceMemberId) continue; + const target: ProactiveContentPullTarget = { + projectId: project.projectId, + workspaceId, + resourceTeamId: identity.resourceTeamId, + viewerMemberId: identity.workspaceMemberId, + ownerMemberId: project.ownerMemberId, + }; + // Full recovery still has to heal a missing local tree. Mark its + // candidates as force-capable and let processContentChanged perform the + // race-closing materialization probe once, after the authoritative head + // read. Missing-only needs the earlier probe as a catalog filter. + let forcePull = + mode === 'full' && + healMissing && + Boolean(deps.hasMaterializedProject); + if (mode === 'missing-only') { + let materialized: boolean; + try { + materialized = await deps.hasMaterializedProject!( + project.projectId, + target, + ); + } catch (error) { + deps.onError?.(error); + complete = false; + retrySweep = true; + if (foregroundShouldPreempt()) { + preemptedByForeground = true; + break; + } + continue; + } + if (foregroundShouldPreempt()) { + complete = false; + preemptedByForeground = true; + break; + } + if (mode === 'missing-only' && materialized && !targetedOnly) continue; + forcePull = !materialized; + } + candidates.push({ project, forcePull }); + } + // A project-specific first-share recovery must not wait behind historical + // missing projects from the same workspace. Keep the broad safety-floor + // sweep, but pull its explicit witnesses first so unrelated downloads + // cannot delay the event that requested this recovery. + const expectedFirstCandidates = expectedProjectIds.size === 0 + ? candidates + : [ + ...candidates.filter(({ project }) => + expectedProjectIds.has(project.projectId)), + ...candidates.filter(({ project }) => + !expectedProjectIds.has(project.projectId)), + ]; + const broadCursorKey = JSON.stringify([mode, workspaceId]); + const previousBroadCursor = broadHeadCursors.get(broadCursorKey); + let broadStartIndex = 0; + if (!targetedOnly && previousBroadCursor) { + const exactIndex = expectedFirstCandidates.findIndex( + ({ project }) => project.projectId === previousBroadCursor, + ); + if (exactIndex >= 0) { + broadStartIndex = (exactIndex + 1) % expectedFirstCandidates.length; + } else { + const nextIndex = expectedFirstCandidates.findIndex( + ({ project }) => project.projectId > previousBroadCursor, + ); + broadStartIndex = nextIndex >= 0 ? nextIndex : 0; + } + } + const orderedCandidates = targetedOnly || broadStartIndex === 0 + ? expectedFirstCandidates + : [ + ...expectedFirstCandidates.slice(broadStartIndex), + ...expectedFirstCandidates.slice(0, broadStartIndex), + ]; + const markBroadCandidateInspected = (projectId: string): void => { + if (!targetedOnly) broadHeadCursors.set(broadCursorKey, projectId); + }; + // Deliberately sequential: reconnect is a recovery path, not permission + // to fan out one request per shared project at once. + for (const candidate of preemptedByForeground ? [] : orderedCandidates) { + // A verified hub event is the latency-sensitive lane. It already runs + // independently from broad reconnect recovery, but two Vela children + // still contend for the same session/network/object-store resources. + // Never abort a pull already materializing (the transport does not yet + // expose a proven-safe cancellation boundary); once that candidate + // settles, stop before launching the next historical download. Targeted + // first-share recovery is deliberately exempt. + if (foregroundShouldPreempt()) { + complete = false; + preemptedByForeground = true; + break; + } + const { project, forcePull } = candidate; + const binding = deps.getLocalBinding(project.projectId); + if (binding?.visibility === 'personal') { + markBroadCandidateInspected(project.projectId); + continue; + } + if (binding && binding.workspaceId !== workspaceId) { + markBroadCandidateInspected(project.projectId); + continue; + } + const target: ProactiveContentPullTarget = { + projectId: project.projectId, + workspaceId, + resourceTeamId: identity.resourceTeamId, + viewerMemberId: identity.workspaceMemberId, + ownerMemberId: project.ownerMemberId, + }; + const persistentRetry = targetedOnly || foreground; + const suppressedHead = suppressedBroadHeads.get(pullScopeKey(target)); + if ( + !persistentRetry && + suppressedHead && + now() < suppressedHead.retryAt + ) { + // The exact scope is still inside its bounded failure cooldown. Avoid + // even the per-project head CLI here: a newer head may wait until the + // next safety floor, while targeted/live events continue to bypass. + suppressed += 1; + complete = false; + markBroadCandidateInspected(project.projectId); + continue; + } + const persistedVersion = Number( + deps.materializedVersion?.(target) ?? NaN, + ); + if (!forcePull && Number.isFinite(persistedVersion)) { + const scopeKey = pullScopeKey(target); + const cursor = pulledVersions.get(scopeKey); + if (cursor == null || persistedVersion > cursor) { + pulledVersions.set(scopeKey, persistedVersion); + } + } + if (!targetedOnly && headChecks >= BROAD_HEAD_CHECK_BUDGET) { + complete = false; + break; + } + let version: number | null; + try { + headChecks += 1; + version = await deps.publishedHead(target); + } catch (error) { + deps.onError?.(error); + complete = false; + retrySweep = true; + markBroadCandidateInspected(project.projectId); + if (foregroundShouldPreempt()) { + preemptedByForeground = true; + break; + } + continue; + } + markBroadCandidateInspected(project.projectId); + if (foregroundShouldPreempt()) { + complete = false; + preemptedByForeground = true; + break; + } + if (version == null) continue; + heads += 1; + if (!forcePull && Number.isFinite(persistedVersion) && persistedVersion >= version) { + continue; + } + const materialized = await processContentChanged( + { projectId: project.projectId, workspaceId, version }, + project.ownerMemberId, + forcePull, + foreground, + persistentRetry, + ); + if (!materialized) { + complete = false; + } + if (foregroundShouldPreempt()) { + complete = false; + preemptedByForeground = true; + break; + } + } + deps.onCatchUp?.({ + phase: 'completed', + mode, + lane, + workspaceId, + scanned: projects.length, + candidates: candidates.length, + headChecks, + heads, + suppressed, + complete, + }); + return { + // The foreground resume re-runs the whole sweep, including any head + // read that failed before preemption. Do not also schedule the ordinary + // failure retry or two background lanes would restart together. + retryLane: preemptedByForeground ? false : retrySweep, + retryProjectIds, + retryWorkspaceId: workspaceId, + ...(preemptedByForeground ? { preemptedByForeground: true } : {}), + }; + } + + function mergeForegroundResumeRequest( + mode: CatchUpMode, + request: CatchUpSweepRequest, + ): void { + const existing = foregroundResumeRequests.get(mode); + if ( + existing && + existing.expectedWorkspaceId === request.expectedWorkspaceId + ) { + for (const projectId of request.expectedProjectIds) { + existing.expectedProjectIds.add(projectId); + } + existing.healMissing ||= request.healMissing; + return; + } + foregroundResumeRequests.set(mode, { + ...(request.expectedWorkspaceId + ? { expectedWorkspaceId: request.expectedWorkspaceId } + : {}), + expectedProjectIds: new Set(request.expectedProjectIds), + healMissing: request.healMissing, + }); + } + + function cancelForegroundResumeTimer(): void { + if (foregroundResumeTimer == null) return; + scheduler.clearTimeout(foregroundResumeTimer); + foregroundResumeTimer = null; + } + + function armForegroundResumeTimer(): void { + if ( + disposed || + foregroundEventsInFlight > 0 || + foregroundIntents.size > 0 || + foregroundCatalogRetries.size > 0 || + foregroundResumeRequests.size === 0 || + foregroundResumeTimer != null + ) { + return; + } + const generation = foregroundGeneration; + let handle: unknown; + handle = scheduler.setTimeout(async () => { + if (foregroundResumeTimer !== handle) return; + foregroundResumeTimer = null; + if (disposed) return; + if ( + foregroundEventsInFlight > 0 || + foregroundIntents.size > 0 || + foregroundCatalogRetries.size > 0 || + foregroundGeneration !== generation + ) { + armForegroundResumeTimer(); + return; + } + const requests = [...foregroundResumeRequests.entries()]; + foregroundResumeRequests.clear(); + await Promise.all( + requests.map(([mode, request]) => + requestCatchUp( + mode, + request.expectedWorkspaceId, + request.expectedProjectIds, + 'foreground-resume', + request.healMissing, + )), + ); + }, FOREGROUND_CATCH_UP_QUIET_MS); + foregroundResumeTimer = handle; + ( + handle as { unref?: () => void } | null | undefined + )?.unref?.(); + } + + function deferCatchUpForForeground( + mode: CatchUpMode, + request: CatchUpSweepRequest, + ): void { + mergeForegroundResumeRequest(mode, request); + armForegroundResumeTimer(); + } + + function cancelCatchUpRetry(lane: CatchUpLane): void { + if (lane.retryTimer == null) return; + scheduler.clearTimeout(lane.retryTimer); + lane.retryTimer = null; + } + + function scheduleCatchUpRetry( + mode: CatchUpMode, + expectedWorkspaceId?: string, + expectedProjectIds: ReadonlySet<string> = new Set(), + healMissing = false, + ): void { + const lane = catchUpLanes[mode]; + if (disposed || lane.retryTimer != null) return; + if (lane.retryFailures >= MAX_CATALOG_PROPAGATION_RETRIES) { + deps.onCatchUp?.({ + phase: 'retry-exhausted', + mode, + lane: 'broad', + ...(expectedWorkspaceId ? { workspaceId: expectedWorkspaceId } : {}), + failures: lane.retryFailures, + attempt: lane.retryFailures, + }); + return; + } + lane.retryFailures += 1; + const delayMs = retryDelay(lane.retryFailures); + deps.onCatchUp?.({ + phase: 'retry-scheduled', + mode, + lane: 'broad', + ...(expectedWorkspaceId ? { workspaceId: expectedWorkspaceId } : {}), + failures: lane.retryFailures, + attempt: lane.retryFailures, + delayMs, + }); + let handle: unknown; + handle = scheduler.setTimeout(async () => { + if (lane.retryTimer !== handle) return; + lane.retryTimer = null; + await requestCatchUp( + mode, + expectedWorkspaceId, + expectedProjectIds, + 'retry', + healMissing, + ); + }, delayMs); + lane.retryTimer = handle; + ( + handle as { unref?: () => void } | null | undefined + )?.unref?.(); + } + + function catalogProjectRetryKey( + workspaceId: string, + projectId: string, + ): string { + return JSON.stringify([workspaceId, projectId]); + } + + function markCatalogRetryForeground(retry: CatalogProjectRetry): void { + retry.foregroundRetryBudget = 1; + foregroundCatalogRetries.add(retry); + cancelForegroundResumeTimer(); + } + + function releaseCatalogRetryForeground(retry: CatalogProjectRetry): void { + delete retry.foregroundRetryBudget; + if (foregroundCatalogRetries.delete(retry)) { + armForegroundResumeTimer(); + } + } + + function clearCatalogProjectRetry( + workspaceId: string, + projectId: string, + ): void { + const key = catalogProjectRetryKey(workspaceId, projectId); + const retry = catalogProjectRetries.get(key); + if (!retry) return; + if (retry.timer != null) scheduler.clearTimeout(retry.timer); + retry.timer = null; + catalogProjectRetries.delete(key); + releaseCatalogRetryForeground(retry); + } + + function clearCatalogRetriesOutsideWorkspace(workspaceId: string): void { + for (const retry of catalogProjectRetries.values()) { + if (retry.workspaceId === workspaceId) continue; + clearCatalogProjectRetry(retry.workspaceId, retry.projectId); + } + } + + function scheduleCatalogProjectRetry( + workspaceId: string, + projectId: string, + ): void { + if (disposed) return; + const key = catalogProjectRetryKey(workspaceId, projectId); + let retry = catalogProjectRetries.get(key); + if (!retry) { + retry = { + key, + workspaceId, + projectId, + failures: 0, + timer: null, + }; + catalogProjectRetries.set(key, retry); + } + if (retry.timer != null) return; + if (retry.failures >= MAX_CATALOG_PROPAGATION_RETRIES) { + deps.onCatchUp?.({ + phase: 'retry-exhausted', + mode: 'missing-only', + lane: 'targeted', + workspaceId, + projectId, + failures: retry.failures, + attempt: retry.failures, + }); + return; + } + retry.failures += 1; + const delayMs = retryDelay(retry.failures); + deps.onCatchUp?.({ + phase: 'retry-scheduled', + mode: 'missing-only', + lane: 'targeted', + workspaceId, + projectId, + failures: retry.failures, + attempt: retry.failures, + delayMs, + }); + let handle: unknown; + handle = scheduler.setTimeout(async () => { + if (retry?.timer !== handle) return; + retry.timer = null; + if (foregroundCatalogRetries.has(retry)) { + retry.foregroundRetryBudget = Math.max( + 0, + (retry.foregroundRetryBudget ?? 0) - 1, + ); + } + await requestCatalogProjectRecovery(workspaceId, projectId, 'retry'); + if ( + foregroundCatalogRetries.has(retry) && + (retry.foregroundRetryBudget ?? 0) <= 0 + ) { + releaseCatalogRetryForeground(retry); + } + }, delayMs); + retry.timer = handle; + ( + handle as { unref?: () => void } | null | undefined + )?.unref?.(); + } + + async function requestCatalogProjectRecovery( + workspaceId: string, + projectId: string, + source: 'external' | 'retry', + foreground = false, + ): Promise<void> { + if (disposed) return; + const key = catalogProjectRetryKey(workspaceId, projectId); + if (source === 'external') { + clearCatalogRetriesOutsideWorkspace(workspaceId); + clearCatalogProjectRetry(workspaceId, projectId); + const retry: CatalogProjectRetry = { + key, + workspaceId, + projectId, + failures: 0, + timer: null, + }; + catalogProjectRetries.set(key, retry); + if (foreground) markCatalogRetryForeground(retry); + } + const retryState = catalogProjectRetries.get(key); + const runForeground = Boolean( + retryState && foregroundCatalogRetries.has(retryState), + ); + let targeted = targetedCatalogRecoveries.get(key); + if (!targeted) { + targeted = (async () => { + const outcome = await runCatchUpSweep( + 'missing-only', + workspaceId, + new Set([projectId]), + true, + runForeground, + ); + if (outcome.retryProjectIds.length > 0) { + const currentIdentity = await deps.getWorkspaceIdentity( + outcome.retryWorkspaceId ?? '', + ).catch( + (error) => { + deps.onError?.(error); + return null; + }, + ); + if ( + !currentIdentity || + currentIdentity.workspaceId !== outcome.retryWorkspaceId + ) { + return; + } + } + for (const retryProjectId of outcome.retryProjectIds) { + if (outcome.retryWorkspaceId) { + scheduleCatalogProjectRetry( + outcome.retryWorkspaceId, + retryProjectId, + ); + } + } + })(); + targetedCatalogRecoveries.set(key, targeted); + void targeted.then( + () => { + if (targetedCatalogRecoveries.get(key) === targeted) { + targetedCatalogRecoveries.delete(key); + } + }, + () => { + if (targetedCatalogRecoveries.get(key) === targeted) { + targetedCatalogRecoveries.delete(key); + } + }, + ); + } + await targeted; + const retry = catalogProjectRetries.get(key); + if (retry?.timer == null) { + catalogProjectRetries.delete(key); + if (retry) releaseCatalogRetryForeground(retry); + } + } + + async function requestCatchUp( + mode: CatchUpMode, + expectedWorkspaceId?: string, + expectedProjectIds: ReadonlySet<string> = new Set(), + source: 'external' | 'retry' | 'foreground-resume' = 'external', + healMissing = false, + ): Promise<void> { + if (disposed) return; + const lane = catchUpLanes[mode]; + if (source === 'external') { + cancelCatchUpRetry(lane); + lane.retryFailures = 0; + } + lane.requestedGeneration += 1; + // Requests for the same workspace UNION their project witnesses so two + // first shares cannot overwrite one another while a sweep is in flight. + // A different verified workspace supersedes stale pending work. + if ( + lane.requestedSweep && + lane.requestedSweep.expectedWorkspaceId === expectedWorkspaceId + ) { + for (const projectId of expectedProjectIds) { + lane.requestedSweep.expectedProjectIds.add(projectId); + } + lane.requestedSweep.healMissing ||= healMissing; + } else { + lane.requestedSweep = { + ...(expectedWorkspaceId ? { expectedWorkspaceId } : {}), + expectedProjectIds: new Set(expectedProjectIds), + healMissing, + }; + } + if (lane.inFlight) { + return lane.inFlight; + } + const run = (async () => { + while (lane.completedGeneration < lane.requestedGeneration) { + const generation = lane.requestedGeneration; + const next: CatchUpSweepRequest = lane.requestedSweep ?? { + expectedProjectIds: new Set(), + healMissing: false, + }; + lane.requestedSweep = null; + if (next.expectedWorkspaceId) { + // A workspace switch may arrive while the preceding sweep is still + // in flight. Clear any stale retries that sweep just rescheduled + // before processing the new verified scope. + clearCatalogRetriesOutsideWorkspace(next.expectedWorkspaceId); + } + const outcome = await runCatchUpSweep( + mode, + next.expectedWorkspaceId, + next.expectedProjectIds, + false, + false, + next.healMissing, + ); + lane.completedGeneration = generation; + if (outcome.preemptedByForeground) { + deferCatchUpForForeground(mode, next); + } + for (const projectId of outcome.retryProjectIds) { + if (outcome.retryWorkspaceId) { + scheduleCatalogProjectRetry( + outcome.retryWorkspaceId, + projectId, + ); + } + } + if (lane.completedGeneration < lane.requestedGeneration) continue; + if (outcome.retryLane) { + scheduleCatchUpRetry( + mode, + next.expectedWorkspaceId, + next.expectedProjectIds, + next.healMissing, + ); + } else { + lane.retryFailures = 0; + } + } + })(); + lane.inFlight = run; + try { + await run; + } finally { + if (lane.inFlight === run) lane.inFlight = null; + } + } + + return { + async handleContentChanged(event: ProactiveContentPullEvent): Promise<void> { + if (event.projectId) { + reportTiming({ + phase: 'queued', + projectId: event.projectId, + ...(event.version != null ? { version: event.version } : {}), + ...(event.profileReceivedAtMs != null + ? { receivedAtMs: event.profileReceivedAtMs } + : {}), + atMs: Date.now(), + }); + } + const isForeground = Boolean(event.projectId); + if (isForeground) { + foregroundGeneration += 1; + foregroundEventsInFlight += 1; + cancelForegroundResumeTimer(); + } + try { + const settled = await processContentChanged( + event, + undefined, + false, + isForeground, + ); + if (settled) { + try { + deps.onEventSettled?.(event); + } catch { + // Lifecycle observation must never affect pull behavior. + } + } + } finally { + if (isForeground) { + foregroundEventsInFlight -= 1; + armForegroundResumeTimer(); + } + } + }, + catchUpPublishedHeads: (workspaceId) => + requestCatchUp('full', workspaceId), + advanceRecoveryFloor: (workspaceId) => + requestCatchUp( + 'full', + workspaceId, + new Set(), + 'external', + true, + ), + materializeMissingProjects: (workspaceId, projectId) => + projectId && workspaceId + ? requestCatalogProjectRecovery(workspaceId, projectId, 'external') + : requestCatchUp('missing-only', workspaceId), + observeMaterialized, + dispose(): void { + if (disposed) return; + disposed = true; + for (const intent of intents.values()) clearIntent(intent); + intents.clear(); + for (const lane of Object.values(catchUpLanes)) { + cancelCatchUpRetry(lane); + } + cancelForegroundResumeTimer(); + foregroundIntents.clear(); + foregroundResumeRequests.clear(); + for (const retry of [...catalogProjectRetries.values()]) { + clearCatalogProjectRetry(retry.workspaceId, retry.projectId); + } + foregroundCatalogRetries.clear(); + targetedCatalogRecoveries.clear(); + targetedCatalogReads.clear(); + suppressedBroadHeads.clear(); + broadHeadCursors.clear(); + }, + }; +} diff --git a/apps/daemon/src/collab/project-content-transfer-state.ts b/apps/daemon/src/collab/project-content-transfer-state.ts new file mode 100644 index 00000000000..fa160f86a08 --- /dev/null +++ b/apps/daemon/src/collab/project-content-transfer-state.ts @@ -0,0 +1,152 @@ +import type { ProjectContentTransferState } from '@open-design/contracts'; + +export interface ProjectContentTransferScope { + projectId: string; + workspaceId: string; + resourceTeamId: string; + viewerMemberId: string; + ownerMemberId: string; +} + +declare const projectContentTransferTokenBrand: unique symbol; + +/** + * Opaque capability for completing one exact transfer generation. Runtime + * membership is also checked, so a structurally copied token cannot finish a + * transfer. + */ +export interface ProjectContentTransferToken { + readonly generation: number; + readonly scopeKey: string; + readonly [projectContentTransferTokenBrand]: true; +} + +export interface ProjectContentTransferStart { + token: ProjectContentTransferToken; + state: ProjectContentTransferState; +} + +export interface ProjectContentTransferStateStore { + begin( + scope: ProjectContentTransferScope, + version?: number, + ): ProjectContentTransferStart; + finish( + scope: ProjectContentTransferScope, + token: ProjectContentTransferToken, + version?: number, + ): ProjectContentTransferState | null; + read(scope: ProjectContentTransferScope): ProjectContentTransferState | null; +} + +export interface ProjectContentTransferStateStoreOptions { + now?: () => number; + onChange?: ( + scope: ProjectContentTransferScope, + state: ProjectContentTransferState, + ) => void; +} + +interface StoredTransfer { + scope: ProjectContentTransferScope; + generation: number; + state: ProjectContentTransferState; +} + +const scopeKey = (scope: ProjectContentTransferScope): string => + JSON.stringify([ + scope.projectId, + scope.workspaceId, + scope.resourceTeamId, + scope.viewerMemberId, + scope.ownerMemberId, + ]); + +const copyScope = ( + scope: ProjectContentTransferScope, +): ProjectContentTransferScope => Object.freeze({ ...scope }); + +/** + * Process-local transfer snapshot used by both project SSE and + * `/collab/status`. + * + * A project id alone is not a safe identity: the same id can be observed + * across workspace/resource/owner bindings. Each actual begin also issues a + * fresh opaque generation token. Only that exact token together with the same + * scope may finish the current transfer, so a versionless event or an older + * async completion cannot hide a newer transfer. + */ +export function createProjectContentTransferStateStore( + options: ProjectContentTransferStateStoreOptions = {}, +): ProjectContentTransferStateStore { + const states = new Map<string, StoredTransfer>(); + const generations = new Map<string, number>(); + const issuedTokens = new WeakSet<object>(); + const now = options.now ?? Date.now; + + const nextTimestamp = (previous?: ProjectContentTransferState): number => + Math.max(now(), (previous?.updatedAt ?? Number.NEGATIVE_INFINITY) + 1); + + const publish = ( + key: string, + scope: ProjectContentTransferScope, + generation: number, + state: ProjectContentTransferState, + ): ProjectContentTransferState => { + states.set(key, { scope, generation, state }); + options.onChange?.(scope, state); + return state; + }; + + return { + begin(inputScope, version) { + const scope = copyScope(inputScope); + const key = scopeKey(scope); + const previous = states.get(key); + const generation = (generations.get(key) ?? 0) + 1; + generations.set(key, generation); + const token = Object.freeze({ + generation, + scopeKey: key, + }) as ProjectContentTransferToken; + issuedTokens.add(token); + const at = nextTimestamp(previous?.state); + const state = publish(key, scope, generation, { + status: 'downloading', + ...(version != null ? { version } : {}), + startedAt: + previous?.state.status === 'downloading' + ? previous.state.startedAt + : at, + updatedAt: at, + }); + return { token, state }; + }, + + finish(inputScope, token, version) { + const key = scopeKey(inputScope); + const previous = states.get(key); + if (!previous) return null; + if ( + !issuedTokens.has(token) + || token.scopeKey !== key + || token.generation !== previous.generation + ) { + return previous.state; + } + if (previous.state.status === 'idle') return previous.state; + const at = nextTimestamp(previous.state); + const resolvedVersion = version ?? previous.state.version; + return publish(key, previous.scope, previous.generation, { + status: 'idle', + ...(resolvedVersion != null ? { version: resolvedVersion } : {}), + startedAt: previous.state.startedAt, + updatedAt: at, + }); + }, + + read(scope) { + return states.get(scopeKey(scope))?.state ?? null; + }, + }; +} diff --git a/apps/daemon/src/collab/project-request-authority.ts b/apps/daemon/src/collab/project-request-authority.ts new file mode 100644 index 00000000000..afae2356078 --- /dev/null +++ b/apps/daemon/src/collab/project-request-authority.ts @@ -0,0 +1,125 @@ +import type { Response } from 'express'; +import { + enforceVerifiedWorkspaceResourceMutation, + enforceVerifiedWorkspaceResourceRead, + type VerifyWorkspaceRequestAuthority, + type WorkspaceResourceAccessInput, + type WorkspaceResourceMutationCapability, +} from './workspace-resource-mutation.js'; + +export type AuthorizeProjectRequestOptions = + | { + mode: 'read'; + /** EventSource, iframe, and direct asset navigation cannot set headers. */ + allowNavigationQuery?: boolean; + } + | { + mode: 'write'; + capability: WorkspaceResourceMutationCapability; + }; + +export type AuthorizeProjectRequest = ( + req: any, + res: Response, + projectId: string, + options: AuthorizeProjectRequestOptions, +) => Promise<boolean>; + +export type AuthorizeProjectToolRequest = ( + res: Response, + projectId: string, + options: AuthorizeProjectRequestOptions, +) => Promise<boolean>; + +/** + * Build the one project data-plane authority gate used by route modules. + * + * Persisted project binding is the resource identity. Bound projects require a + * fresh exact Workspace/member verification on every request; no active, + * current, or last-known daemon Workspace participates. Unbound pre-Workspace + * local projects retain their legacy behavior. + */ +export function createAuthorizeProjectRequest(deps: { + db: unknown; + getWorkspaceProject: ( + db: unknown, + workspaceId: string, + projectId: string, + ) => WorkspaceResourceAccessInput | null | undefined; + getWorkspaceProjectByProjectId: ( + db: unknown, + projectId: string, + ) => WorkspaceResourceAccessInput | null | undefined; + /** + * Bounded successful authority lease for idempotent project reads. When + * omitted, reads retain the mutation verifier for compatibility with narrow + * fixtures and callers that do not provide separate cache policy. + */ + verifyWorkspaceReadAuthority?: VerifyWorkspaceRequestAuthority; + /** Fresh fail-closed authority used for every project mutation. */ + verifyWorkspaceRequestAuthority?: VerifyWorkspaceRequestAuthority; + /** + * Durable local quarantine witness for a pulled mirror whose authoritative + * Team catalog row disappeared. Kept outside the generic workspace binding + * shape because it lives on project metadata for restart-safe recovery. + */ + isProjectRevoked?: (db: unknown, projectId: string) => boolean; + sendApiError: ( + res: Response, + status: number, + code: string, + message: string, + details?: Record<string, unknown>, + ) => unknown; +}): AuthorizeProjectRequest { + const { + db, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + verifyWorkspaceReadAuthority, + verifyWorkspaceRequestAuthority, + isProjectRevoked, + sendApiError, + } = deps; + return async (req, res, projectId, options) => { + if (isProjectRevoked?.(db, projectId)) { + sendApiError( + res, + options.mode === 'read' ? 404 : 403, + options.mode === 'read' + ? 'PROJECT_NOT_FOUND' + : 'WORKSPACE_PROJECT_PERMISSION_DENIED', + options.mode === 'read' + ? 'project not found' + : 'workspace project mutation is not allowed', + ); + return false; + } + if (options.mode === 'write') { + return await enforceVerifiedWorkspaceResourceMutation( + 'project', + req, + res, + sendApiError, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + db, + projectId, + options.capability, + verifyWorkspaceRequestAuthority, + ); + } + return await enforceVerifiedWorkspaceResourceRead( + 'project', + req, + res, + sendApiError, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + db, + projectId, + verifyWorkspaceReadAuthority ?? verifyWorkspaceRequestAuthority, + options.allowNavigationQuery ? { allowNavigationQuery: true } : {}, + ); + }; +} diff --git a/apps/daemon/src/collab/project-share-dir.ts b/apps/daemon/src/collab/project-share-dir.ts new file mode 100644 index 00000000000..f69942110ef --- /dev/null +++ b/apps/daemon/src/collab/project-share-dir.ts @@ -0,0 +1,9 @@ +export function resolveProjectShareDir( + projectsRoot: string, + projectId: string, + project: { id: string; metadata?: unknown } | null | undefined, + resolveProjectDir: (projectsRoot: string, projectId: string, metadata?: unknown) => string, +): string { + if (!project) throw new Error(`Project ${projectId} not found`); + return resolveProjectDir(projectsRoot, projectId, project.metadata); +} diff --git a/apps/daemon/src/collab/project-workspace-scope.ts b/apps/daemon/src/collab/project-workspace-scope.ts new file mode 100644 index 00000000000..ed628cc6cee --- /dev/null +++ b/apps/daemon/src/collab/project-workspace-scope.ts @@ -0,0 +1,145 @@ +import type { + ProjectVisibility, + ProjectWorkspaceScope, +} from '@open-design/contracts'; +import { + workspaceContextFromDirectoryItem, + type WorkspaceDirectoryFetchResult, +} from './vela-workspace-context.js'; + +interface ProjectWorkspaceBinding { + workspaceId?: unknown; + visibility?: unknown; + resourceState?: unknown; +} + +export type ProjectWorkspaceScopeBootstrapResult = + | { + ok: true; + scope: ProjectWorkspaceScope; + } + | { + ok: false; + status: 403 | 503; + code: 'WORKSPACE_PROJECT_PERMISSION_DENIED' | 'WORKSPACE_DIRECTORY_UNAVAILABLE'; + message: string; + }; + +/** + * Resolve a project's persisted workspace binding against the signed-in + * caller's authoritative membership directory. + * + * Directory ordering and the daemon's ambient/active workspace are + * intentionally irrelevant. A missing or failed exact membership lookup + * stays `unavailable`; it must never borrow another workspace's member id. + */ +export function resolveProjectWorkspaceScope(input: { + projectId: string; + binding: ProjectWorkspaceBinding | null | undefined; + directory: WorkspaceDirectoryFetchResult; +}): ProjectWorkspaceScope { + const projectId = input.projectId.trim(); + const workspaceId = + typeof input.binding?.workspaceId === 'string' + ? input.binding.workspaceId.trim() + : ''; + if (!workspaceId) { + return { + kind: 'unbound', + projectId, + workspaceId: null, + context: null, + }; + } + + const visibility: ProjectVisibility = + input.binding?.visibility === 'team' ? 'team' : 'personal'; + const unavailable = (): ProjectWorkspaceScope => ({ + kind: 'unavailable', + projectId, + workspaceId, + visibility, + context: null, + }); + if (!input.directory.ok) return unavailable(); + + const item = input.directory.items.find( + (candidate) => + candidate.workspaceId === workspaceId && + candidate.memberStatus === 'active' && + candidate.lifecycleState !== 'deleted', + ); + if (!item) return unavailable(); + + const context = workspaceContextFromDirectoryItem(item); + if ( + context.workspaceId !== workspaceId || + !context.workspaceMemberId || + context.memberStatus !== 'active' + ) { + return unavailable(); + } + if (context.workspaceType === 'team') { + return { + kind: 'team', + projectId, + workspaceId, + visibility, + context: { ...context, workspaceType: 'team' }, + }; + } + return { + kind: 'personal', + projectId, + workspaceId, + visibility, + context: { ...context, workspaceType: 'personal' }, + }; +} + +/** + * Resolve the one headerless bootstrap read used by a fresh project deep link. + * + * This does not authorize project content. It discloses a persisted binding + * only after a fresh signed-in directory proves the caller is an active member + * of that exact Workspace. The web must then attach the returned context to + * every project data-plane request, which still passes the normal route gate. + */ +export function resolveProjectWorkspaceScopeBootstrap(input: { + projectId: string; + binding: ProjectWorkspaceBinding | null | undefined; + directory: WorkspaceDirectoryFetchResult; +}): ProjectWorkspaceScopeBootstrapResult { + if (!input.binding?.workspaceId) { + return { + ok: true, + scope: resolveProjectWorkspaceScope(input), + }; + } + if (input.binding.resourceState === 'deleted') { + return { + ok: false, + status: 403, + code: 'WORKSPACE_PROJECT_PERMISSION_DENIED', + message: 'workspace project read is not allowed', + }; + } + if (!input.directory.ok) { + return { + ok: false, + status: 503, + code: 'WORKSPACE_DIRECTORY_UNAVAILABLE', + message: 'workspace membership directory is unavailable', + }; + } + const scope = resolveProjectWorkspaceScope(input); + if (scope.kind === 'unavailable' || scope.context === null) { + return { + ok: false, + status: 403, + code: 'WORKSPACE_PROJECT_PERMISSION_DENIED', + message: 'workspace project read is not allowed', + }; + } + return { ok: true, scope }; +} diff --git a/apps/daemon/src/collab/publish-scheduler.ts b/apps/daemon/src/collab/publish-scheduler.ts new file mode 100644 index 00000000000..fa8fcd762bb --- /dev/null +++ b/apps/daemon/src/collab/publish-scheduler.ts @@ -0,0 +1,177 @@ +// Team collaboration sync trigger — the author-side "trigger + orchestration" that the sync trigger owns. +// +// It does NOT implement the resource store: publishing content + advancing the +// `published` ref is the resource hub (the resource-hub owner, the resource hub the spec = createVersion + setRef). +// C's job is *when* to publish: coalesce rapid author edits into one publish so +// half-written intermediate states never reach members, flush at run boundaries, +// and — on success — let the orchestrator notify online members to pull. +// +// Invariant: notification happens strictly AFTER the adapter's +// publish resolves (content durable, pointer moved), so members are never told to +// pull a version that is not yet durable. The adapter is expected to resolve only +// on durable success (E's atomic write); this scheduler adds the coalescing. + +import type { ResourceHubPrincipal } from './resource-principal.js'; + +export interface ResourcePublishInput { + projectId: string; + principal?: ResourceHubPrincipal; +} + +export interface PublishedResourceVersion { + version: number; + versionId?: string; +} + +export interface ResourcePublishAdapter { + /** + * Publish the current state of a project's sync unit to the resource hub and + * advance its `published` ref. Resolves ONLY after the content is durably + * written (content-first / pointer-last). Returns the new version, or null if + * there was nothing to publish. + */ + publish(input: ResourcePublishInput & { reason: string }): Promise<PublishedResourceVersion | null>; + /** + * Read the currently-published head for a project. The scheduler decides + * *when* a member pulls; the adapter reports what head is available. Optional: + * the local stub reports the in-memory head; the real hub adapter resolves the + * published ref. Returns null when nothing has been published yet. + */ + syncLatest?(input: ResourcePublishInput): Promise<PublishedResourceVersion | null>; + /** + * Materialize the published tree into the member's local copy. Optional: the + * local stub has no bytes to fetch; the real hub adapter fetches the missing + * blobs and writes the files. The scheduler decides *when* to pull. A real + * materializer must return the exact version it landed on disk; a later head + * read is not equivalent because the ref may advance while bytes are in + * flight. + */ + pull?(input: ResourcePublishInput): Promise<PublishedResourceVersion | null>; + /** + * Remove the project from the shared team index. Existing immutable versions may + * remain in the hub, but team members should no longer discover/pull it from the + * team project list. Optional: older/local adapters can no-op. + */ + unpublish?(input: ResourcePublishInput): Promise<void>; +} + +export interface CollabPublishSchedulerOptions { + adapter: ResourcePublishAdapter; + /** Coalesce window (ms). Rapid changes within it collapse into one publish. */ + debounceMs?: number; + /** Fired after a successful publish so the orchestrator can notify members. */ + onPublished?: (result: { + projectId: string; + version: number; + versionId?: string; + reason: string; + }) => void; + onError?: (result: { projectId: string; error: unknown }) => void; +} + +interface ProjectState { + timer: ReturnType<typeof setTimeout> | null; + reason: string; + publishing: boolean; + /** A change arrived while a publish was in flight → re-publish after it settles. */ + dirty: boolean; + dirtyReason: string; +} + +const DEFAULT_DEBOUNCE_MS = 400; + +export class CollabPublishScheduler { + private readonly adapter: ResourcePublishAdapter; + private readonly debounceMs: number; + private readonly onPublished?: CollabPublishSchedulerOptions['onPublished']; + private readonly onError?: CollabPublishSchedulerOptions['onError']; + private readonly projects = new Map<string, ProjectState>(); + + constructor(options: CollabPublishSchedulerOptions) { + this.adapter = options.adapter; + this.debounceMs = Math.max(0, options.debounceMs ?? DEFAULT_DEBOUNCE_MS); + this.onPublished = options.onPublished; + this.onError = options.onError; + } + + /** An author-side change to a project. Publishes are coalesced within the window. */ + notifyChanged(projectId: string, reason = 'change'): void { + const state = this.ensure(projectId); + state.reason = reason; + if (state.publishing) { + // Don't interrupt an in-flight publish — mark dirty so a fresh one runs + // after it settles (last-write-wins; the change is never lost). + state.dirty = true; + state.dirtyReason = reason; + return; + } + if (state.timer) clearTimeout(state.timer); + state.timer = setTimeout(() => { + void this.flush(projectId); + }, this.debounceMs); + } + + /** + * Run boundary — flush any pending publish immediately instead of waiting out + * the debounce, so members see the stable end-of-run state promptly. + */ + runBoundary(projectId: string): void { + const state = this.projects.get(projectId); + if (!state) return; + if (state.timer) { + clearTimeout(state.timer); + state.timer = null; + } + if (state.publishing) { + state.dirty = true; + state.dirtyReason = state.reason; + return; + } + void this.flush(projectId); + } + + /** Cancel all pending timers (shutdown). */ + dispose(): void { + for (const state of this.projects.values()) { + if (state.timer) clearTimeout(state.timer); + } + this.projects.clear(); + } + + private async flush(projectId: string): Promise<void> { + const state = this.projects.get(projectId); + if (!state || state.publishing) return; + state.timer = null; + state.publishing = true; + const reason = state.reason; + try { + const result = await this.adapter.publish({ projectId, reason }); + if (result) { + this.onPublished?.({ + projectId, + version: result.version, + ...(result.versionId ? { versionId: result.versionId } : {}), + reason, + }); + } + } catch (error) { + this.onError?.({ projectId, error }); + } finally { + state.publishing = false; + if (state.dirty) { + state.dirty = false; + // A change landed during the publish — schedule a fresh one. + this.notifyChanged(projectId, state.dirtyReason || 'change'); + } + } + } + + private ensure(projectId: string): ProjectState { + let state = this.projects.get(projectId); + if (!state) { + state = { timer: null, reason: 'change', publishing: false, dirty: false, dirtyReason: 'change' }; + this.projects.set(projectId, state); + } + return state; + } +} diff --git a/apps/daemon/src/collab/pull-profile.ts b/apps/daemon/src/collab/pull-profile.ts new file mode 100644 index 00000000000..99f58c3fa8e --- /dev/null +++ b/apps/daemon/src/collab/pull-profile.ts @@ -0,0 +1,194 @@ +const ENABLED_VALUES = new Set(['1', 'true', 'yes', 'on']); +const VELA_PULL_PHASES = new Set([ + 'load_profile', + 'resolve_cache', + 'resolve_ref', + 'get_manifest', + 'download_authorization', + 'object_store_download', + 'materialize_snapshot', +]); + +export type SharedProjectPullTimingPhase = + | 'event-received' + | 'queued' + | 'guard-started' + | 'guard-completed' + | 'invoke' + | 'completed' + | 'route-started' + | 'initial-authorization-reused' + | 'authorized-stage-started' + | 'authorized-stage-done' + | 'authorized-receipt-validated' + | 'authorized-scope-revalidated' + | 'promotion-started' + | 'promotion-done' + | 'version-persisted' + | 'transport-invoke' + | 'transport-done' + | 'registration-prepared' + | 'catalog-revalidated' + | 'scope-revalidated' + | 'mirror-materialized' + | 'version-write-started' + | 'persisted' + | 'route-completed'; + +export interface SharedProjectPullTimingEvent { + phase: SharedProjectPullTimingPhase; + projectId: string; + version?: number | undefined; + receivedAtMs?: number | undefined; + atMs?: number | undefined; + status?: string | undefined; +} + +interface VelaPullPhase { + name: string; + count: number; + totalMs: number; + maxMs: number; +} + +function finiteNonNegative(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 + ? value + : null; +} + +function safeText(value: unknown, maxLength = 256): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed && trimmed.length <= maxLength ? trimmed : null; +} + +function safeTimestamp(value: unknown): string | null { + const timestamp = safeText(value, 64); + return timestamp && Number.isFinite(Date.parse(timestamp)) ? timestamp : null; +} + +export function sharedProjectPullProfileEnabled( + env: NodeJS.ProcessEnv = process.env, +): boolean { + return ENABLED_VALUES.has( + env.OD_COLLAB_PULL_PROFILE?.trim().toLowerCase() ?? '', + ); +} + +export function emitSharedProjectPullTiming( + event: SharedProjectPullTimingEvent, + env: NodeJS.ProcessEnv = process.env, +): void { + if (!sharedProjectPullProfileEnabled(env)) return; + const atMs = finiteNonNegative(event.atMs) ?? Date.now(); + const receivedAtMs = finiteNonNegative(event.receivedAtMs); + console.info( + `[od] shared_project_pull_profile ${JSON.stringify({ + event: 'shared_project_pull_profile', + schemaVersion: 1, + phase: event.phase, + projectId: event.projectId, + ...(event.version != null ? { version: event.version } : {}), + atMs, + ...(receivedAtMs != null + ? { + receivedAtMs, + elapsedSinceEventMs: Math.max(0, atMs - receivedAtMs), + } + : {}), + ...(event.status ? { status: event.status } : {}), + })}`, + ); +} + +/** + * Parse only Vela's secret-free resource pull profile envelope. Arbitrary + * stderr remains discarded: warnings can contain URLs, paths, or credentials. + */ +export function emitVelaResourcePullProfile( + stderr: string, + env: NodeJS.ProcessEnv = process.env, +): void { + if (!sharedProjectPullProfileEnabled(env)) return; + for (const line of stderr.split(/\r?\n/u)) { + if (!line.trim()) continue; + let candidate: unknown; + try { + candidate = JSON.parse(line); + } catch { + continue; + } + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) { + continue; + } + const record = candidate as Record<string, unknown>; + if ( + record.event !== 'resource_pull_profile' || + record.schemaVersion !== 1 || + typeof record.success !== 'boolean' + ) { + continue; + } + const totalMs = finiteNonNegative(record.totalMs); + const startedAt = safeTimestamp(record.startedAt); + const finishedAt = safeTimestamp(record.finishedAt); + const kind = safeText(record.kind, 64); + const resourceId = safeText(record.resourceId); + const ref = safeText(record.ref, 64); + if ( + totalMs == null || + !startedAt || + !finishedAt || + !kind || + !resourceId || + !ref + ) { + continue; + } + const phases: VelaPullPhase[] = []; + const seenPhases = new Set<string>(); + if (Array.isArray(record.phases)) { + for (const rawPhase of record.phases) { + if (phases.length >= VELA_PULL_PHASES.size) break; + if (!rawPhase || typeof rawPhase !== 'object' || Array.isArray(rawPhase)) { + continue; + } + const phase = rawPhase as Record<string, unknown>; + const name = safeText(phase.name, 64); + const count = finiteNonNegative(phase.count); + const phaseTotalMs = finiteNonNegative(phase.totalMs); + const maxMs = finiteNonNegative(phase.maxMs); + if ( + !name || + !VELA_PULL_PHASES.has(name) || + count == null || + !Number.isInteger(count) || + phaseTotalMs == null || + maxMs == null + ) { + continue; + } + if (seenPhases.has(name)) continue; + seenPhases.add(name); + phases.push({ name, count, totalMs: phaseTotalMs, maxMs }); + } + } + console.info( + `[od] shared_project_pull_profile ${JSON.stringify({ + event: 'shared_project_pull_profile', + schemaVersion: 1, + phase: 'vela-child-done', + atMs: Date.now(), + success: record.success, + kind, + resourceId, + ref, + startedAt, + finishedAt, + totalMs, + phases, + })}`, + ); + } +} diff --git a/apps/daemon/src/collab/request-workspace-context.ts b/apps/daemon/src/collab/request-workspace-context.ts new file mode 100644 index 00000000000..c6ef21a9d0c --- /dev/null +++ b/apps/daemon/src/collab/request-workspace-context.ts @@ -0,0 +1,89 @@ +import type { WorkspaceCollabContext } from '@open-design/contracts'; +import { workspaceResourceContextFromRequest } from './workspace-resource-mutation.js'; +import { + workspaceContextFromDirectoryItem, + type WorkspaceDirectoryFetchResult, +} from './vela-workspace-context.js'; + +export type VerifiedWorkspaceRequestContextResult = + | { ok: true; context: WorkspaceCollabContext } + | { + ok: false; + status: 400 | 403 | 503; + code: + | 'WORKSPACE_CONTEXT_REQUIRED' + | 'WORKSPACE_CONTEXT_INCOMPLETE' + | 'WORKSPACE_AUTHORITY_UNAVAILABLE' + | 'WORKSPACE_ACCESS_DENIED'; + message: string; + retryable?: true; + }; + +/** + * Resolve a request's explicit Workspace identity against the signed-in + * account's authoritative membership directory. + * + * Data-plane routes must call this instead of reading the daemon's mutable + * active Workspace. The headers choose which membership to verify; the + * directory supplies every authority-bearing field. + */ +export async function verifyWorkspaceRequestContext(input: { + req: unknown; + fetchWorkspaceDirectory: () => Promise<WorkspaceDirectoryFetchResult>; + requireTeam?: boolean; +}): Promise<VerifiedWorkspaceRequestContextResult> { + const claimed = workspaceResourceContextFromRequest(input.req); + if (claimed === null) { + return { + ok: false, + status: 400, + code: 'WORKSPACE_CONTEXT_REQUIRED', + message: 'an explicit workspace context is required', + }; + } + if (claimed === 'missing') { + return { + ok: false, + status: 400, + code: 'WORKSPACE_CONTEXT_INCOMPLETE', + message: 'both workspace and member identity are required', + }; + } + + let directory: WorkspaceDirectoryFetchResult; + try { + directory = await input.fetchWorkspaceDirectory(); + } catch { + directory = { ok: false, items: [] }; + } + if (!directory.ok) { + return { + ok: false, + status: 503, + code: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + message: 'workspace membership authority is temporarily unavailable', + retryable: true, + }; + } + + const membership = directory.items.find( + (item) => + item.workspaceId === claimed.workspaceId + && item.workspaceMemberId === claimed.workspaceMemberId + && item.memberStatus === 'active' + && item.lifecycleState !== 'deleted', + ); + if (!membership || (input.requireTeam && membership.workspaceType !== 'team')) { + return { + ok: false, + status: 403, + code: 'WORKSPACE_ACCESS_DENIED', + message: 'the requested workspace is not available to this member', + }; + } + + return { + ok: true, + context: workspaceContextFromDirectoryItem(membership), + }; +} diff --git a/apps/daemon/src/collab/resource-principal.ts b/apps/daemon/src/collab/resource-principal.ts new file mode 100644 index 00000000000..d78599546eb --- /dev/null +++ b/apps/daemon/src/collab/resource-principal.ts @@ -0,0 +1,31 @@ +import { + workspaceContextHasTeamIdentity, + type WorkspaceCollabContext, +} from '@open-design/contracts'; + +/** + * Workspace identity used to scope collaboration state. Authentication is + * owned by the Vela login session; these fields are routing context only. + */ +export interface ResourceHubPrincipal { + memberId: string; + /** Workspace-scoped resource id. Historically named teamId by the Vela CLI. */ + teamId: string; + role: WorkspaceCollabContext['role']; + lifecycleState: WorkspaceCollabContext['lifecycleState']; + workspaceType?: WorkspaceCollabContext['workspaceType']; +} + +/** Derive resource scope from the one login-backed workspace context. */ +export function contextToResourceHubPrincipal( + context: WorkspaceCollabContext | null, +): ResourceHubPrincipal | null { + if (!context || !workspaceContextHasTeamIdentity(context)) return null; + return { + memberId: context.workspaceMemberId, + teamId: context.teamId ?? context.workspaceId, + role: context.role, + lifecycleState: context.lifecycleState, + workspaceType: context.workspaceType, + }; +} diff --git a/apps/daemon/src/collab/runtime.ts b/apps/daemon/src/collab/runtime.ts new file mode 100644 index 00000000000..e2bb1e5032b --- /dev/null +++ b/apps/daemon/src/collab/runtime.ts @@ -0,0 +1,723 @@ +// Team collaboration daemon subsystem: bundles the author-side publish +// scheduler and the presence tracker behind one factory so the server wires +// them once. The resource hub itself is E's (the resource-hub owner) — this +// holds only C's trigger + presence, talking to the hub through +// ResourcePublishAdapter. + +import type { ProjectSyncState } from '@open-design/contracts'; +import { projectResourceIdFor } from '../integrations/vela-team-projects.js'; +import { + CollabPresenceTracker, + type CollabPresenceTrackerOptions, + type PresenceMember, +} from './presence-tracker.js'; +import { + CollabPublishScheduler, + type CollabPublishSchedulerOptions, + type PublishedResourceVersion, + type ResourcePublishAdapter, +} from './publish-scheduler.js'; +import type { ResourceHubPrincipal } from './resource-principal.js'; +import { createStubResourcePublishAdapter } from './stub-resource-adapter.js'; +import { + createDevTeamResourceStateProvider, + type TeamResourceStateProvider, +} from './team-resource-state.js'; +import { + createVelaCliResourceAdapter, + shouldUseVelaCliResourceTransport, +} from './vela-cli-resource-adapter.js'; +import type { WorkspaceContextProvider } from './workspace-context.js'; +import { createWorkspaceContextProviderFromEnv } from './vela-workspace-context.js'; + +type TeamProjectCatalogSyncState = 'pending_upload' | 'synced' | 'failed'; + +interface TeamProjectCatalogSink { + upsert( + input: { + projectId: string; + resourceId: string; + displayName?: string | null; + syncState?: TeamProjectCatalogSyncState; + lastSyncedVersionId?: string | null; + metadata?: Record<string, unknown> | null; + }, + principal?: ResourceHubPrincipal | null, + ): Promise<unknown>; + remove?(projectId: string, principal?: ResourceHubPrincipal | null): Promise<unknown>; +} + +/** + * The subset of {@link CollabPublishScheduler} the rest of the daemon is + * allowed to drive directly (the HTTP routes and the project file watcher — + * see `server.ts`'s `notifyFilesChanged` wiring). Narrowed to an interface, + * rather than exposing the class, so `createCollabRuntime` can hand out a + * facade that also updates `syncState` on every author-side change without + * either side needing to know about the other. + */ +export interface CollabRuntimeScheduler { + notifyChanged( + projectId: string, + reason?: string, + principal?: ResourceHubPrincipal | null, + ): void; + runBoundary( + projectId: string, + principal?: ResourceHubPrincipal | null, + ): void; +} + +export interface CollabRuntime { + presence: CollabPresenceTracker; + scheduler: CollabRuntimeScheduler; + /** Workspace-context provider — the B-integration seam (identity/visibility). */ + workspaceContext: WorkspaceContextProvider; + /** Team-resource state provider — the E-resource-hub seam (share/freeze state). */ + teamResources: TeamResourceStateProvider; + /** Last published version for a project (members poll this to know what to pull). */ + publishedVersion(projectId: string, principal?: ResourceHubPrincipal | null): number | null; + /** + * Current published head from the resource hub, not just this daemon's memory. + * Members never publish the owner's project, so this is the cross-daemon source + * that tells them a pull is needed. + */ + publishedHead(projectId: string, principal?: ResourceHubPrincipal | null): Promise<number | null>; + /** Sync state for a project (`local_only` until a share is requested). */ + projectSyncState(projectId: string, principal?: ResourceHubPrincipal | null): ProjectSyncState; + /** + * visibility-to-sync sync-intent seam: mark a project as awaiting upload and + * publish it durably before reporting success. + */ + requestTeamShare( + projectId: string, + share?: string | ResourceHubPrincipal, + ): Promise<{ version: number | null; versionId?: string }>; + /** Move a project out of the team space. */ + requestTeamUnshare(projectId: string, principal?: ResourceHubPrincipal | null): Promise<void>; + /** Restore a persisted team share into runtime bookkeeping without publishing. */ + rememberTeamShare(projectId: string, share: ResourceHubPrincipal, syncState?: ProjectSyncState): void; + /** + * Re-upsert the shared project's catalog entry so metadata-only changes + * (rename today) reach teammates without waiting for the next content + * publish — before this, a rename with no follow-up file edit NEVER + * converged on member clients. No-op for projects that are not shared + * from this daemon. Fire-and-forget; failures land on `onError`. + */ + refreshTeamProjectMetadata(projectId: string): void; + /** The member who shared this project, or null if not shared here. */ + projectOwnerMemberId(projectId: string, principal?: ResourceHubPrincipal | null): string | null; + /** Materialize the published tree into the local member copy. */ + pullLatest(projectId: string, principal?: ResourceHubPrincipal | null): Promise<{ version: number | null }>; + dispose(): void; +} + +export interface CreateCollabRuntimeOptions { + adapter?: ResourcePublishAdapter; + /** Managed-project directory resolver, so the real hub adapter can pack/land. */ + resolveProjectDir?: (projectId: string) => string | Promise<string>; + /** Pull destination for a project that may not have a local database row yet. */ + resolvePullDir?: (projectId: string) => string | Promise<string>; + /** Resource-index metadata for team project discovery/cards. */ + describeProject?: (projectId: string) => Record<string, unknown> | null | Promise<Record<string, unknown> | null>; + /** Workspace-context provider. Defaults to a dev provider until wired to an identity source. */ + workspaceContext?: WorkspaceContextProvider; + /** Team-resource state provider. Defaults to a dev provider until wired to the hub. */ + teamResources?: TeamResourceStateProvider; + /** Vela-owned team-project discovery catalog. Runtime treats it as an injectable sink. */ + teamProjectCatalog?: TeamProjectCatalogSink; + /** Fired after a project is published so the caller can notify online members. */ + onPublished?: (result: { + projectId: string; + version: number; + versionId?: string; + reason: string; + principal: ResourceHubPrincipal | null; + }) => void; + /** Fired when a project's presence set changes (join/leave). */ + onPresenceChange?: (result: { projectId: string; present: PresenceMember[] }) => void; + onError?: (result: { projectId: string; error: unknown; principal: ResourceHubPrincipal | null }) => void; + /** + * Gate for SCHEDULER-driven publishes (file watcher, `/collab/changed`, + * `/collab/publish`, run boundaries): return false and the flush becomes a + * no-op for that project. The second layer of the fresh-install wipe guard + * (recvqzaDvUU6B3) — `should-publish.ts` keeps a placeholder from ever + * being WATCHED, this keeps an already-scheduled notification (or a direct + * HTTP nudge) from publishing one. Deliberately NOT consulted by + * `requestTeamShare`/`publishNow`: an explicit share is the user saying + * "publish my local state", which must keep working for brand-new local + * projects. Defaults to allow. + */ + canPublishProjectContent?: (projectId: string) => boolean; +} + +function selectResourcePublishAdapter( + resolveProjectDir: ((projectId: string) => string | Promise<string>) | undefined, + resolvePullDir: ((projectId: string) => string | Promise<string>) | undefined, + describeProject: ((projectId: string) => Record<string, unknown> | null | Promise<Record<string, unknown> | null>) | undefined, +): ResourcePublishAdapter | null { + if (!resolveProjectDir) return null; + if (shouldUseVelaCliResourceTransport()) { + return createVelaCliResourceAdapter({ + resolveProjectDir, + ...(resolvePullDir ? { resolvePullDir } : {}), + ...(describeProject ? { describeProject } : {}), + // Every data-plane caller must provide a principal that was captured from + // an explicit, authoritative Workspace scope. There is no ambient + // Workspace fallback at the transport boundary. + hasTeamIdentity: (principal) => principal != null, + }); + } + return null; +} + +export function createCollabRuntime(options: CreateCollabRuntimeOptions = {}): CollabRuntime { + const workspaceContext = options.workspaceContext ?? createWorkspaceContextProviderFromEnv(); + const sharePrincipals = new Map<string, Map<string, ResourceHubPrincipal>>(); + const knownScopedPrincipals = new Map<string, ResourceHubPrincipal>(); + const scopedOwners = new Map<string, string>(); + const published = new Map<string, number>(); + const syncStates = new Map<string, ProjectSyncState>(); + const owners = new Map<string, string>(); + const unshared = new Set<string>(); + const barePublishResults = new Map< + string, + Map<string, PublishedResourceVersion> + >(); + const SCOPED_PROJECT_SEPARATOR = '\u0000'; + + const scopedProjectKey = (projectId: string, principal: ResourceHubPrincipal) => + `${principal.teamId}${SCOPED_PROJECT_SEPARATOR}${projectId}`; + + const principalsForProject = (projectId: string) => [ + ...(sharePrincipals.get(projectId)?.values() ?? []), + ]; + + function parseScopedProjectKey(key: string) { + const separatorIndex = key.indexOf(SCOPED_PROJECT_SEPARATOR); + if (separatorIndex < 0) return { projectId: key, principal: null }; + const projectId = key.slice(separatorIndex + SCOPED_PROJECT_SEPARATOR.length); + return { projectId, principal: knownScopedPrincipals.get(key) ?? null }; + } + + const getProjectPrincipal = async (projectId?: string) => { + if (projectId) { + const principal = principalsForProject(projectId)[0]; + if (principal) return principal; + } + return null; + }; + + const baseAdapter = + options.adapter ?? + selectResourcePublishAdapter( + options.resolveProjectDir, + options.resolvePullDir, + options.describeProject, + ) ?? + createStubResourcePublishAdapter(); + + function rememberTeamShare( + projectId: string, + share: ResourceHubPrincipal, + syncState?: ProjectSyncState, + ) { + knownScopedPrincipals.set(scopedProjectKey(projectId, share), share); + owners.set(projectId, share.memberId); + scopedOwners.set(scopedProjectKey(projectId, share), share.memberId); + let principals = sharePrincipals.get(projectId); + if (!principals) { + principals = new Map(); + sharePrincipals.set(projectId, principals); + } + principals.set(share.teamId, share); + if (syncState) { + syncStates.set(projectId, syncState); + syncStates.set(scopedProjectKey(projectId, share), syncState); + } + } + + function refreshProjectAggregate(projectId: string) { + const principals = principalsForProject(projectId); + if (principals.length === 0) { + owners.delete(projectId); + published.delete(projectId); + syncStates.set(projectId, 'local_only'); + return; + } + + owners.set(projectId, principals[0]!.memberId); + const remainingVersions = principals + .map((candidate) => published.get(scopedProjectKey(projectId, candidate))) + .filter((version): version is number => version != null); + const aggregateVersion = remainingVersions.at(-1); + if (aggregateVersion == null) published.delete(projectId); + else published.set(projectId, aggregateVersion); + + const remainingStates = principals.map( + (candidate) => syncStates.get(scopedProjectKey(projectId, candidate)) ?? 'local_only', + ); + const aggregateState = remainingStates.includes('pending_upload') + ? 'pending_upload' + : remainingStates.includes('sync_failed') + ? 'sync_failed' + : remainingStates.includes('synced') + ? 'synced' + : 'local_only'; + syncStates.set(projectId, aggregateState); + } + + /** + * A local edit landed on a project that is already shared to the team: its + * published head is about to go stale until the scheduler's debounced + * publish confirms. Without this, `syncState` only ever left `'synced'` on + * the FIRST share (`requestTeamShare`) and stayed `'synced'` through every + * later edit-then-republish cycle, so `/collab/status` had no way to tell + * the owner's own client "your last edit hasn't reached teammates yet" — + * the "uploading" tab badge has nothing to key off without this. Only + * touches projects that already have a share principal; an unshared + * project's `syncState` stays `'local_only'` regardless of local edits. + */ + function markLocalChangePending( + projectId: string, + principal?: ResourceHubPrincipal | null, + ) { + const principals = principal ? [principal] : principalsForProject(projectId); + if (principals.length === 0) return; + for (const principal of principals) { + const key = scopedProjectKey(projectId, principal); + const state = syncStates.get(key); + if (state === 'synced' || state === 'sync_failed') { + syncStates.set(key, 'pending_upload'); + } + } + refreshProjectAggregate(projectId); + } + + async function markTeamProject( + projectId: string, + syncState: TeamProjectCatalogSyncState, + principal?: ResourceHubPrincipal | null, + lastSyncedVersionId?: string, + ) { + const descriptor = await options.describeProject?.(projectId) ?? null; + const displayName = typeof descriptor?.name === 'string' + ? descriptor.name.trim() + : ''; + const principals = principal ? [principal] : principalsForProject(projectId); + const fallbackPrincipal = await getProjectPrincipal(projectId); + const targets = principals.length > 0 + ? principals + : fallbackPrincipal + ? [fallbackPrincipal] + : []; + for (const target of targets) { + await options.teamProjectCatalog?.upsert( + { + projectId, + resourceId: projectResourceIdFor(projectId, target), + ...(displayName ? { displayName } : {}), + syncState, + ...(lastSyncedVersionId ? { lastSyncedVersionId } : {}), + ...(descriptor ? { metadata: descriptor } : {}), + }, + target, + ); + } + } + + function markTeamProjectSoon( + projectId: string, + syncState: TeamProjectCatalogSyncState, + principal?: ResourceHubPrincipal | null, + lastSyncedVersionId?: string, + ) { + void markTeamProject( + projectId, + syncState, + principal, + lastSyncedVersionId, + ).catch((error) => { + const principals = principal ? [principal] : principalsForProject(projectId); + if (principals.length === 0) { + options.onError?.({ projectId, error, principal: null }); + return; + } + for (const scopedPrincipal of principals) { + options.onError?.({ projectId, error, principal: scopedPrincipal }); + } + }); + } + + const schedulerAdapter: ResourcePublishAdapter = { + async publish({ projectId: key, reason }) { + const { projectId, principal } = parseScopedProjectKey(key); + // Fresh-install wipe guard, layer 2 (recvqzaDvUU6B3): every scheduler + // flush re-asks whether this project's local copy is publishable at + // all. An unmaterialized placeholder answers no, so even a publish + // notification that raced ahead of the placeholder stamp (or a direct + // `/collab/publish` nudge) cannot push its empty directory to the hub. + if (options.canPublishProjectContent && !options.canPublishProjectContent(projectId)) { + return null; + } + if (!principal) { + const principals = principalsForProject(projectId); + if (principals.length > 0) { + const versions = new Map<string, PublishedResourceVersion>(); + for (const scopedPrincipal of principals) { + const result = await baseAdapter.publish({ + projectId, + reason, + principal: scopedPrincipal, + }); + if (result) versions.set(scopedPrincipal.teamId, result); + } + barePublishResults.set(projectId, versions); + if (versions.size === 0) return null; + return [...versions.values()].reduce((highest, candidate) => + candidate.version > highest.version ? candidate : highest, + ); + } + // No scoped principal on the notification AND no remaining share + // principals for this project: every share has been removed, which + // is exactly the condition `requestTeamUnshare` uses to mark the + // project `unshared`. A file-watcher subscription is only torn down + // when a project is deleted locally (see collab-publish-watcher.ts + // `reconcile`), never on unshare, so a debounced `notifyChanged` can + // still land here well after the unshare completed. Publishing + // anyway would durably re-create the resource on the hub under an + // unscoped id for the round-trip it takes `onPublished`'s `unshared` + // guard to notice and unpublish it again — a real window in which a + // status read reports the just-unshared project as shared again. + // Refuse outright instead of publish-then-cleanup. + if (unshared.has(projectId)) return null; + } + return baseAdapter.publish({ + projectId, + reason, + ...(principal ? { principal } : {}), + }); + }, + }; + if (baseAdapter.syncLatest) { + schedulerAdapter.syncLatest = ({ projectId: key }) => { + const { projectId, principal } = parseScopedProjectKey(key); + return baseAdapter.syncLatest!({ + projectId, + ...(principal ? { principal } : {}), + }); + }; + } + if (baseAdapter.pull) { + schedulerAdapter.pull = ({ projectId: key }) => { + const { projectId, principal } = parseScopedProjectKey(key); + return baseAdapter.pull!({ + projectId, + ...(principal ? { principal } : {}), + }); + }; + } + if (baseAdapter.unpublish) { + schedulerAdapter.unpublish = ({ projectId: key }) => { + const { projectId, principal } = parseScopedProjectKey(key); + return baseAdapter.unpublish!({ + projectId, + ...(principal ? { principal } : {}), + }); + }; + } + + async function publishNow( + projectId: string, + reason: string, + principal?: ResourceHubPrincipal | null, + ): Promise<{ version: number | null; versionId?: string }> { + const key = principal ? scopedProjectKey(projectId, principal) : projectId; + let publishedResult: PublishedResourceVersion | null = null; + try { + const result = await baseAdapter.publish({ + projectId, + reason, + ...(principal ? { principal } : {}), + }); + if (!result) return { version: null }; + publishedResult = result; + if (unshared.has(key) || unshared.has(projectId)) { + await baseAdapter.unpublish?.({ + projectId, + ...(principal ? { principal } : {}), + }); + published.delete(key); + syncStates.set(key, 'local_only'); + if (principal) refreshProjectAggregate(projectId); + else { + published.delete(projectId); + syncStates.set(projectId, 'local_only'); + } + return { version: null }; + } + published.set(projectId, result.version); + syncStates.set(projectId, 'synced'); + if (principal) { + published.set(key, result.version); + syncStates.set(key, 'synced'); + } + await markTeamProject( + projectId, + 'synced', + principal, + result.versionId, + ); + options.onPublished?.({ + projectId, + version: result.version, + ...(result.versionId ? { versionId: result.versionId } : {}), + reason, + principal: principal ?? null, + }); + return { + version: result.version, + ...(result.versionId ? { versionId: result.versionId } : {}), + }; + } catch (error) { + if (publishedResult) { + await baseAdapter.unpublish?.({ + projectId, + ...(principal ? { principal } : {}), + }).catch(() => undefined); + await options.teamProjectCatalog?.remove?.( + projectId, + principal, + ).catch(() => undefined); + } + syncStates.set(projectId, 'sync_failed'); + if (principal) syncStates.set(key, 'sync_failed'); + options.onError?.({ projectId, error, principal: principal ?? null }); + throw error; + } + } + + const schedulerOptions: CollabPublishSchedulerOptions = { + adapter: schedulerAdapter, + onPublished: (result) => { + const { projectId, principal } = parseScopedProjectKey(result.projectId); + const key = principal ? scopedProjectKey(projectId, principal) : projectId; + if (unshared.has(result.projectId) || unshared.has(key) || unshared.has(projectId)) { + void schedulerAdapter.unpublish?.({ projectId: result.projectId }).catch((error: unknown) => { + options.onError?.({ projectId, error, principal }); + }); + published.delete(key); + syncStates.set(key, 'local_only'); + if (principal) refreshProjectAggregate(projectId); + else { + published.delete(projectId); + syncStates.set(projectId, 'local_only'); + } + return; + } + published.set(projectId, result.version); + syncStates.set(projectId, 'synced'); + if (principal) { + published.set(key, result.version); + syncStates.set(key, 'synced'); + markTeamProjectSoon( + projectId, + 'synced', + principal, + result.versionId, + ); + options.onPublished?.({ ...result, projectId, principal }); + return; + } + const versions = barePublishResults.get(projectId); + if (versions) { + for (const scopedPrincipal of principalsForProject(projectId)) { + const publishedResult = versions.get(scopedPrincipal.teamId); + if (!publishedResult) continue; + published.set( + scopedProjectKey(projectId, scopedPrincipal), + publishedResult.version, + ); + syncStates.set(scopedProjectKey(projectId, scopedPrincipal), 'synced'); + markTeamProjectSoon( + projectId, + 'synced', + scopedPrincipal, + publishedResult.versionId, + ); + options.onPublished?.({ + projectId, + version: publishedResult.version, + ...(publishedResult.versionId + ? { versionId: publishedResult.versionId } + : {}), + reason: result.reason, + principal: scopedPrincipal, + }); + } + barePublishResults.delete(projectId); + return; + } + markTeamProjectSoon(projectId, 'synced', null); + options.onPublished?.({ ...result, projectId, principal: null }); + }, + onError: (result) => { + const { projectId, principal } = parseScopedProjectKey(result.projectId); + const key = principal ? scopedProjectKey(projectId, principal) : projectId; + if (unshared.has(result.projectId) || unshared.has(key) || unshared.has(projectId)) { + syncStates.set(key, 'local_only'); + if (principal) refreshProjectAggregate(projectId); + else syncStates.set(projectId, 'local_only'); + return; + } + syncStates.set(projectId, 'sync_failed'); + const principals = principal ? [principal] : principalsForProject(projectId); + for (const scopedPrincipal of principals) { + syncStates.set(scopedProjectKey(projectId, scopedPrincipal), 'sync_failed'); + } + markTeamProjectSoon(projectId, 'failed', principal); + if (principals.length > 0) { + for (const scopedPrincipal of principals) { + options.onError?.({ ...result, projectId, principal: scopedPrincipal }); + } + } else { + options.onError?.({ ...result, projectId, principal: null }); + } + }, + }; + + const scheduler = new CollabPublishScheduler(schedulerOptions); + // Every external caller of `.scheduler` only ever needs to REPORT a change; + // route that through `markLocalChangePending` first so `syncState` reflects + // "uploading" for the window between the edit and the debounced publish + // confirming it (see the function's doc comment). The real scheduler still + // owns debouncing/coalescing/flush — this only adds the state update. + const schedulerFacade: CollabRuntimeScheduler = { + notifyChanged(projectId, reason, principal) { + markLocalChangePending(projectId, principal); + scheduler.notifyChanged( + principal ? scopedProjectKey(projectId, principal) : projectId, + reason, + ); + }, + runBoundary(projectId, principal) { + markLocalChangePending(projectId, principal); + scheduler.runBoundary( + principal ? scopedProjectKey(projectId, principal) : projectId, + ); + }, + }; + const presenceOptions: CollabPresenceTrackerOptions = {}; + if (options.onPresenceChange) presenceOptions.onChange = options.onPresenceChange; + const presence = new CollabPresenceTracker(presenceOptions); + const teamResources = options.teamResources ?? createDevTeamResourceStateProvider(); + + return { + presence, + scheduler: schedulerFacade, + workspaceContext, + teamResources, + publishedVersion: (projectId, principal) => { + if (principal) return published.get(scopedProjectKey(projectId, principal)) ?? null; + return published.get(projectId) ?? null; + }, + async publishedHead(projectId, principal) { + const head = baseAdapter.syncLatest + ? await baseAdapter.syncLatest({ projectId, ...(principal ? { principal } : {}) }) + : null; + if (head?.version != null) return head.version; + if (principal) return published.get(scopedProjectKey(projectId, principal)) ?? null; + return published.get(projectId) ?? null; + }, + projectSyncState: (projectId, principal) => { + if (principal) { + return syncStates.get(scopedProjectKey(projectId, principal)) ?? 'local_only'; + } + const states = principalsForProject(projectId) + .map((candidate) => syncStates.get(scopedProjectKey(projectId, candidate))) + .filter((state): state is ProjectSyncState => Boolean(state)); + if (states.includes('pending_upload')) return 'pending_upload'; + if (states.includes('sync_failed')) return 'sync_failed'; + if (states.includes('synced')) return 'synced'; + return syncStates.get(projectId) ?? 'local_only'; + }, + async requestTeamShare(projectId, share) { + const principal = typeof share === 'object' && share + ? share + : await getProjectPrincipal(projectId); + if (typeof share === 'string') owners.set(projectId, share); + if (principal) rememberTeamShare(projectId, principal, 'pending_upload'); + else syncStates.set(projectId, 'pending_upload'); + const key = principal ? scopedProjectKey(projectId, principal) : projectId; + unshared.delete(projectId); + unshared.delete(key); + return publishNow(projectId, 'share', principal); + }, + async requestTeamUnshare(projectId, principal) { + const targets = principal + ? [principal] + : principalsForProject(projectId).length > 0 + ? principalsForProject(projectId) + : [await getProjectPrincipal(projectId)].filter((candidate): candidate is ResourceHubPrincipal => Boolean(candidate)); + if (targets.length === 0) { + unshared.add(projectId); + await baseAdapter.unpublish?.({ projectId }); + } + for (const target of targets) { + const key = scopedProjectKey(projectId, target); + unshared.add(key); + await baseAdapter.unpublish?.({ projectId, principal: target }); + await options.teamProjectCatalog?.remove?.(projectId, target); + published.delete(key); + syncStates.set(key, 'local_only'); + scopedOwners.delete(key); + sharePrincipals.get(projectId)?.delete(target.teamId); + } + if (principal) { + if (sharePrincipals.get(projectId)?.size === 0) { + sharePrincipals.delete(projectId); + unshared.add(projectId); + } + refreshProjectAggregate(projectId); + return; + } + + unshared.add(projectId); + owners.delete(projectId); + published.delete(projectId); + syncStates.set(projectId, 'local_only'); + sharePrincipals.delete(projectId); + }, + projectOwnerMemberId: (projectId, principal) => { + if (principal) return scopedOwners.get(scopedProjectKey(projectId, principal)) ?? null; + return owners.get(projectId) ?? null; + }, + rememberTeamShare, + refreshTeamProjectMetadata(projectId) { + // Only projects this daemon actually shares have catalog rows to + // refresh; principalsForProject is the authority on that. Reuse the + // per-principal sync state so a pending upload stays pending. + for (const principal of principalsForProject(projectId)) { + const state = syncStates.get(scopedProjectKey(projectId, principal)); + if (state !== 'synced' && state !== 'pending_upload') continue; + markTeamProjectSoon(projectId, state, principal); + } + }, + async pullLatest(projectId, principal) { + if (baseAdapter.pull) { + const materialized = await baseAdapter.pull({ + projectId, + ...(principal ? { principal } : {}), + }); + return { version: materialized?.version ?? null }; + } + const head = baseAdapter.syncLatest + ? await baseAdapter.syncLatest({ projectId, ...(principal ? { principal } : {}) }) + : { version: principal ? published.get(scopedProjectKey(projectId, principal)) ?? null : published.get(projectId) ?? null }; + return { version: head?.version ?? null }; + }, + dispose() { + scheduler.dispose(); + presence.dispose(); + }, + }; +} diff --git a/apps/daemon/src/collab/shared-project-placeholder.ts b/apps/daemon/src/collab/shared-project-placeholder.ts new file mode 100644 index 00000000000..50e2c1466e2 --- /dev/null +++ b/apps/daemon/src/collab/shared-project-placeholder.ts @@ -0,0 +1,60 @@ +// Fresh-install wipe guard (飞书 recvqzaDvUU6B3): the "unmaterialized +// shared-project placeholder" invariant. +// +// `ensureSharedProjectPlaceholder` (routes/collab-sync.ts) registers a minimal +// local `projects` row (named "共享项目") the moment someone opens a +// hub-shared project this daemon has no local copy of, so the project's other +// routes stop 404ing while a pull materializes real content. That row is a +// UI convenience — it is NOT content authority. On a daemon whose data root +// was wiped (uninstall + reinstall under the same signed-in owner), the +// placeholder is the ONLY local record of the owner's own shared project, its +// content directory is empty, and the hub still names this member as the +// project's single writer. Without this guard the collab publish watcher +// discovered that placeholder as an ordinary owned+shared local project and +// its initial-publish-on-first-watch pushed the EMPTY directory to the +// resource hub as a new published version — erasing the project's content and +// catalog display name for the whole team, one project after another as the +// owner opened them (reproduced live on the feature-test hub, 2026-07-27: +// both repro projects advanced to a head whose manifestDigest was +// sha256:e3b0c442… — the empty tree — ~13s after `/collab/status` first ran). +// +// Invariant: while a project's local record still carries the +// `sharedProjectPlaceholderAt` metadata stamp, this daemon must treat the +// local copy as NOT publishable — no publish watcher may attach to it and no +// scheduler-driven publish may run for it. The stamp is set when the +// placeholder is registered and cleared exactly once a pull has materialized +// real hub content locally (`markSharedProjectPlaceholder(projectId, false)` +// in the pull flow). Explicit share requests (`requestTeamShare`) are not +// gated: a share is a deliberate "publish my local state" action on a project +// the user created locally, which never carries the stamp. +// +// The stamp lives in the project row's metadata (same pattern as +// `teamMirrorRevokedAt`) so it survives daemon restarts — a placeholder left +// behind by a crashed/restarted daemon must stay unpublishable until it is +// actually materialized. + +export const SHARED_PROJECT_PLACEHOLDER_METADATA_KEY = 'sharedProjectPlaceholderAt'; + +/** The `sharedProjectPlaceholderAt` stamp from a project's metadata, or null + * when the metadata does not mark an unmaterialized placeholder. */ +export function sharedProjectPlaceholderStamp(metadata: unknown): number | null { + if (!metadata || typeof metadata !== 'object') return null; + const value = (metadata as Record<string, unknown>)[ + SHARED_PROJECT_PLACEHOLDER_METADATA_KEY + ]; + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +/** + * Whether this local project record is still an unmaterialized shared-project + * placeholder — i.e. local state that must never be published to the resource + * hub (see module doc comment). Null/undefined (no local record at all) is + * not a placeholder: it has nothing to publish either way, and the publish + * paths already require a local record. + */ +export function isUnmaterializedSharedPlaceholder( + project: { metadata?: unknown } | null | undefined, +): boolean { + if (!project) return false; + return sharedProjectPlaceholderStamp(project.metadata) != null; +} diff --git a/apps/daemon/src/collab/should-publish.ts b/apps/daemon/src/collab/should-publish.ts new file mode 100644 index 00000000000..e90c44f2f24 --- /dev/null +++ b/apps/daemon/src/collab/should-publish.ts @@ -0,0 +1,64 @@ +// The `shouldPublish` predicate `collab-publish-watcher.ts` consults before +// attaching a file watcher to a project (see that file's read-only-gate +// invariant: watched only when team-shared AND this daemon's member is its +// owner). Extracted to its own module so the exact project-scope invariant is +// independently unit-testable without spinning up the whole server. + +import type { ResourceHubPrincipal } from './resource-principal.js'; + +export interface CreateShouldPublishOptions { + /** Server-authoritative owner lookup (the team hub catalog, never a client-supplied id). */ + resolveSharedProjectOwner: (projectId: string) => Promise<string | null>; + /** + * Resolve the immutable, authoritative Team scope bound to this exact local + * project. A missing result means the project has no currently active, + * writable Team identity. + */ + resolveProjectPrincipal: (projectId: string) => Promise<ResourceHubPrincipal | null>; + /** Remember the resolved principal so the scheduler/adapter scope the publish under it. */ + rememberTeamShare: (projectId: string, principal: ResourceHubPrincipal) => void; + /** + * Whether the local record for `projectId` is still an unmaterialized + * shared-project placeholder (`isUnmaterializedSharedPlaceholder` over the + * local project row — see collab/shared-project-placeholder.ts). Required, + * not optional: forgetting to wire it is exactly the fresh-install wipe of + * recvqzaDvUU6B3, where an empty placeholder on a wiped data root passed + * the owner check and was published over the team's real content. + */ + hasUnmaterializedPlaceholder: (projectId: string) => boolean; +} + +/** + * Whether THIS daemon should attach a file watcher to `projectId` and publish + * its local edits: the project is team-shared, the exact principal bound to + * this project is its OWNER (the single writer), AND that Workspace lifecycle + * is currently ACTIVE. + * + * This only gates whether a NEW watch is attached — `collab-publish- + * watcher.ts`'s `reconcile` never re-checks `shouldPublish` for a project it + * is already watching (see that file's doc comment). The already-attached + * case is instead closed at the transport layer with the same captured + * project principal. No daemon-global active Workspace participates in this + * decision, so switching Workspace cannot retarget an existing watcher. + */ +export function createShouldPublish( + options: CreateShouldPublishOptions, +): (projectId: string) => Promise<false | ResourceHubPrincipal> { + return async ( + projectId: string, + ): Promise<false | ResourceHubPrincipal> => { + // Fresh-install wipe guard (recvqzaDvUU6B3): a local record that is still + // an unmaterialized shared-project placeholder is not content authority — + // it must never be watched or publish, no matter what the owner check + // says. Checked before the hub round-trips so a placeholder never even + // reaches the catalog. + if (options.hasUnmaterializedPlaceholder(projectId)) return false; + const owner = await options.resolveSharedProjectOwner(projectId); + if (!owner) return false; + const principal = await options.resolveProjectPrincipal(projectId); + if (principal?.lifecycleState !== 'active') return false; + if (owner !== principal.memberId) return false; + options.rememberTeamShare(projectId, principal); + return principal; + }; +} diff --git a/apps/daemon/src/collab/stub-resource-adapter.ts b/apps/daemon/src/collab/stub-resource-adapter.ts new file mode 100644 index 00000000000..d5691033e68 --- /dev/null +++ b/apps/daemon/src/collab/stub-resource-adapter.ts @@ -0,0 +1,29 @@ +import type { ResourcePublishAdapter } from './publish-scheduler.js'; + +/** + * Placeholder resource-hub adapter until E's `services/resource-hub` (the resource-hub owner, + * ) ships its client. It assigns a monotonic in-memory version per + * project so the author-side publish flow (coalesce → publish → notify) is + * exercisable end-to-end locally, but it does NOT durably store content. Swap + * for the real E client (the resource hub the spec = createVersion + setRef('published')) when + * it lands; the {@link ResourcePublishAdapter} interface is the seam. + */ +export function createStubResourcePublishAdapter(): ResourcePublishAdapter { + const versions = new Map<string, number>(); + return { + async publish({ projectId }) { + const next = (versions.get(projectId) ?? 0) + 1; + versions.set(projectId, next); + return { version: next }; + }, + // Read-only: report the current head without advancing it (getRef stand-in). + // The real E client also fetches + extracts the missing blobs locally. + async syncLatest({ projectId }) { + const current = versions.get(projectId); + return current === undefined ? null : { version: current }; + }, + async unpublish({ projectId }) { + versions.delete(projectId); + }, + }; +} diff --git a/apps/daemon/src/collab/swr-cache.ts b/apps/daemon/src/collab/swr-cache.ts new file mode 100644 index 00000000000..cdac0423b50 --- /dev/null +++ b/apps/daemon/src/collab/swr-cache.ts @@ -0,0 +1,79 @@ +// Generic stale-while-revalidate cache. After the first load every call +// returns the last value for the key immediately and only kicks a background +// refresh once it is older than `freshMs`; concurrent callers coalesce onto +// the in-flight fetch. Keyed so a workspace switch (or any other key change) +// is an automatic miss. +// +// Extracted out of server.ts (where every SWR-cached read in the daemon used +// to redeclare this inline) so `invalidate()` is a property of the primitive +// itself instead of something each call site has to bolt on by hand — see +// `cachedTeamResourceList` in server.ts, which forgot to and left share/ +// unshare with no way to drop the stale entry it just created (the team +// design-system/plugin/skill lists took up to `freshMs` — or the client's +// slower background poll once SSE lowers its cadence — to catch up). +// `teamProjectsDisplayCache` in server.ts is the sibling precedent: it wraps +// its OWN hand-rolled SWR loop in an equivalent `Object.assign(read, { +// invalidate })`. + +export interface SwrCache<T> { + (): Promise<T>; + /** + * Drop the cached value for the key last used, so the next read is a real + * fetch instead of the stale one. Only needed for the moments a caller KNOWS + * the underlying data changed (a local mutation, a hub push) — correctness + * does not depend on it, since the fetcher will naturally refresh once + * `freshMs` elapses, but without it a just-made change can take up to that + * long (or longer, behind a slower client poll) to become visible. + * + * Safe to call while a background refresh is already in flight: that + * refresh's result is keyed against the pre-invalidate entry identity, so it + * is discarded on landing instead of clobbering whatever the post-invalidate + * read produces. + */ + invalidate(): void; +} + +export function createSwrCache<T>( + fetcher: () => Promise<T>, + keyFn: () => string, + freshMs: number, +): SwrCache<T> { + let entry: { key: string; value: T | null; settledAt: number; inflight: Promise<T> | null } | null = null; + const refresh = (key: string) => { + if (!entry || entry.key !== key) entry = { key, value: null, settledAt: 0, inflight: null }; + const cur = entry; + const p = fetcher(); + cur.inflight = p; + p.then( + (value) => { + if (entry === cur) { + cur.value = value; + cur.settledAt = Date.now(); + cur.inflight = null; + } + }, + () => { + if (entry === cur) { + cur.inflight = null; + if (cur.value === null) entry = null; + } + }, + ); + return p; + }; + const read = (): Promise<T> => { + const key = keyFn(); + if (!entry || entry.key !== key) return refresh(key); + if (entry.value !== null) { + const cached = entry.value; + if (!entry.inflight && Date.now() - entry.settledAt >= freshMs) void refresh(key).catch(() => {}); + return Promise.resolve(cached); + } + return entry.inflight ?? refresh(key); + }; + return Object.assign(read, { + invalidate() { + entry = null; + }, + }); +} diff --git a/apps/daemon/src/collab/sync-digest.ts b/apps/daemon/src/collab/sync-digest.ts new file mode 100644 index 00000000000..206c5360605 --- /dev/null +++ b/apps/daemon/src/collab/sync-digest.ts @@ -0,0 +1,180 @@ +// Cross-device sync digest: B's cheap "did anything change?" probe. +// +// `GET /api/v1/collab/sync-digest` answers with four OPAQUE tokens. They are +// comparison keys, NOT timestamps and NOT versions: +// +// catalogToken / membersToken `max(updated_at)::text || ':' || count(*)` +// contextToken the workspace row's `updated_at` +// billingToken the subscription row's `updated_at`, or the +// empty string when there is no subscription row +// +// The only legal operation on a token is `===` against a token we stored +// earlier. Never parse one, never read it as a date, never ask which of two is +// newer — the `count(*)` term exists precisely so a hard delete moves the token +// without moving any timestamp, and that makes ordering meaningless. Empty +// tables produce values like `'0:0'` / `'0'`, so a token is never SQL null. +// +// Only the `vela` workspace-context source has a hub to ask, so a dev daemon +// pointed at anything else resolves to null rather than dialing production — +// the same gate `startHubEventsSubscriber`'s endpoint resolver uses. + +import { readVelaControlApiContext } from '../integrations/vela.js'; + +/** The two faces whose payloads are big enough (and change rarely enough) to be + * worth reusing from a local snapshot. Workspace context and billing are + * deliberately absent: they are cheap and must always read live. */ +export type SyncDigestFace = 'catalog' | 'members'; + +export interface SyncDigest { + catalogToken: string; + membersToken: string; + contextToken: string; + billingToken: string; +} + +/** + * One digest read, carried together with the identity it was read under. + * + * Account + workspace travel WITH the tokens on purpose: a snapshot keyed by a + * different account than the token it is compared against would let one signed- + * in user read another's cached data. Keeping them in one object makes that + * mismatch unrepresentable. + */ +export interface SyncDigestReading { + /** Vela user id — the account half of the snapshot key. Never empty. */ + accountId: string; + /** Active workspace id — the workspace half of the snapshot key. Never empty. */ + workspaceId: string; + digest: SyncDigest; +} + +export type SyncDigestReader = () => Promise<SyncDigestReading | null>; + +export interface SyncDigestReaderOptions { + env?: NodeJS.ProcessEnv; + getWorkspaceId: () => string | null | undefined; + fetchImpl?: typeof fetch; + /** Injectable session read for tests; defaults to the vela control-key session. */ + readSession?: typeof readVelaControlApiContext; + /** Abort a hung digest so it can never outlast the real fetch it is saving. */ + timeoutMs?: number; + /** How long to stop asking after the endpoint failed to answer. */ + failureCooldownMs?: number; + now?: () => number; + onError?: (error: unknown) => void; +} + +// The digest exists to REPLACE a slow read, so it must never become a slow read +// itself. Two seconds is already longer than the query it stands in for. +const DEFAULT_TIMEOUT_MS = 2_000; +// After a failure — transport error, 5xx, or a 404 because this B deployment +// predates the endpoint — asking again on the very next read would add a round +// trip to every catalog and member load for nothing. Park briefly instead; the +// caller degrades to a real fetch either way. +const DEFAULT_FAILURE_COOLDOWN_MS = 60_000; + +/** Pick the token that governs one face. */ +export function tokenForFace(digest: SyncDigest, face: SyncDigestFace): string { + return face === 'catalog' ? digest.catalogToken : digest.membersToken; +} + +/** + * Validate a digest response body. + * + * All four fields must be present and be strings. `billingToken` is allowed to + * be empty (no subscription row); the others are not validated for content + * because their content is opaque by contract. + */ +export function parseSyncDigest(value: unknown): SyncDigest | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const record = value as Record<string, unknown>; + const { catalogToken, membersToken, contextToken, billingToken } = record; + if ( + typeof catalogToken !== 'string' || + typeof membersToken !== 'string' || + typeof contextToken !== 'string' || + typeof billingToken !== 'string' + ) { + return null; + } + return { catalogToken, membersToken, contextToken, billingToken }; +} + +/** + * A digest reader with in-flight coalescing. + * + * Catalog and members are refreshed by the same page load milliseconds apart, + * so without coalescing one navigation costs two identical digest round-trips. + * Deliberately in-flight only — no TTL — so a settled reading is never reused: + * the whole point of the token is that it is compared against a freshly read + * one, and a cached token could green-light a snapshot the cloud has already + * moved past. + */ +export function createSyncDigestReader(options: SyncDigestReaderOptions): SyncDigestReader { + const env = options.env ?? process.env; + const fetchImpl = options.fetchImpl ?? fetch; + const readSession = options.readSession ?? readVelaControlApiContext; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const failureCooldownMs = options.failureCooldownMs ?? DEFAULT_FAILURE_COOLDOWN_MS; + const now = options.now ?? Date.now; + let inflight: Promise<SyncDigestReading | null> | null = null; + let cooldownUntil = 0; + + async function read(): Promise<SyncDigestReading | null> { + // Same gate as the hub events subscriber: no vela source, no hub. + if (env.OD_WORKSPACE_CONTEXT_SOURCE?.trim() !== 'vela') return null; + if (now() < cooldownUntil) return null; + const session = readSession(env); + if (!session?.controlKey || !session.apiUrl) return null; + const accountId = session.user?.id?.trim() ?? ''; + const workspaceId = options.getWorkspaceId()?.trim() ?? ''; + // No account or no workspace means no safe cache key. Reporting null keeps + // the caller on a real fetch instead of letting it invent a shared key. + if (!accountId || !workspaceId) return null; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + timer.unref?.(); + try { + const response = await fetchImpl( + new URL('/api/v1/collab/sync-digest', session.apiUrl).toString(), + { + headers: { + authorization: `Bearer ${session.controlKey}`, + 'x-vela-workspace-id': workspaceId, + accept: 'application/json', + }, + signal: controller.signal, + }, + ); + if (!response.ok) { + cooldownUntil = now() + failureCooldownMs; + return null; + } + const digest = parseSyncDigest(await response.json()); + if (!digest) { + cooldownUntil = now() + failureCooldownMs; + return null; + } + cooldownUntil = 0; + return { accountId, workspaceId, digest }; + } finally { + clearTimeout(timer); + } + } + + return () => { + if (inflight) return inflight; + const pending = read() + .catch((error) => { + cooldownUntil = now() + failureCooldownMs; + options.onError?.(error); + return null; + }) + .finally(() => { + if (inflight === pending) inflight = null; + }); + inflight = pending; + return pending; + }; +} diff --git a/apps/daemon/src/collab/sync-snapshot-store.ts b/apps/daemon/src/collab/sync-snapshot-store.ts new file mode 100644 index 00000000000..eba54892e1c --- /dev/null +++ b/apps/daemon/src/collab/sync-snapshot-store.ts @@ -0,0 +1,150 @@ +// Local persistence for the sync-digest snapshot cache. +// +// One row holds a face's last-fetched payload TOGETHER with the digest token +// that payload was current at. Storing them in the same row of the same +// database is the whole trick: a half state ("snapshot but no token", or the +// reverse) would make the reuse test unanswerable, so the schema makes it +// impossible to write one without the other. +// +// The row lives in the daemon's SQLite database, which the daemon opened from +// the resolved runtime data root — this module never resolves a data path of +// its own (see the Daemon data directory contract in AGENTS.md). + +import type Database from 'better-sqlite3'; +import type { CollabCloudMemberDirectoryEntry, TeamProject } from '@open-design/contracts'; +import type { SyncDigestFace } from './sync-digest.js'; + +type SqliteDb = Database.Database; + +export function migrateCollabSyncSnapshots(db: SqliteDb): void { + db.exec(` + CREATE TABLE IF NOT EXISTS collab_sync_snapshots ( + face TEXT NOT NULL, + account_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + digest_token TEXT NOT NULL, + snapshot_json TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (face, account_id, workspace_id) + ); + `); +} + +/** + * Cache identity. `accountId` is part of the primary key so a snapshot can + * only ever be read back under the account that wrote it — switching accounts + * is a miss, not a leak. Callers must never synthesize a placeholder for a + * missing account id. + */ +export interface CollabSyncSnapshotKey { + face: SyncDigestFace; + accountId: string; + workspaceId: string; +} + +export interface CollabSyncSnapshotRecord { + token: string; + snapshotJson: string; +} + +export interface CollabSyncSnapshotStore { + read(key: CollabSyncSnapshotKey): CollabSyncSnapshotRecord | null; + /** Write token + payload as one atomic unit. */ + write(key: CollabSyncSnapshotKey, record: CollabSyncSnapshotRecord): void; + drop(key: CollabSyncSnapshotKey): void; +} + +export function createCollabSyncSnapshotStore(db: SqliteDb): CollabSyncSnapshotStore { + const selectRow = db.prepare( + `SELECT digest_token AS token, snapshot_json AS snapshotJson + FROM collab_sync_snapshots + WHERE face = ? AND account_id = ? AND workspace_id = ?`, + ); + const upsertRow = db.prepare( + `INSERT INTO collab_sync_snapshots + (face, account_id, workspace_id, digest_token, snapshot_json, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(face, account_id, workspace_id) DO UPDATE SET + digest_token = excluded.digest_token, + snapshot_json = excluded.snapshot_json, + updated_at = excluded.updated_at`, + ); + const deleteRow = db.prepare( + `DELETE FROM collab_sync_snapshots + WHERE face = ? AND account_id = ? AND workspace_id = ?`, + ); + // One statement already writes both columns atomically; the explicit + // transaction states the invariant at the call site so a future edit that + // splits the write into two statements still cannot publish a half state. + const writeAtomically = db.transaction( + (key: CollabSyncSnapshotKey, record: CollabSyncSnapshotRecord) => { + upsertRow.run( + key.face, + key.accountId, + key.workspaceId, + record.token, + record.snapshotJson, + Date.now(), + ); + }, + ); + + return { + read(key) { + const row = selectRow.get(key.face, key.accountId, key.workspaceId) as + | { token?: unknown; snapshotJson?: unknown } + | undefined; + if (!row || typeof row.token !== 'string' || typeof row.snapshotJson !== 'string') { + return null; + } + return { token: row.token, snapshotJson: row.snapshotJson }; + }, + write(key, record) { + writeAtomically(key, record); + }, + drop(key) { + deleteRow.run(key.face, key.accountId, key.workspaceId); + }, + }; +} + +/** + * Shape guards for the two cached faces. + * + * A snapshot that survives a schema change (or lands corrupted) must degrade to + * a real fetch rather than being handed to the UI, so parsing is a validation + * step and not a cast. + */ +export function parseTeamProjectSnapshot(value: unknown): TeamProject[] | null { + if (!Array.isArray(value)) return null; + for (const entry of value) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; + const project = entry as Record<string, unknown>; + if ( + typeof project.projectId !== 'string' || + typeof project.ownerMemberId !== 'string' || + typeof project.sharedAt !== 'string' + ) { + return null; + } + } + return value as TeamProject[]; +} + +export function parseMemberDirectorySnapshot( + value: unknown, +): CollabCloudMemberDirectoryEntry[] | null { + if (!Array.isArray(value)) return null; + for (const entry of value) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; + const member = entry as Record<string, unknown>; + if ( + typeof member.memberId !== 'string' || + typeof member.displayName !== 'string' || + typeof member.role !== 'string' + ) { + return null; + } + } + return value as CollabCloudMemberDirectoryEntry[]; +} diff --git a/apps/daemon/src/collab/team-mirror-materializer.ts b/apps/daemon/src/collab/team-mirror-materializer.ts new file mode 100644 index 00000000000..1e987a7ccbf --- /dev/null +++ b/apps/daemon/src/collab/team-mirror-materializer.ts @@ -0,0 +1,387 @@ +import Database from 'better-sqlite3'; + +import { + ensureProjectCommentAnchorConversation, + ensureWorkspaceProject, + getProject, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + insertProject, + rebindWorkspaceProject, + updateProject, +} from '../db.js'; +import { projectResourceIdFor } from '../integrations/vela-team-projects.js'; +import type { + RegisterPulledProjectInput, + TeamMirrorPullScope, +} from '../routes/collab-sync.js'; +import type { AuthorizedTeamProjectPullReceipt } from './authorized-team-project-pull.js'; +import type { ResourceHubPrincipal } from './resource-principal.js'; + +type SqliteDb = Database.Database; + +export interface MaterializePulledTeamMirrorResult { + localRecordChanged: boolean; +} + +export function teamProjectMaterializationMatches( + stored: AuthorizedTeamProjectPullReceipt | null, + expected: AuthorizedTeamProjectPullReceipt, +): boolean { + return Boolean( + stored && + stored.schemaVersion === expected.schemaVersion && + stored.workspaceId === expected.workspaceId && + stored.resourceTeamId === expected.resourceTeamId && + stored.viewerMemberId === expected.viewerMemberId && + stored.ownerMemberId === expected.ownerMemberId && + stored.projectId === expected.projectId && + stored.resourceId === expected.resourceId && + stored.ref === expected.ref && + stored.version === expected.version && + stored.versionId === expected.versionId && + stored.manifestDigest === expected.manifestDigest && + stored.lifecycleState === expected.lifecycleState && + stored.authorizedAt === expected.authorizedAt && + stored.expiresAt === expected.expiresAt + ); +} + +export function teamProjectMaterializationSupersedes( + stored: AuthorizedTeamProjectPullReceipt | null, + previous: AuthorizedTeamProjectPullReceipt, +): boolean { + const canonicalResourceId = projectResourceIdFor(previous.projectId, { + teamId: previous.resourceTeamId, + memberId: previous.ownerMemberId, + role: 'member', + lifecycleState: 'active', + workspaceType: 'team', + }); + return Boolean( + stored && + stored.schemaVersion === previous.schemaVersion && + stored.workspaceId === previous.workspaceId && + stored.resourceTeamId === previous.resourceTeamId && + stored.viewerMemberId === previous.viewerMemberId && + stored.ownerMemberId === previous.ownerMemberId && + stored.projectId === previous.projectId && + stored.resourceId === canonicalResourceId && + previous.resourceId === canonicalResourceId && + stored.ref === previous.ref && + stored.lifecycleState === previous.lifecycleState && + Number.isSafeInteger(stored.version) && + ( + stored.version > previous.version || + ( + stored.version === previous.version && + stored.versionId === previous.versionId && + stored.manifestDigest === previous.manifestDigest && + !teamProjectMaterializationMatches(stored, previous) + ) + ) + ); +} + +export function getTeamProjectMaterialization( + db: SqliteDb, + workspaceId: string, + projectId: string, +): AuthorizedTeamProjectPullReceipt | null { + const value = db.prepare(` + SELECT + workspace_id AS workspaceId, + resource_team_id AS resourceTeamId, + viewer_member_id AS viewerMemberId, + owner_member_id AS ownerMemberId, + project_id AS projectId, + resource_id AS resourceId, + ref, + version, + version_id AS versionId, + manifest_digest AS manifestDigest, + lifecycle_state AS lifecycleState, + authorized_at AS authorizedAt, + expires_at AS expiresAt + FROM team_project_materializations + WHERE workspace_id = ? AND project_id = ? + `).get(workspaceId, projectId) as + | Omit<AuthorizedTeamProjectPullReceipt, 'schemaVersion'> + | undefined; + return value ? { schemaVersion: 1, ...value } : null; +} + +function parseLegacyTeamProjectVersion(stored: string | null): number | null { + if (stored == null || !/^(?:0|[1-9]\d*)$/.test(stored)) return null; + const parsed = Number(stored); + return Number.isSafeInteger(parsed) ? parsed : null; +} + +/** + * Read the newest cursor across the receipt-backed and legacy stores. + * + * Legacy/manual pulls may advance after an authorized staged pull. The receipt + * cursor remains useful, but it must not hide that newer legacy cursor. An + * authorized row only participates when its complete scope and canonical + * owner-scoped resource binding still match the requested project. + */ +export function latestTeamProjectMaterializationVersion( + authorized: AuthorizedTeamProjectPullReceipt | null, + legacyVersion: string | null, + projectId: string, + scope: TeamMirrorPullScope, +): number | null { + const ownerPrincipal: ResourceHubPrincipal = { + teamId: scope.resourceTeamId, + memberId: scope.ownerMemberId, + role: 'member', + lifecycleState: 'active', + workspaceType: 'team', + }; + const authorizedVersion = + authorized && + authorized.schemaVersion === 1 && + authorized.workspaceId === scope.workspaceId && + authorized.resourceTeamId === scope.resourceTeamId && + authorized.viewerMemberId === scope.viewerMemberId && + authorized.ownerMemberId === scope.ownerMemberId && + authorized.projectId === projectId && + authorized.resourceId === projectResourceIdFor(projectId, ownerPrincipal) && + authorized.ref === 'published' && + authorized.lifecycleState === 'active' && + Number.isSafeInteger(authorized.version) && + authorized.version >= 0 + ? authorized.version + : null; + const legacy = parseLegacyTeamProjectVersion(legacyVersion); + if (authorizedVersion == null) return legacy; + if (legacy == null) return authorizedVersion; + return Math.max(authorizedVersion, legacy); +} + +/** + * Atomically create/update a pulled project and bind it as a read-only mirror + * in the exact validated team scope. Existing bindings are never migrated: + * only an absent row or a compatible active mirror may proceed. + */ +export function materializePulledTeamMirror( + db: SqliteDb, + input: RegisterPulledProjectInput, + scope: TeamMirrorPullScope, + receipt?: AuthorizedTeamProjectPullReceipt, +): MaterializePulledTeamMirrorResult { + if ( + receipt && + ( + receipt.workspaceId !== scope.workspaceId || + receipt.resourceTeamId !== scope.resourceTeamId || + receipt.viewerMemberId !== scope.viewerMemberId || + receipt.ownerMemberId !== scope.ownerMemberId || + receipt.projectId !== input.id || + receipt.ref !== 'published' || + receipt.lifecycleState !== 'active' + ) + ) { + throw new Error(`team mirror receipt binding conflict for ${input.id}`); + } + return db.transaction(() => { + const ownerPrincipal: ResourceHubPrincipal = { + teamId: scope.resourceTeamId, + memberId: scope.ownerMemberId, + role: 'member', + lifecycleState: 'active', + workspaceType: 'team', + }; + const expectedCreator = + scope.ownerMemberId === scope.viewerMemberId ? scope.viewerMemberId : null; + const resourceHubResourceId = + receipt?.resourceId ?? projectResourceIdFor(input.id, ownerPrincipal); + if ( + receipt && + receipt.resourceId !== projectResourceIdFor(input.id, ownerPrincipal) + ) { + throw new Error(`team mirror receipt resource conflict for ${input.id}`); + } + const existingBinding = getWorkspaceProjectByProjectId(db, input.id) as + | { + workspaceId: string; + visibility: string; + resourceState: string | null; + createdByWorkspaceMemberId: string | null; + resourceHubResourceId: string | null; + cloudTombstonedAt: number | null; + } + | undefined; + const existing = getProject(db, input.id); + const isRevokedMirror = + existingBinding?.resourceState === 'deleted' + && Boolean(existing?.metadata?.teamMirrorRevokedAt); + const compatibleBinding = + !existingBinding || + ( + existingBinding.workspaceId === scope.workspaceId && + existingBinding.visibility === 'team' && + ( + existingBinding.resourceState === 'active' + || isRevokedMirror + ) && + existingBinding.cloudTombstonedAt === null && + existingBinding.createdByWorkspaceMemberId === expectedCreator && + ( + existingBinding.resourceHubResourceId === null || + existingBinding.resourceHubResourceId === resourceHubResourceId + ) + ); + if (!compatibleBinding) { + throw new Error(`team mirror binding conflict for ${input.id}`); + } + + let localRecordChanged = false; + if (!existing) { + insertProject(db, { + id: input.id, + name: input.name, + skillId: input.skillId, + designSystemId: input.designSystemId, + metadata: input.metadata, + createdAt: input.createdAt, + updatedAt: input.updatedAt, + }); + localRecordChanged = true; + } else if (existing.name === '共享项目') { + updateProject(db, input.id, { + name: input.name, + skillId: input.skillId, + designSystemId: input.designSystemId, + metadata: input.metadata, + updatedAt: input.updatedAt, + }); + localRecordChanged = true; + } else if (existing.metadata?.teamMirrorRevokedAt) { + const metadata = { + ...((existing.metadata as Record<string, unknown> | null) ?? {}), + }; + delete metadata.teamMirrorRevokedAt; + updateProject(db, input.id, { + metadata, + // Materialization is synchronization, not local activity. Preserve + // the owner's content timestamp rather than stamping this daemon's + // re-share observation time. + updatedAt: input.updatedAt, + }); + localRecordChanged = true; + } + const commentAnchor = ensureProjectCommentAnchorConversation( + db, + input.id, + ); + if (commentAnchor?.created) localRecordChanged = true; + + const patch = { + workspaceId: scope.workspaceId, + visibility: 'team' as const, + resourceState: 'active' as const, + createdByWorkspaceMemberId: expectedCreator, + updatedByWorkspaceMemberId: scope.viewerMemberId, + resourceHubResourceId, + cloudTombstonedAt: null, + syncState: 'synced' as const, + // The ORIGIN's content time, exactly like the project row above — never + // this pull's clock. The project list answers a card's one relative time + // as `MAX(p.updated_at, wp.updated_at)`, so carrying the origin into the + // project row alone was not enough: the binding written in this same + // transaction defaulted to `Date.now()` and `MAX` surfaced it, which is + // why a member's card read 「刚刚更新」 hours after a background pull. + updatedAt: input.updatedAt, + }; + if (existingBinding) { + rebindWorkspaceProject(db, input.id, patch); + } else { + ensureWorkspaceProject(db, { projectId: input.id, ...patch }); + } + const binding = getWorkspaceProject(db, scope.workspaceId, input.id) as + | { + workspaceId: string; + visibility: string; + resourceState: string | null; + createdByWorkspaceMemberId: string | null; + updatedByWorkspaceMemberId: string | null; + resourceHubResourceId: string | null; + cloudTombstonedAt: number | null; + syncState: string | null; + } + | undefined; + if ( + !binding || + binding.workspaceId !== patch.workspaceId || + binding.visibility !== patch.visibility || + binding.resourceState !== patch.resourceState || + binding.createdByWorkspaceMemberId !== patch.createdByWorkspaceMemberId || + binding.updatedByWorkspaceMemberId !== patch.updatedByWorkspaceMemberId || + binding.resourceHubResourceId !== patch.resourceHubResourceId || + binding.cloudTombstonedAt !== null || + binding.syncState !== patch.syncState + ) { + throw new Error(`team mirror binding verification failed for ${input.id}`); + } + if (receipt) { + db.prepare(` + INSERT INTO team_project_materializations ( + workspace_id, + resource_team_id, + viewer_member_id, + owner_member_id, + project_id, + resource_id, + ref, + version, + version_id, + manifest_digest, + lifecycle_state, + authorized_at, + expires_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(workspace_id, project_id) DO UPDATE SET + resource_team_id = excluded.resource_team_id, + viewer_member_id = excluded.viewer_member_id, + owner_member_id = excluded.owner_member_id, + resource_id = excluded.resource_id, + ref = excluded.ref, + version = excluded.version, + version_id = excluded.version_id, + manifest_digest = excluded.manifest_digest, + lifecycle_state = excluded.lifecycle_state, + authorized_at = excluded.authorized_at, + expires_at = excluded.expires_at, + updated_at = excluded.updated_at + `).run( + receipt.workspaceId, + receipt.resourceTeamId, + receipt.viewerMemberId, + receipt.ownerMemberId, + receipt.projectId, + receipt.resourceId, + receipt.ref, + receipt.version, + receipt.versionId, + receipt.manifestDigest, + receipt.lifecycleState, + receipt.authorizedAt, + receipt.expiresAt, + Date.now(), + ); + if ( + !teamProjectMaterializationMatches( + getTeamProjectMaterialization(db, scope.workspaceId, input.id), + receipt, + ) + ) { + throw new Error( + `team mirror materialization verification failed for ${input.id}`, + ); + } + } + return { localRecordChanged }; + })(); +} diff --git a/apps/daemon/src/collab/team-mirror-promotion.ts b/apps/daemon/src/collab/team-mirror-promotion.ts new file mode 100644 index 00000000000..cd0e60fcf23 --- /dev/null +++ b/apps/daemon/src/collab/team-mirror-promotion.ts @@ -0,0 +1,596 @@ +import { randomUUID } from 'node:crypto'; +import { renameSync } from 'node:fs'; +import { + lstat, + mkdir, + open, + readFile, + readdir, + realpath, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; + +import type { + AuthorizedTeamProjectPullReceipt, + AuthorizedTeamProjectStageIdentity, +} from './authorized-team-project-pull.js'; + +export interface TeamMirrorPromotionJournalRecord { + schemaVersion: 1; + id: string; + receipt: AuthorizedTeamProjectPullReceipt; + liveDir: string; + stageDir: string; + recoveryDir: string; + liveExisted: boolean; + phase: 'prepared' | 'live-moved' | 'promoted'; + promotedIdentity: AuthorizedTeamProjectStageIdentity; + recoveryIdentity?: AuthorizedTeamProjectStageIdentity; +} + +interface ActiveWorkspaceSnapshot { + workspaceId: string | null; + generation: number; +} + +interface PromotionDurability { + syncDirectory?: (directory: string) => Promise<void>; + renameDirectorySync?: (from: string, to: string) => void; +} + +export interface PromoteAuthorizedTeamProjectStageInput<T> { + receipt: AuthorizedTeamProjectPullReceipt; + liveDir: string; + stageDir: string; + expectedStageIdentity: AuthorizedTeamProjectStageIdentity; + journalDir: string; + /** Exact-scope authorization captured by the caller. When present it + * replaces the legacy mutable-active-Workspace guard below. */ + isScopeStillAuthorized?: () => boolean; + /** Legacy compatibility seam for older callers/tests. New data-plane + * callers must use `isScopeStillAuthorized`. */ + expectedWorkspaceId?: string; + activeWorkspaceGeneration?: number; + getActiveWorkspaceSnapshot?: () => ActiveWorkspaceSnapshot; + isExpectedVersion: () => boolean; + validateReceipt: () => void; + commit: () => T; + onPostCommitCleanupError?: (error: unknown) => void; + durability?: PromotionDurability; +} + +export interface RecoverAuthorizedTeamProjectPromotionsInput { + journalDir: string; + allowedProjectsRoot: string; + isCommitted: (entry: TeamMirrorPromotionJournalRecord) => boolean; + isSuperseded?: (entry: TeamMirrorPromotionJournalRecord) => boolean; + onError?: (error: unknown) => void; + durability?: PromotionDurability; +} + +function identityOf( + value: Awaited<ReturnType<typeof lstat>>, +): AuthorizedTeamProjectStageIdentity { + return { dev: String(value.dev), ino: String(value.ino) }; +} + +function sameIdentity( + expected: AuthorizedTeamProjectStageIdentity | undefined, + actual: Awaited<ReturnType<typeof lstat>>, +): boolean { + return Boolean( + expected && + expected.dev === String(actual.dev) && + expected.ino === String(actual.ino), + ); +} + +async function exists(target: string): Promise<boolean> { + try { + await lstat(target); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } +} + +async function inspectOwnedDirectory( + target: string, + expected: AuthorizedTeamProjectStageIdentity, + label: string, +): Promise<Awaited<ReturnType<typeof lstat>>> { + const info = await lstat(target); + if ( + info.isSymbolicLink() || + !info.isDirectory() || + !sameIdentity(expected, info) + ) { + throw new Error(`${label} identity changed; refusing mutation`); + } + return info; +} + +async function removeOwnedDirectory( + target: string, + expected: AuthorizedTeamProjectStageIdentity, +): Promise<void> { + if (!(await exists(target))) return; + await inspectOwnedDirectory(target, expected, 'promotion-owned directory'); + const quarantine = `${target}.cleanup-${process.pid}-${randomUUID()}`; + await rename(target, quarantine); + const moved = await lstat(quarantine); + if (!sameIdentity(expected, moved)) { + try { + await rename(quarantine, target); + } catch { + // Preserve the unexpected directory at quarantine if restoration races. + } + throw new Error('promotion-owned directory identity changed during cleanup'); + } + await rm(quarantine, { recursive: true, force: false }); +} + +async function defaultSyncDirectory(directory: string): Promise<void> { + const handle = await open(directory, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +function directorySync( + durability: PromotionDurability | undefined, +): (directory: string) => Promise<void> { + return durability?.syncDirectory ?? defaultSyncDirectory; +} + +async function syncFile(filePath: string): Promise<void> { + const handle = await open(filePath, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function writeJournal( + journalPath: string, + record: TeamMirrorPromotionJournalRecord, + syncDirectory: (directory: string) => Promise<void>, +): Promise<void> { + const journalDir = path.dirname(journalPath); + const journalDirExisted = await exists(journalDir); + await mkdir(journalDir, { recursive: true }); + if (!journalDirExisted) { + // Persist the directory entry itself before relying on fsyncs of files + // contained by a newly-created journal directory. + await syncDirectory(path.dirname(journalDir)); + } + const tempPath = `${journalPath}.${process.pid}.tmp`; + await writeFile(tempPath, `${JSON.stringify(record, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + await syncFile(tempPath); + await rename(tempPath, journalPath); + await syncDirectory(journalDir); +} + +async function removeJournal( + journalPath: string, + syncDirectory: (directory: string) => Promise<void>, +): Promise<void> { + await rm(journalPath, { force: true }); + await syncDirectory(path.dirname(journalPath)); +} + +function expectedStagePrefix(liveDir: string): string { + return `.${path.basename(liveDir)}.od-pull-stage-`; +} + +function expectedRecoveryPrefix(liveDir: string): string { + return `.${path.basename(liveDir)}.od-pull-recovery-`; +} + +async function validatePromotionPaths(input: { + liveDir: string; + stageDir: string; + recoveryDir?: string; + id?: string; + allowedProjectsRoot?: string; +}): Promise<void> { + const liveDir = path.resolve(input.liveDir); + const stageDir = path.resolve(input.stageDir); + if (!path.isAbsolute(input.liveDir) || !path.isAbsolute(input.stageDir)) { + throw new Error('team mirror promotion paths must be absolute'); + } + if (path.dirname(liveDir) !== path.dirname(stageDir)) { + throw new Error('team mirror stage must be on the live directory filesystem'); + } + if (!path.basename(stageDir).startsWith(expectedStagePrefix(liveDir))) { + throw new Error('team mirror stage has an invalid name'); + } + if (input.recoveryDir) { + const recoveryDir = path.resolve(input.recoveryDir); + if (path.dirname(recoveryDir) !== path.dirname(liveDir)) { + throw new Error('team mirror recovery must be beside the live directory'); + } + const expectedName = input.id + ? `${expectedRecoveryPrefix(liveDir)}${input.id}` + : expectedRecoveryPrefix(liveDir); + if ( + input.id + ? path.basename(recoveryDir) !== expectedName + : !path.basename(recoveryDir).startsWith(expectedName) + ) { + throw new Error('team mirror recovery has an invalid name'); + } + } + if (input.allowedProjectsRoot) { + const [root, parent] = await Promise.all([ + realpath(input.allowedProjectsRoot), + realpath(path.dirname(liveDir)), + ]); + if (parent !== root) { + throw new Error('team mirror journal points outside the projects root'); + } + } +} + +async function rollbackPromotion( + record: TeamMirrorPromotionJournalRecord, + journalPath: string, + syncDirectory: (directory: string) => Promise<void>, +): Promise<void> { + const projectParent = path.dirname(record.liveDir); + const recoveryExists = await exists(record.recoveryDir); + if (recoveryExists) { + await inspectOwnedDirectory( + record.recoveryDir, + record.recoveryIdentity!, + 'team mirror recovery', + ); + if (await exists(record.liveDir)) { + // Once a stage was promoted, only its exact persisted inode may be + // removed. An unexpected live tree belongs to the caller and is retained. + await removeOwnedDirectory(record.liveDir, record.promotedIdentity); + await syncDirectory(projectParent); + } + await rename(record.recoveryDir, record.liveDir); + await syncDirectory(projectParent); + } else if ( + !record.liveExisted && + await exists(record.liveDir) + ) { + // `stage -> live` may reach disk before the following promoted-journal + // rewrite. The inode is the durable truth; phase can still be `prepared`. + await removeOwnedDirectory(record.liveDir, record.promotedIdentity); + await syncDirectory(projectParent); + } + if (await exists(record.stageDir)) { + await removeOwnedDirectory(record.stageDir, record.promotedIdentity); + await syncDirectory(projectParent); + } + await removeJournal(journalPath, syncDirectory); +} + +export async function promoteAuthorizedTeamProjectStage<T>( + input: PromoteAuthorizedTeamProjectStageInput<T>, +): Promise<T> { + const liveDir = path.resolve(input.liveDir); + const stageDir = path.resolve(input.stageDir); + await validatePromotionPaths({ liveDir, stageDir }); + await inspectOwnedDirectory( + stageDir, + input.expectedStageIdentity, + 'team mirror stage', + ); + + const scopeStillAuthorized = (): boolean => { + if (input.isScopeStillAuthorized) { + return input.isScopeStillAuthorized(); + } + if ( + !input.getActiveWorkspaceSnapshot || + input.expectedWorkspaceId == null || + input.activeWorkspaceGeneration == null + ) { + return false; + } + const snapshot = input.getActiveWorkspaceSnapshot(); + return ( + snapshot.workspaceId === input.expectedWorkspaceId && + snapshot.generation === input.activeWorkspaceGeneration + ); + }; + if (!scopeStillAuthorized()) { + await removeOwnedDirectory(stageDir, input.expectedStageIdentity); + throw new Error( + input.isScopeStillAuthorized + ? 'team mirror workspace scope changed while the project was staged' + : 'active workspace changed while the team mirror was staged', + ); + } + if (!input.isExpectedVersion()) { + await removeOwnedDirectory(stageDir, input.expectedStageIdentity); + throw new Error('stale team mirror stage version'); + } + + const id = randomUUID(); + const journalPath = path.join(input.journalDir, `${id}.json`); + const recoveryDir = path.join( + path.dirname(liveDir), + `${expectedRecoveryPrefix(liveDir)}${id}`, + ); + const syncDirectory = directorySync(input.durability); + const renameDirectorySync = + input.durability?.renameDirectorySync ?? renameSync; + const liveExisted = await exists(liveDir); + let originalLiveIdentity: AuthorizedTeamProjectStageIdentity | undefined; + if (liveExisted) { + const live = await lstat(liveDir); + if (live.isSymbolicLink() || !live.isDirectory()) { + throw new Error('live team mirror must be a real directory'); + } + originalLiveIdentity = identityOf(live); + } + const record: TeamMirrorPromotionJournalRecord = { + schemaVersion: 1, + id, + receipt: input.receipt, + liveDir, + stageDir, + recoveryDir, + liveExisted, + phase: 'prepared', + promotedIdentity: input.expectedStageIdentity, + ...(originalLiveIdentity + ? { recoveryIdentity: originalLiveIdentity } + : {}), + }; + await writeJournal(journalPath, record, syncDirectory); + + try { + if (liveExisted) { + await inspectOwnedDirectory(liveDir, originalLiveIdentity!, 'live team mirror'); + await inspectOwnedDirectory( + stageDir, + input.expectedStageIdentity, + 'team mirror stage', + ); + // A pair of awaited renames leaves a JavaScript-observable ENOENT + // window at `liveDir`; durability work between them made that window + // last seconds on a busy disk. Keep the two same-filesystem namespace + // mutations in one synchronous critical section so daemon readers can + // observe either the complete old tree or the complete promoted tree, + // never an absent path. The already-durable `prepared` journal covers + // a process crash after either syscall. + renameDirectorySync(liveDir, recoveryDir); + try { + renameDirectorySync(stageDir, liveDir); + } catch (error) { + try { + renameDirectorySync(recoveryDir, liveDir); + } catch (restoreError) { + throw new AggregateError( + [error, restoreError], + 'team mirror swap failed and immediate live recovery failed', + ); + } + throw error; + } + const recovery = await inspectOwnedDirectory( + recoveryDir, + originalLiveIdentity!, + 'team mirror recovery', + ); + if (!sameIdentity(record.recoveryIdentity, recovery)) { + throw new Error('team mirror recovery identity changed after live move'); + } + } else { + await inspectOwnedDirectory( + stageDir, + input.expectedStageIdentity, + 'team mirror stage', + ); + await rename(stageDir, liveDir); + } + await inspectOwnedDirectory( + liveDir, + input.expectedStageIdentity, + 'promoted team mirror', + ); + record.phase = 'promoted'; + await syncDirectory(path.dirname(liveDir)); + await writeJournal(journalPath, record, syncDirectory); + + // No await may be introduced between these final local guards and the + // synchronous SQLite transaction. + if ( + !scopeStillAuthorized() || + !input.isExpectedVersion() + ) { + throw new Error('team mirror promotion became stale'); + } + input.validateReceipt(); + const result = input.commit(); + try { + if (await exists(recoveryDir)) { + // This barrier also gives post-commit cleanup failures a safe boundary: + // the committed promoted tree and journal remain recoverable, and the + // old recovery tree has not been touched yet. + await syncDirectory(path.dirname(liveDir)); + await removeOwnedDirectory(recoveryDir, record.recoveryIdentity!); + await syncDirectory(path.dirname(liveDir)); + } + await removeJournal(journalPath, syncDirectory); + } catch (error) { + // Cleanup is maintenance after the durable SQLite+live commit boundary. + // Report it for observability, retain the journal for startup recovery, + // and still return the committed result so callers advance their cursor. + try { + input.onPostCommitCleanupError?.(error); + } catch { + // Observer failures cannot turn a committed materialization into a + // failed pull or make it eligible for rollback. + } + } + return result; + } catch (error) { + try { + await rollbackPromotion(record, journalPath, syncDirectory); + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + `team mirror promotion failed and old tree recovery was retained at ${recoveryDir}`, + ); + } + throw error; + } +} + +function parseIdentity( + value: unknown, +): AuthorizedTeamProjectStageIdentity | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const identity = value as Partial<AuthorizedTeamProjectStageIdentity>; + return typeof identity.dev === 'string' && typeof identity.ino === 'string' + ? { dev: identity.dev, ino: identity.ino } + : undefined; +} + +function parseReceipt(value: unknown): AuthorizedTeamProjectPullReceipt | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const receipt = value as Partial<AuthorizedTeamProjectPullReceipt>; + return receipt.schemaVersion === 1 && + typeof receipt.workspaceId === 'string' && + typeof receipt.resourceTeamId === 'string' && + typeof receipt.viewerMemberId === 'string' && + typeof receipt.ownerMemberId === 'string' && + typeof receipt.projectId === 'string' && + typeof receipt.resourceId === 'string' && + receipt.ref === 'published' && + Number.isSafeInteger(receipt.version) && + typeof receipt.versionId === 'string' && + typeof receipt.manifestDigest === 'string' && + receipt.lifecycleState === 'active' && + typeof receipt.authorizedAt === 'string' && + typeof receipt.expiresAt === 'string' + ? receipt as AuthorizedTeamProjectPullReceipt + : null; +} + +function parseJournal(value: unknown): TeamMirrorPromotionJournalRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const record = value as Partial<TeamMirrorPromotionJournalRecord>; + const receipt = parseReceipt(record.receipt); + const promotedIdentity = parseIdentity(record.promotedIdentity); + const recoveryIdentity = parseIdentity(record.recoveryIdentity); + if ( + record.schemaVersion !== 1 || + typeof record.id !== 'string' || + !receipt || + typeof record.liveDir !== 'string' || + typeof record.stageDir !== 'string' || + typeof record.recoveryDir !== 'string' || + typeof record.liveExisted !== 'boolean' || + !promotedIdentity || + ( + record.phase !== 'prepared' && + record.phase !== 'live-moved' && + record.phase !== 'promoted' + ) || + (record.liveExisted && !recoveryIdentity) + ) { + return null; + } + return { + schemaVersion: 1, + id: record.id, + receipt, + liveDir: record.liveDir, + stageDir: record.stageDir, + recoveryDir: record.recoveryDir, + liveExisted: record.liveExisted, + phase: record.phase, + promotedIdentity, + ...(recoveryIdentity ? { recoveryIdentity } : {}), + }; +} + +export async function recoverAuthorizedTeamProjectPromotions( + input: RecoverAuthorizedTeamProjectPromotionsInput, +): Promise<void> { + let entries: string[]; + try { + entries = await readdir(input.journalDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw error; + } + const syncDirectory = directorySync(input.durability); + for (const name of entries.filter((entry) => entry.endsWith('.json'))) { + const journalPath = path.join(input.journalDir, name); + try { + const record = parseJournal( + JSON.parse(await readFile(journalPath, 'utf8')) as unknown, + ); + if (!record) { + throw new Error(`invalid team mirror promotion journal: ${journalPath}`); + } + await validatePromotionPaths({ + liveDir: record.liveDir, + stageDir: record.stageDir, + recoveryDir: record.recoveryDir, + id: record.id, + allowedProjectsRoot: input.allowedProjectsRoot, + }); + if (input.isCommitted(record)) { + await inspectOwnedDirectory( + record.liveDir, + record.promotedIdentity, + 'committed team mirror', + ); + if (await exists(record.recoveryDir)) { + await removeOwnedDirectory( + record.recoveryDir, + record.recoveryIdentity!, + ); + await syncDirectory(path.dirname(record.liveDir)); + } + if (await exists(record.stageDir)) { + await removeOwnedDirectory(record.stageDir, record.promotedIdentity); + await syncDirectory(path.dirname(record.liveDir)); + } + await removeJournal(journalPath, syncDirectory); + continue; + } + if (input.isSuperseded?.(record)) { + // A newer authorized receipt/live tree owns `liveDir`. Only remove + // paths whose exact inodes belong to this older journal. + if (await exists(record.recoveryDir)) { + await removeOwnedDirectory( + record.recoveryDir, + record.recoveryIdentity!, + ); + await syncDirectory(path.dirname(record.liveDir)); + } + if (await exists(record.stageDir)) { + await removeOwnedDirectory(record.stageDir, record.promotedIdentity); + await syncDirectory(path.dirname(record.liveDir)); + } + await removeJournal(journalPath, syncDirectory); + continue; + } + await rollbackPromotion(record, journalPath, syncDirectory); + } catch (error) { + if (!input.onError) throw error; + input.onError(error); + } + } +} diff --git a/apps/daemon/src/collab/team-projects.ts b/apps/daemon/src/collab/team-projects.ts new file mode 100644 index 00000000000..3026b01ee2f --- /dev/null +++ b/apps/daemon/src/collab/team-projects.ts @@ -0,0 +1,29 @@ +// Team-wide shared-project discovery. The Vela CLI is the only production +// transport: it reuses the login session and keeps backend credentials out of +// the Open Design daemon. + +import type { TeamProject } from '@open-design/contracts'; +import { + createVelaCliTeamProjectCatalog, + shouldUseVelaCliTeamProjectCatalog, + type VelaTeamProjectCatalog, +} from './vela-cli-team-projects.js'; + +export interface CreateTeamProjectsListerOptions { + /** Injectable Vela catalog for tests. */ + teamProjectCatalog?: VelaTeamProjectCatalog; + env?: NodeJS.ProcessEnv; +} + +export function createTeamProjectsLister( + options: CreateTeamProjectsListerOptions, +): (workspaceId: string) => Promise<TeamProject[]> { + const env = options.env ?? process.env; + return async (workspaceId: string) => { + const scopedWorkspaceId = workspaceId.trim(); + if (!scopedWorkspaceId) return []; + if (options.teamProjectCatalog) return options.teamProjectCatalog.list(workspaceId); + if (!shouldUseVelaCliTeamProjectCatalog(env)) return []; + return createVelaCliTeamProjectCatalog().list(scopedWorkspaceId); + }; +} diff --git a/apps/daemon/src/collab/team-resource-list-cache.ts b/apps/daemon/src/collab/team-resource-list-cache.ts new file mode 100644 index 00000000000..000f4e46dd0 --- /dev/null +++ b/apps/daemon/src/collab/team-resource-list-cache.ts @@ -0,0 +1,24 @@ +import type { TeamResourceRequestScope } from './team-resource-share.js'; + +export const TEAM_RESOURCE_LIST_KINDS = ['design_system', 'plugin', 'skill'] as const; + +export type TeamResourceListKind = (typeof TEAM_RESOURCE_LIST_KINDS)[number]; + +export interface TeamResourceListInvalidator { + invalidate(scope: TeamResourceRequestScope): void; +} + +export function invalidateTeamResourceListingCaches(input: { + resourceKind?: string; + scope: TeamResourceRequestScope; + providers: Record<TeamResourceListKind, TeamResourceListInvalidator>; + invalidateSharedCommand: (workspaceId: string) => void; +}): void { + const kinds = input.resourceKind + ? TEAM_RESOURCE_LIST_KINDS.filter((kind) => kind === input.resourceKind) + : TEAM_RESOURCE_LIST_KINDS; + for (const kind of kinds) { + input.providers[kind].invalidate(input.scope); + } + input.invalidateSharedCommand(input.scope.principal.teamId); +} diff --git a/apps/daemon/src/collab/team-resource-materialization.ts b/apps/daemon/src/collab/team-resource-materialization.ts new file mode 100644 index 00000000000..1a624538143 --- /dev/null +++ b/apps/daemon/src/collab/team-resource-materialization.ts @@ -0,0 +1,214 @@ +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +const TEAM_RESOURCE_ROOT = '.team-workspaces'; +const MATERIALIZATION_FILE = '.od-team-resource.json'; + +export interface TeamResourceMaterializationIdentity { + kind: 'design_system' | 'plugin' | 'skill'; + workspaceId: string; + resourceId: string; + hubResourceId: string; + sourceKey: string; +} + +export type TeamResourceMaterializationResult = + | { status: 'committed'; targetDir: string; sourceKey: string } + | { status: 'revoked' }; + +function storageSegment(value: string): string { + const readable = value + .trim() + .replace(/[^a-zA-Z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48) || 'resource'; + const digest = createHash('sha256').update(value).digest('hex').slice(0, 16); + return `${readable}-${digest}`; +} + +function workspaceStorageSegment(workspaceId: string): string { + return createHash('sha256').update(workspaceId).digest('hex'); +} + +export function teamResourceWorkspaceRoot( + kindRoot: string, + workspaceId: string, +): string { + return path.join( + kindRoot, + TEAM_RESOURCE_ROOT, + workspaceStorageSegment(workspaceId), + ); +} + +export function teamResourceMaterializationDir( + kindRoot: string, + workspaceId: string, + resourceId: string, + storageName?: string, +): string { + const physicalName = storageName?.trim(); + if (physicalName && (!/^[a-zA-Z0-9_-]+$/u.test(physicalName) || physicalName === '.' || physicalName === '..')) { + throw new Error('invalid team resource storage name'); + } + return path.join( + teamResourceWorkspaceRoot(kindRoot, workspaceId), + physicalName || storageSegment(resourceId), + ); +} + +export function teamResourceSourceKey(input: { + kind: TeamResourceMaterializationIdentity['kind']; + workspaceId: string; + resourceId: string; +}): string { + return `team:${input.kind}:${input.workspaceId}:${input.resourceId}`; +} + +export async function readTeamResourceMaterialization( + kindRoot: string, + workspaceId: string, + resourceId: string, + storageName?: string, +): Promise<TeamResourceMaterializationIdentity | null> { + const targetDir = teamResourceMaterializationDir( + kindRoot, + workspaceId, + resourceId, + storageName, + ); + try { + const raw = await fs.readFile(path.join(targetDir, MATERIALIZATION_FILE), 'utf8'); + const parsed = JSON.parse(raw) as Partial<TeamResourceMaterializationIdentity>; + if ( + parsed.workspaceId !== workspaceId || + parsed.resourceId !== resourceId || + typeof parsed.kind !== 'string' || + typeof parsed.hubResourceId !== 'string' || + typeof parsed.sourceKey !== 'string' + ) { + return null; + } + return parsed as TeamResourceMaterializationIdentity; + } catch { + return null; + } +} + +export async function readWorkspaceScopedTeamResourceFile( + kindRoot: string, + workspaceId: string, + resourceId: string, + relativePath: string, + storageName?: string, +): Promise<Buffer | null> { + const normalized = relativePath.replaceAll('\\', '/').replace(/^\/+/u, ''); + if ( + !normalized || + normalized.split('/').some((segment) => segment === '..' || segment === '.') + ) { + return null; + } + const marker = await readTeamResourceMaterialization( + kindRoot, + workspaceId, + resourceId, + storageName, + ); + if (!marker) return null; + const root = teamResourceMaterializationDir( + kindRoot, + workspaceId, + resourceId, + storageName, + ); + const target = path.resolve(root, normalized); + if (path.dirname(target) !== path.resolve(root) && !target.startsWith(`${path.resolve(root)}${path.sep}`)) { + return null; + } + try { + const [realRoot, realTarget] = await Promise.all([ + fs.realpath(root), + fs.realpath(target), + ]); + if ( + realTarget !== realRoot + && !realTarget.startsWith(`${realRoot}${path.sep}`) + ) { + return null; + } + return await fs.readFile(realTarget); + } catch { + return null; + } +} + +/** + * Pull into an invisible staging directory, then close both authorization + * races before replacing the exact Workspace's live copy. A directory/listing + * failure is a revocation verdict, never permission to commit downloaded + * bytes. + */ +export async function materializeWorkspaceScopedTeamResource(input: { + kindRoot: string; + identity: Omit<TeamResourceMaterializationIdentity, 'sourceKey'>; + storageName?: string; + pullInto: (stagedDir: string) => Promise<void>; + verifyWorkspaceScope: () => Promise<boolean>; + verifyStillShared: () => Promise<boolean>; +}): Promise<TeamResourceMaterializationResult> { + const sourceKey = teamResourceSourceKey(input.identity); + const targetDir = teamResourceMaterializationDir( + input.kindRoot, + input.identity.workspaceId, + input.identity.resourceId, + input.storageName, + ); + const workspaceRoot = path.dirname(targetDir); + await fs.mkdir(workspaceRoot, { recursive: true }); + const stagedDir = await fs.mkdtemp( + path.join(workspaceRoot, `.${path.basename(targetDir)}.pull-`), + ); + + try { + await input.pullInto(stagedDir); + const scopeStillValid = await input.verifyWorkspaceScope().catch(() => false); + if (!scopeStillValid) return { status: 'revoked' }; + const resourceStillShared = await input.verifyStillShared().catch(() => false); + if (!resourceStillShared) return { status: 'revoked' }; + + const identity: TeamResourceMaterializationIdentity = { + ...input.identity, + sourceKey, + }; + await fs.writeFile( + path.join(stagedDir, MATERIALIZATION_FILE), + `${JSON.stringify(identity, null, 2)}\n`, + 'utf8', + ); + + const recoveryDir = `${targetDir}.recovery-${process.pid}-${Date.now()}`; + let hadPrevious = false; + try { + await fs.rename(targetDir, recoveryDir); + hadPrevious = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + try { + await fs.rename(stagedDir, targetDir); + } catch (error) { + if (hadPrevious) { + await fs.rename(recoveryDir, targetDir).catch(() => undefined); + } + throw error; + } + if (hadPrevious) { + await fs.rm(recoveryDir, { recursive: true, force: true }).catch(() => undefined); + } + return { status: 'committed', targetDir, sourceKey }; + } finally { + await fs.rm(stagedDir, { recursive: true, force: true }).catch(() => undefined); + } +} diff --git a/apps/daemon/src/collab/team-resource-share.ts b/apps/daemon/src/collab/team-resource-share.ts new file mode 100644 index 00000000000..07d97341d24 --- /dev/null +++ b/apps/daemon/src/collab/team-resource-share.ts @@ -0,0 +1,360 @@ +// Team resource sharing. A member with publish rights promotes a personal +// resource — a design system, plugin, or skill — into the team scope: the +// resource's directory is packed and pushed by the login-backed Vela CLI under +// its kind, so teammates can pull it into their own workspace. Open Design owns +// the permission gate and scheduling, not backend credentials or byte transfer. + +import type { + WorkspaceCollabContext, + WorkspaceDirectoryItem, +} from '@open-design/contracts'; +import { + contextToResourceHubPrincipal, + type ResourceHubPrincipal, +} from './resource-principal.js'; +import { + createVelaCliResourceAdapter, + shouldUseVelaCliResourceTransport, +} from './vela-cli-resource-adapter.js'; +import type { ResourcePublishAdapter } from './publish-scheduler.js'; +import { workspaceContextFromDirectoryItem } from './vela-workspace-context.js'; + +/** Thrown when a team member without share rights attempts to share a resource. */ +export class TeamResourceShareForbiddenError extends Error { + constructor() { + super('workspace_resource_share_denied'); + this.name = 'TeamResourceShareForbiddenError'; + } +} + +export interface TeamResourceShareRecord { + id: string; + hubResourceId?: string; + title?: string; + description?: string; + ownerMemberId?: string; + canUnshare?: boolean; + versionId?: string; + version?: number; +} + +/** + * Request-verified authority for one Team resource operation. + * + * The route resolves this from the request's explicit Workspace headers and + * the authoritative membership directory. Services must never reconstruct it + * from the daemon's mutable active Workspace. + */ +export interface TeamResourceRequestScope { + principal: ResourceHubPrincipal; + canShare: boolean; +} + +export function teamResourceRequestScopeFromContext( + context: WorkspaceCollabContext, +): TeamResourceRequestScope | null { + const principal = contextToResourceHubPrincipal(context); + if (!principal || context.memberStatus !== 'active') return null; + return { + principal, + canShare: Boolean( + context.permissions.canManageSharedResources || + context.permissions.canShareProjects, + ), + }; +} + +export function teamResourceRequestScopeForWorkspaceId( + items: WorkspaceDirectoryItem[], + workspaceId: string, +): TeamResourceRequestScope | null { + const requestedWorkspaceId = workspaceId.trim(); + if (!requestedWorkspaceId) return null; + const membership = items.find( + (item) => + item.workspaceId === requestedWorkspaceId && + item.workspaceType === 'team' && + item.memberStatus === 'active' && + item.lifecycleState !== 'deleted', + ); + return membership + ? teamResourceRequestScopeFromContext( + workspaceContextFromDirectoryItem(membership), + ) + : null; +} + +export interface TeamResourceShareService { + /** Share a resource to the team. Returns the published version, or null off-team. */ + share(resourceId: string, scope: TeamResourceRequestScope): Promise<{ version: number } | null>; + /** Remove a resource from the team index. Returns false off-team/unconfigured. */ + unshare(resourceId: string, scope: TeamResourceRequestScope): Promise<boolean>; + /** Ids of resources shared to the team. */ + sharedIds(scope: TeamResourceRequestScope): Promise<string[]>; + /** Resources shared to the team, including best-effort display metadata. */ + sharedResources(scope: TeamResourceRequestScope): Promise<TeamResourceShareRecord[]>; + /** True once a resource has been shared to the team. */ + isShared(resourceId: string, scope: TeamResourceRequestScope): boolean; + /** Whether the login-backed Vela transport is wired. */ + readonly configured: boolean; +} + +export interface CreateTeamResourceShareOptions { + /** Resource hub kind, e.g. `design_system` | `plugin` | `skill`. */ + kind: string; + /** Colon-free id-namespace prefix distinguishing this kind on the shared hub. */ + idPrefix: string; + /** Resolve a resource's source directory (what gets packed and pushed). May be + * async: the skill resolver awaits the skill index, and the publish adapter + * awaits this before packing. */ + resolveDir: (resourceId: string) => string | Promise<string>; + /** Optional resource-index metadata shown in teammate team lists. */ + describeResource?: (resourceId: string) => Record<string, unknown> | null | Promise<Record<string, unknown> | null>; + /** Injectable Vela resource runner for tests. */ + run?: (args: string[], workspaceId?: string) => Promise<string>; + env?: NodeJS.ProcessEnv; +} + +export function createTeamResourceShareService( + options: CreateTeamResourceShareOptions, +): TeamResourceShareService { + const env = options.env ?? process.env; + if (!shouldUseVelaCliResourceTransport(env)) { + return { + share: async () => null, + unshare: async () => false, + sharedIds: async () => [], + sharedResources: async () => [], + isShared: () => false, + configured: false, + }; + } + // Distinct, colon-free id namespace on the shared hub. The caller's id (e.g. + // `user:palette-x`) is sanitized to path-safe chars — the hub routes the + // resource id as a path param, so a colon would 404. + const sanitizeResourceIdSegment = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, '-'); + const scopedIdPrefixFor = (principal?: ResourceHubPrincipal | null) => + principal?.teamId + ? `${options.idPrefix}-${sanitizeResourceIdSegment(principal.teamId)}` + : options.idPrefix; + const resourceIdFor = (id: string, principal?: ResourceHubPrincipal | null) => + `${scopedIdPrefixFor(principal)}-${sanitizeResourceIdSegment(id)}`; + // Ids shared this session. The published resources are the durable record on + // the hub; this is the fast local view the team collection reads until a hub + // listing query lands. + const sharedByWorkspace = new Map<string, Set<string>>(); + const sharedFor = (workspaceId: string): Set<string> => { + let shared = sharedByWorkspace.get(workspaceId); + if (!shared) { + shared = new Set<string>(); + sharedByWorkspace.set(workspaceId, shared); + } + return shared; + }; + + const adapter: ResourcePublishAdapter = createVelaCliResourceAdapter({ + resolveProjectDir: options.resolveDir, + resourceIdFor, + kind: options.kind, + // Every operation below already requires a request-verified Team scope. + // Re-reading the daemon's ambient Workspace here would reintroduce the + // cross-tab switch race this service boundary exists to prevent. + hasTeamIdentity: () => true, + ...(options.describeResource ? { describeProject: options.describeResource } : {}), + ...(options.run ? { run: options.run } : {}), + }); + + return { + async share(resourceId, scope) { + // Permission gate: only a member who can manage shared resources may + // promote one to the team. The route supplied this bit from the + // authoritative directory, never from caller-controlled headers. + if (!scope.canShare) throw new TeamResourceShareForbiddenError(); + const { principal } = scope; + const result = await adapter.publish({ + projectId: resourceId, + principal, + reason: 'share', + }); + if (result) sharedFor(principal.teamId).add(resourceId); + return result; + }, + async unshare(resourceId, scope) { + const { principal } = scope; + const sharedResource = (await this.sharedResources(scope)).find((resource) => resource.id === resourceId); + if (sharedResource && !sharedResource.canUnshare) { + throw new TeamResourceShareForbiddenError(); + } + await adapter.unpublish?.({ projectId: resourceId, principal }); + sharedFor(principal.teamId).delete(resourceId); + return true; + }, + async sharedIds(scope) { + return (await this.sharedResources(scope)).map((resource) => resource.id); + }, + async sharedResources(scope) { + const { principal } = scope; + const shared = sharedFor(principal.teamId); + try { + const out = await (options.run ?? defaultRun)( + ['shared', '--json'], + principal.teamId, + ); + const scopedResources = parseSharedResourceRecords(out, options.kind, scopedIdPrefixFor(principal)); + const legacyResources = principal.workspaceType === 'personal' + ? [] + : parseSharedResourceRecords(out, options.kind, options.idPrefix); + const byId = new Map<string, TeamResourceShareRecord>(); + for (const resource of legacyResources) byId.set(resource.id, resource); + for (const resource of scopedResources) byId.set(resource.id, resource); + const resources = [...byId.values()]; + shared.clear(); + for (const resource of resources) shared.add(resource.id); + return resources + .map((resource) => { + // Keep the non-enumerable hubResourceId attached for the local + // materializer. Spreading into a new object drops that descriptor + // and makes workspace-scoped resources fall back to the legacy, + // unscoped id when a teammate pulls them. + resource.canUnshare = canManageSharedResource(principal, resource); + return resource; + }) + .sort((a, b) => a.id.localeCompare(b.id)); + } catch { + return [...shared].sort().map((id) => ({ id, canUnshare: true })); + } + }, + isShared: (resourceId, scope) => sharedFor(scope.principal.teamId).has(resourceId), + configured: true, + }; +} + +async function defaultRun(args: string[], workspaceId?: string): Promise<string> { + const { runVelaResourceCommand } = await import('./vela-cli-resource-adapter.js'); + return runVelaResourceCommand(args, workspaceId); +} + +/** + * spec 04 §11: unshare `resourceId` from `service`'s team hub, but ONLY when + * it is CURRENTLY on the LIVE `sharedResources()` list — never inferred from + * a caller-side "was this ever synced locally" flag. A resource's own + * sharer never carries a local "teamSynced" marker (that flag is only ever + * stamped onto a TEAMMATE's pulled copy — see `isTeamSyncedUserDesignSystem` + * in design-systems/index.ts), so gating an unshare on it would let the + * sharer delete their own shared resource without ever touching the hub's + * index, leaving a dangling entry that keeps re-syncing onto every teammate. + * This is the exact bug a resource-delete route must close for every + * resource kind that can be team-shared (design system today; the analogous + * project fix lives in `routes/project/index.ts`'s DELETE handler, reusing + * `requestTeamVisibility`/`collabSync.requestTeamUnshare` instead of this + * helper because projects don't go through `TeamResourceShareService`). + * + * Returns whether an unshare actually ran, so a caller — and its tests — can + * assert on the real state transition instead of a "was unshare called" mock. + * Deliberately does NOT swallow a thrown `TeamResourceShareForbiddenError`: + * the caller's delete must abort rather than proceed past a failed unshare. + */ +export async function unshareIfCurrentlyShared( + service: Pick<TeamResourceShareService, 'sharedResources' | 'unshare'>, + resourceId: string, + scope: TeamResourceRequestScope, +): Promise<boolean> { + const resources = await service.sharedResources(scope); + if (!resources.some((resource) => resource.id === resourceId)) return false; + await service.unshare(resourceId, scope); + return true; +} + +interface SharedResourceListPayload { + resources?: Array<{ + id?: unknown; + kind?: unknown; + deletedAt?: unknown; + metadata?: unknown; + ownerMemberId?: unknown; + publishedVersion?: unknown; + }>; +} + +export function parseSharedResourceIds( + stdout: string, + kind: string, + idPrefix: string, +): string[] { + return parseSharedResourceRecords(stdout, kind, idPrefix).map((resource) => resource.id); +} + +export function parseSharedResourceRecords( + stdout: string, + kind: string, + idPrefix: string, +): TeamResourceShareRecord[] { + const trimmed = stdout.trim(); + if (!trimmed) return []; + let parsed: SharedResourceListPayload; + try { + parsed = JSON.parse(trimmed) as SharedResourceListPayload; + } catch { + return []; + } + const prefix = `${idPrefix}-`; + const records = new Map<string, TeamResourceShareRecord>(); + for (const resource of parsed.resources ?? []) { + if (resource.kind !== kind || resource.deletedAt != null) continue; + if (typeof resource.id !== 'string' || !resource.id.startsWith(prefix)) { + continue; + } + const rawLocalId = resource.id.slice(prefix.length); + const metadata = isObjectRecord(resource.metadata) ? resource.metadata : {}; + const localId = stringValue(metadata.localId) || decodeSharedResourceLocalId(rawLocalId, kind); + if (!localId) continue; + const title = stringValue(metadata.title) || stringValue(metadata.name); + const description = stringValue(metadata.description); + const ownerMemberId = stringValue(resource.ownerMemberId); + const publishedVersion = isObjectRecord(resource.publishedVersion) + ? resource.publishedVersion + : null; + const versionId = stringValue(publishedVersion?.id); + const version = typeof publishedVersion?.version === 'number' + ? publishedVersion.version + : undefined; + const record: TeamResourceShareRecord = { + id: localId, + ...(title ? { title } : {}), + ...(description ? { description } : {}), + ...(ownerMemberId ? { ownerMemberId } : {}), + ...(versionId ? { versionId } : {}), + ...(version !== undefined ? { version } : {}), + }; + Object.defineProperty(record, 'hubResourceId', { + value: resource.id, + enumerable: false, + configurable: true, + }); + records.set(localId, record); + } + return [...records.values()].sort((a, b) => a.id.localeCompare(b.id)); +} + +function canManageSharedResource( + principal: ResourceHubPrincipal, + resource: TeamResourceShareRecord, +): boolean { + if (principal.role === 'owner' || principal.role === 'admin') return true; + return typeof resource.ownerMemberId === 'string' && resource.ownerMemberId === principal.memberId; +} + +function decodeSharedResourceLocalId(localId: string, kind: string): string { + if (kind === 'design_system' && localId.startsWith('user-')) { + return `user:${localId.slice('user-'.length)}`; + } + return localId; +} + +function isObjectRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} diff --git a/apps/daemon/src/collab/team-resource-state.ts b/apps/daemon/src/collab/team-resource-state.ts new file mode 100644 index 00000000000..4c0a0e64fa5 --- /dev/null +++ b/apps/daemon/src/collab/team-resource-state.ts @@ -0,0 +1,66 @@ +import { + assertTeamResourceCopyAllowed, + type TeamResourceCopyTarget, + type TeamResourceState, +} from '@open-design/contracts'; + +// Team-resource state seam (D1 state model). Reports whether a design system / +// plugin / skill is a team resource and its lifecycle state, so the copy +// red-line guard (D3, assertTeamResourceCopyAllowed) can be enforced at the +// copy-out routes. In production this resolves against E's resource-hub (the resource-hub owner, +// which owns whether a resource is team-shared + frozen); until it is reachable, +// the dev provider holds an in-memory map a demo/test can seed. Swapping the +// provider is the only change when the hub ships. + +export type TeamResourceKind = 'design-system' | 'plugin' | 'skill'; + +export interface TeamResourceKey { + kind: TeamResourceKind; + resourceId: string; +} + +export interface TeamResourceStateProvider { + /** The copy-guard view of a resource. Unknown resources default to personal. */ + resolve(key: TeamResourceKey): Promise<TeamResourceCopyTarget>; + /** Dev/demo seam: mark a resource team-shared with a state (absent on the real hub-backed provider). */ + set?(key: TeamResourceKey, target: TeamResourceCopyTarget): void; +} + +function mapKey(key: TeamResourceKey): string { + return `${key.kind}:${key.resourceId}`; +} + +/** + * Dev/demo provider: an in-memory registry of team-shared resources. A resource + * not in the registry is treated as `personal` (copies freely) — so with no team + * resources registered the guard is a no-op, exactly as production is until E's + * hub reports real team resources. A test seeds a `frozen` resource to prove the + * guard actually rejects. + */ +export function createDevTeamResourceStateProvider(): TeamResourceStateProvider { + const registry = new Map<string, TeamResourceState>(); + return { + async resolve(key) { + const state = registry.get(mapKey(key)); + return state === undefined ? { scope: 'personal' } : { scope: 'team', state }; + }, + set(key, target) { + if (target.scope === 'team' && target.state) registry.set(mapKey(key), target.state); + else registry.delete(mapKey(key)); + }, + }; +} + +/** + * Resolve a resource's state and enforce the copy red-line (D3) at a copy-out + * route. Throws {@link TeamResourceCopyForbiddenError} (which routes map to 403) + * when the resource is a frozen/deleted team resource. A one-liner the escape + * routes (plugin duplicate, DS copy, skill edit-shadow) call before copying. + */ +export async function enforceTeamResourceCopyAllowed( + provider: TeamResourceStateProvider, + key: TeamResourceKey, +): Promise<void> { + const target = await provider.resolve(key); + assertTeamResourceCopyAllowed(target); +} diff --git a/apps/daemon/src/collab/team-resource-version-store.ts b/apps/daemon/src/collab/team-resource-version-store.ts new file mode 100644 index 00000000000..66d5054b384 --- /dev/null +++ b/apps/daemon/src/collab/team-resource-version-store.ts @@ -0,0 +1,107 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +type StoredVersions = Record<string, string>; + +export interface TeamResourceVersionStore { + get(workspaceId: string, kind: string, resourceId: string): string | null; + set( + workspaceId: string, + kind: string, + resourceId: string, + versionId: string, + ): Promise<void>; +} + +function versionKey(workspaceId: string, kind: string, resourceId: string) { + return JSON.stringify([workspaceId, kind, resourceId]); +} + +export function createTeamResourceVersionStore( + runtimeDataDir: string, +): TeamResourceVersionStore { + const filePath = path.join(runtimeDataDir, 'team-resource-versions.json'); + let versions: StoredVersions = {}; + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + versions = Object.fromEntries( + Object.entries(parsed).filter( + (entry): entry is [string, string] => + typeof entry[1] === 'string' && entry[1].length > 0, + ), + ); + } + } catch { + versions = {}; + } + + let pendingVersions = new Map<string, string>(); + let pendingWaiters: Array<{ + resolve: () => void; + reject: (error: unknown) => void; + }> = []; + let drainScheduled = false; + + async function drainPendingVersions(): Promise<void> { + try { + while (pendingVersions.size > 0) { + const batchVersions = pendingVersions; + const batchWaiters = pendingWaiters; + pendingVersions = new Map(); + pendingWaiters = []; + try { + // Build from the last successfully COMMITTED snapshot inside the + // single writer. Every cursor that arrived before this batch began + // shares one full-file atomic replace instead of queueing another + // rewrite of the same JSON document. + const next = { + ...versions, + ...Object.fromEntries(batchVersions), + }; + await fs.promises.mkdir(runtimeDataDir, { recursive: true }); + const tempPath = `${filePath}.${process.pid}.tmp`; + await fs.promises.writeFile( + tempPath, + `${JSON.stringify(next, null, 2)}\n`, + { encoding: 'utf8', mode: 0o600 }, + ); + await fs.promises.rename(tempPath, filePath); + // Publish to readers only after the atomic rename committed. + versions = next; + for (const waiter of batchWaiters) waiter.resolve(); + } catch (error) { + for (const waiter of batchWaiters) waiter.reject(error); + } + } + } finally { + drainScheduled = false; + // No await occurs between the loop's final emptiness check and this + // branch, but keep the reschedule guard explicit for future changes. + if (pendingVersions.size > 0) scheduleDrain(); + } + } + + function scheduleDrain(): void { + if (drainScheduled) return; + drainScheduled = true; + // One microtask of coalescing collects independent pulls that complete in + // the same event-loop turn without adding wall-clock delay. + void Promise.resolve().then(drainPendingVersions); + } + + return { + get(workspaceId, kind, resourceId) { + return versions[versionKey(workspaceId, kind, resourceId)] ?? null; + }, + set(workspaceId, kind, resourceId, versionId) { + const key = versionKey(workspaceId, kind, resourceId); + pendingVersions.set(key, versionId); + const committed = new Promise<void>((resolve, reject) => { + pendingWaiters.push({ resolve, reject }); + }); + scheduleDrain(); + return committed; + }, + }; +} diff --git a/apps/daemon/src/collab/team-share-scope.ts b/apps/daemon/src/collab/team-share-scope.ts new file mode 100644 index 00000000000..9987017195b --- /dev/null +++ b/apps/daemon/src/collab/team-share-scope.ts @@ -0,0 +1,171 @@ +// THE INVARIANT: a team share lives in a TEAM workspace. +// +// `workspace_projects.visibility = 'team'` means "this project is projected onto +// its workspace's TEAM plane". B has no standalone team id — the workspace id IS +// the team identity (`services/api/src/resources/auth.ts` resolves the workspace +// and answers `403 missing_principal` for any non-team one). A PERSONAL +// workspace therefore has no team plane to act on, and a row that claims +// `visibility: 'team'` while pinned to a personal workspace is not a scope at +// all: it is a permanently-broken address. Every project-scoped collab call it +// pins — presence heartbeat/list/leave, comments, publish — is answered +// `403 missing_principal`, forever, and silently. The two clients then address +// two different resource ids for the same project, so nothing syncs, presence is +// empty on both sides, and comment counts drift apart. +// +// This module is the ONE place that names the contradiction. Three call sites +// consume it: +// - the write path refuses to create such a row (routes/project/index.ts); +// - the read path refuses to PIN one, so the local selection — which normally +// holds the real team workspace — wins instead (server.ts presenceScopeFor); +// - startup reconciliation demotes the ones older builds already wrote. +// +// Detection needs one fact the row alone cannot carry: whether a workspace id is +// personal or team. Two independent sources answer that, and either is enough to +// refuse: +// 1. the caller's own assertion (`x-od-workspace-type`), and +// 2. the workspace directory B already serves the daemon (`typeOf` below). +// Both are allowed to be silent. An UNKNOWN workspace is never refused — this +// guard only ever fires on positive evidence that the target is personal, so it +// can never break a legitimate scope it simply has not learned about yet. + +import type { WorkspaceType } from '@open-design/contracts'; +import { resolveWorkspaceScope, type WorkspaceScope } from './workspace-scope.js'; + +/** Why a workspace cannot host a team share. Null means "no evidence against". */ +export type TeamShareScopeRefusal = 'asserted_personal' | 'directory_personal'; + +/** Minimal shape of anything that names a workspace and its type. Structural on + * purpose so both `WorkspaceDirectoryItem` and `WorkspaceCollabContext` fit. */ +export interface WorkspaceTypeFact { + workspaceId?: string | null; + workspaceType?: string | null; +} + +/** + * What the daemon has learned about each workspace's type. + * + * Deliberately synchronous and best-effort: it is a memo of facts that flowed + * past on reads the daemon already performs (the workspace directory the web + * fetches on every load, the workspace context the invalidation poller reads + * every 15s). Nothing here fetches. An id it has never seen answers `null`, and + * every consumer treats `null` as "no opinion". + */ +export interface WorkspaceTypeRegistry { + learn(facts: readonly WorkspaceTypeFact[] | WorkspaceTypeFact | null | undefined): void; + typeOf(workspaceId: string | null | undefined): WorkspaceType | null; + /** True ONLY on positive evidence that this workspace is personal. */ + isKnownPersonal(workspaceId: string | null | undefined): boolean; +} + +function normalizeId(workspaceId: string | null | undefined): string { + return typeof workspaceId === 'string' ? workspaceId.trim() : ''; +} + +function normalizeType(workspaceType: string | null | undefined): WorkspaceType | null { + return workspaceType === 'personal' || workspaceType === 'team' ? workspaceType : null; +} + +export function createWorkspaceTypeRegistry(): WorkspaceTypeRegistry { + const types = new Map<string, WorkspaceType>(); + const typeOf = (workspaceId: string | null | undefined): WorkspaceType | null => { + const id = normalizeId(workspaceId); + return id ? types.get(id) ?? null : null; + }; + return { + learn(facts) { + if (!facts) return; + for (const fact of Array.isArray(facts) ? facts : [facts as WorkspaceTypeFact]) { + const id = normalizeId(fact?.workspaceId); + const type = normalizeType(fact?.workspaceType); + if (!id || !type) continue; + types.set(id, type); + } + }, + typeOf, + // Bound, not a method: consumers routinely pass this around on its own. + isKnownPersonal: (workspaceId) => typeOf(workspaceId) === 'personal', + }; +} + +/** + * Can `workspaceId` host a team share? Returns the refusal reason, or null when + * there is no evidence against it (including "not enough information"). + * + * `assertedType` is the caller's own claim about the workspace it is acting in + * (the `x-od-workspace-type` request header). A caller that says "personal" and + * asks for a team share has stated the contradiction itself; that alone is + * grounds to refuse, without consulting anything else. + */ +export function refuseTeamShareScope( + workspaceId: string | null | undefined, + evidence: { + assertedType?: string | null; + registry?: Pick<WorkspaceTypeRegistry, 'isKnownPersonal'> | null; + } = {}, +): TeamShareScopeRefusal | null { + if (normalizeType(evidence.assertedType) === 'personal') return 'asserted_personal'; + if (evidence.registry?.isKnownPersonal(workspaceId)) return 'directory_personal'; + return null; +} + +/** + * The workspace scope for a PROJECT-scoped collab call. + * + * Same fixed priority as `resolveWorkspaceScope` (the project's pinned workspace + * outranks the local selection, so a workspace switch on another device cannot + * re-aim an open project's heartbeats) with one subtraction: a pinned workspace + * that provably cannot host a team share is not a scope and does not outrank + * anything. Refusals are reported through `onRefused` rather than swallowed — + * a silently wrong scope is precisely how this bug survived in the field. + */ +export function projectCollabScope(input: { + projectId?: string; + projectWorkspaceId: string | null | undefined; + localSelection: string | null | undefined; + registry?: Pick<WorkspaceTypeRegistry, 'isKnownPersonal'> | null; + onRefused?: (refusal: { + projectId?: string | undefined; + workspaceId: string; + reason: TeamShareScopeRefusal; + }) => void; +}): WorkspaceScope { + const pinned = normalizeId(input.projectWorkspaceId); + const reason = pinned + ? refuseTeamShareScope(pinned, { ...(input.registry ? { registry: input.registry } : {}) }) + : null; + if (pinned && reason) { + input.onRefused?.({ + ...(input.projectId ? { projectId: input.projectId } : {}), + workspaceId: pinned, + reason, + }); + } + return resolveWorkspaceScope({ + projectWorkspaceId: reason ? null : pinned, + localSelection: input.localSelection ?? null, + }); +} + +/** A `workspace_projects` row, as far as this invariant is concerned. */ +export interface TeamShareRow { + projectId?: string | null; + workspaceId?: string | null; + visibility?: string | null; +} + +/** + * The rows that violate the invariant and must be healed. + * + * Only `visibility: 'team'` rows are ever candidates — a `visibility: 'personal'` + * row in a personal workspace is the normal, correct shape and is never touched. + * A row whose workspace type is unknown is left alone too: healing acts on + * positive evidence only. + */ +export function impossibleTeamShareRows<T extends TeamShareRow>( + rows: readonly T[], + registry: Pick<WorkspaceTypeRegistry, 'isKnownPersonal'>, +): T[] { + return rows.filter( + (row) => row?.visibility === 'team' && registry.isKnownPersonal(row.workspaceId), + ); +} diff --git a/apps/daemon/src/collab/vela-cli-collab-client.ts b/apps/daemon/src/collab/vela-cli-collab-client.ts new file mode 100644 index 00000000000..ef6637aaa2d --- /dev/null +++ b/apps/daemon/src/collab/vela-cli-collab-client.ts @@ -0,0 +1,244 @@ +import type { + CollabCloudComment, + CollabCloudMemberDirectoryEntry, + CollabMemberRole, + CollabPresenceMember, +} from '@open-design/contracts'; +import { + runVelaCommand, + velaWorkspaceCommandOptions, +} from '../integrations/vela-command.js'; + +export type RunVelaCollab = ( + args: string[], + workspaceId?: string, +) => Promise<string>; + +export interface VelaCliCollabClientOptions { + run?: RunVelaCollab; +} + +type MemberWire = { + memberId?: unknown; + displayName?: unknown; + role?: unknown; + avatarUrl?: unknown; +}; + +type PresenceWire = MemberWire & { + filePath?: unknown; + activity?: unknown; + heartbeatAt?: unknown; +}; + +type PullCommentsWire = { + comments?: unknown; + latestSeq?: unknown; +}; + +export interface VelaCliPresenceHeartbeatInput { + member: CollabPresenceMember; + clientId?: string; + filePath?: string | null; + activity?: CollabPresenceMember['activity']; +} + +export interface VelaCliPresenceLeaveInput { + memberId: string; + clientId?: string; +} + +type PresenceActivity = Exclude<CollabPresenceMember['activity'], undefined>; + +export function createVelaCliCollabClient(options: VelaCliCollabClientOptions = {}) { + const run = options.run ?? defaultRunVelaCollab; + + async function runJson<T>(args: string[], workspaceId: string): Promise<T> { + const requestedWorkspaceId = workspaceId.trim(); + if (!requestedWorkspaceId) { + throw new Error('explicit workspace scope is required'); + } + const stdout = await run(args, requestedWorkspaceId); + const trimmed = stdout.trim(); + if (!trimmed) return {} as T; + return JSON.parse(trimmed) as T; + } + + return { + isConfigured(): boolean { + return true; + }, + + async registerMember( + _teamId: string, + _memberId: string, + input: { displayName: string; role: CollabMemberRole }, + ): Promise<CollabCloudMemberDirectoryEntry> { + const args = ['member', 'register', '--display-name', input.displayName, '--role', input.role]; + const payload = await runJson<{ member?: MemberWire }>(args, _teamId); + return toDirectoryEntry(payload.member); + }, + + async listMembers(_teamId: string): Promise<CollabCloudMemberDirectoryEntry[]> { + const payload = await runJson<{ members?: MemberWire[] }>( + ['member', 'list'], + _teamId, + ); + return Array.isArray(payload.members) ? payload.members.map(toDirectoryEntry) : []; + }, + + async pushComment( + _teamId: string, + projectId: string, + comment: CollabCloudComment, + ): Promise<{ seq: number }> { + const payload = await runJson<{ seq?: unknown }>([ + 'comment', + 'push', + projectId, + '--comment-json', + JSON.stringify(comment), + ], _teamId); + return { seq: typeof payload.seq === 'number' ? payload.seq : 0 }; + }, + + async pullComments( + _teamId: string, + projectId: string, + sinceSeq: number, + ): Promise<{ + comments: CollabCloudComment[]; + latestSeq: number; + notModified: boolean; + etag: string | null; + }> { + const payload = await runJson<PullCommentsWire>([ + 'comment', + 'pull', + projectId, + '--since-seq', + String(sinceSeq), + ], _teamId); + const comments = Array.isArray(payload.comments) + ? (payload.comments as CollabCloudComment[]) + : []; + return { + comments, + latestSeq: typeof payload.latestSeq === 'number' ? payload.latestSeq : sinceSeq, + notModified: comments.length === 0, + etag: null, + }; + }, + + async heartbeatPresence( + projectId: string, + input: VelaCliPresenceHeartbeatInput, + workspaceId: string, + ): Promise<CollabPresenceMember[]> { + const args = [ + 'presence', + 'heartbeat', + projectId, + '--client-id', + input.clientId ?? input.member.memberId, + ]; + const displayName = input.member.name?.trim(); + if (displayName) args.push('--display-name', displayName); + if (input.filePath) args.push('--file-path', input.filePath); + if (input.activity !== undefined && input.activity !== null) { + args.push('--activity-json', JSON.stringify(input.activity)); + } + const payload = await runJson<{ viewers?: PresenceWire[] }>(args, workspaceId); + return Array.isArray(payload.viewers) ? payload.viewers.map(toPresenceMember) : []; + }, + + async listPresence(projectId: string, workspaceId: string): Promise<CollabPresenceMember[]> { + const payload = await runJson<{ viewers?: PresenceWire[] }>([ + 'presence', + 'list', + projectId, + ], workspaceId); + return Array.isArray(payload.viewers) ? payload.viewers.map(toPresenceMember) : []; + }, + + async leavePresence( + projectId: string, + input: VelaCliPresenceLeaveInput, + workspaceId: string, + ): Promise<CollabPresenceMember[]> { + const payload = await runJson<{ viewers?: PresenceWire[] }>([ + 'presence', + 'leave', + projectId, + '--client-id', + input.clientId ?? input.memberId, + ], workspaceId); + return Array.isArray(payload.viewers) ? payload.viewers.map(toPresenceMember) : []; + }, + }; +} + +export type VelaCliCollabClient = ReturnType<typeof createVelaCliCollabClient>; + +function toDirectoryEntry(input: MemberWire | undefined): CollabCloudMemberDirectoryEntry { + const memberId = typeof input?.memberId === 'string' ? input.memberId : ''; + const displayName = + typeof input?.displayName === 'string' && input.displayName.trim() + ? input.displayName + : memberId; + const role = isRole(input?.role) ? input.role : 'member'; + return { memberId, displayName, role }; +} + +function toPresenceMember(input: PresenceWire): CollabPresenceMember { + const member: CollabPresenceMember = { + memberId: typeof input.memberId === 'string' ? input.memberId : '', + role: isRole(input.role) ? input.role : 'member', + }; + if (typeof input.displayName === 'string' && input.displayName.trim()) { + member.name = input.displayName.trim(); + } + if (typeof input.avatarUrl === 'string' || input.avatarUrl === null) { + member.avatarUrl = input.avatarUrl; + } + if (typeof input.filePath === 'string' || input.filePath === null) { + member.filePath = input.filePath; + } + if (input.activity !== undefined) { + member.activity = input.activity as PresenceActivity; + } + if (typeof input.heartbeatAt === 'string') { + member.heartbeatAt = input.heartbeatAt; + } + return member; +} + +function isRole(value: unknown): value is CollabMemberRole { + return value === 'owner' || value === 'admin' || value === 'member'; +} + +const defaultRunVelaCollab: RunVelaCollab = (args, workspaceId) => + runVelaCommand( + ['collab', ...args], + velaWorkspaceCommandOptions(workspaceId), + ); + +export function shouldUseVelaCliCollabTransport( + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (env.OD_WORKSPACE_CONTEXT_SOURCE?.trim() === 'vela') return true; + const explicitTransport = env.OD_COLLAB_TRANSPORT?.trim(); + if (explicitTransport) return explicitTransport === 'vela-cli'; + if (env.OD_COLLAB_CLOUD_URL?.trim()) return false; + return env.OD_TEAM_PROJECTS_TRANSPORT?.trim() === 'vela-cli' || + env.OD_RESOURCE_TRANSPORT?.trim() === 'vela-cli'; +} + +export function createVelaCliCollabClientFromEnv( + env: NodeJS.ProcessEnv = process.env, + options: Omit<VelaCliCollabClientOptions, 'run'> = {}, +): VelaCliCollabClient | null { + return shouldUseVelaCliCollabTransport(env) + ? createVelaCliCollabClient(options) + : null; +} diff --git a/apps/daemon/src/collab/vela-cli-resource-adapter.ts b/apps/daemon/src/collab/vela-cli-resource-adapter.ts new file mode 100644 index 00000000000..86d87765183 --- /dev/null +++ b/apps/daemon/src/collab/vela-cli-resource-adapter.ts @@ -0,0 +1,345 @@ +import { + workspaceContextHasTeamIdentity, + type WorkspaceCollabContext, +} from '@open-design/contracts'; +import { + runVelaCommand, + velaWorkspaceCommandOptions, +} from '../integrations/vela-command.js'; +import { projectResourceIdFor } from '../integrations/vela-team-projects.js'; +import type { ResourcePublishAdapter } from './publish-scheduler.js'; +import { + emitVelaResourcePullProfile, + sharedProjectPullProfileEnabled, +} from './pull-profile.js'; +import type { ResourceHubPrincipal } from './resource-principal.js'; + +// The `vela resource` transport for the publish/pull machinery (T7c). Instead of +// the daemon holding an internal token and driving the hub over HTTP itself, it +// shells out to `vela resource push/head/pull`, which authenticates with the same +// vela login session AMR uses — one identity, and the content-addressing lives in +// the vela CLI so any vela-embedding project shares the exact same code path. +// +// This is a drop-in ResourcePublishAdapter selected by the collaboration mode. +// The child process is injectable so the wiring is unit-tested without a live +// CLI or hub. + +const PUBLISHED_REF = 'published'; +const PROJECT_KIND = 'project'; +// A normal feature-test pull is expected to finish in roughly five seconds. +// Give the transport a generous 6x envelope for large snapshots, but never +// let a wedged Vela child hold the per-project materialization lock forever. +const RESOURCE_PULL_TIMEOUT_MS = 30_000; +const MEMBER_MIRROR_EXCLUDED_ENTRIES = [ + '.file-versions', + '.live-artifacts', + '.od-skills', + '.git', + 'node_modules', + '.npmrc', + '.yarnrc', + '.yarnrc.yml', + '.aws', + '.ssh', + '.azure', + '.docker', + '.gnupg', + '.kube', + '.pulumi', + '.terraform', + '.git-credentials', + '.netrc', + '.pypirc', + 'terraform.tfstate', + 'terraform.tfstate.backup', +] as const; +const MEMBER_MIRROR_EXCLUDED_PREFIXES = ['.env'] as const; + +/** Run `vela resource <args>` and resolve its stdout. */ +export type RunVelaResource = ( + args: string[], + workspaceId?: string, +) => Promise<string>; + +export interface VelaCliResourceAdapterOptions { + /** The project's source directory to publish (managed-project root). */ + resolveProjectDir: (projectId: string) => string | Promise<string>; + /** Optional resource-index metadata for team project discovery/cards. */ + describeProject?: (projectId: string) => Record<string, unknown> | null | Promise<Record<string, unknown> | null>; + /** Where a member materializes pulled content. Defaults to the project dir. */ + resolvePullDir?: (projectId: string) => string | Promise<string>; + /** (projectId, principal) → hub resourceId. Colon-free (routed as a path param). */ + resourceIdFor?: (projectId: string, principal?: ResourceHubPrincipal | null) => string; + /** Hub resource kind (project / design_system / plugin / skill). */ + kind?: string; + /** + * Whether the caller currently has a team identity. Null/false → no-op, the + * same single-identity gate the SDK adapter applies, so a personal / signed-out + * session never publishes. The CLI itself resolves the concrete member/team + * from the vela session; this only gates whether we invoke it at all. + */ + hasTeamIdentity: ( + principal?: ResourceHubPrincipal | null, + ) => boolean | Promise<boolean>; + /** Injectable child-process runner; defaults to spawning the vela binary. */ + run?: RunVelaResource; +} + +interface VelaVersionRecord { + id?: string; + version?: number; + versionId?: string; +} + +export interface VelaResourceSnapshotRecord { + slug: string; + name: string; + kind: string; + versionId: string; + createdAt: string; +} + +export function createVelaCliResourceAdapter( + options: VelaCliResourceAdapterOptions, +): ResourcePublishAdapter { + const resolvePullDir = options.resolvePullDir ?? options.resolveProjectDir; + const resourceIdFor = options.resourceIdFor ?? projectResourceIdFor; + const kind = options.kind ?? PROJECT_KIND; + const run = options.run ?? defaultRunVelaResource; + + async function gated<T>( + principal: ResourceHubPrincipal | null | undefined, + fn: () => Promise<T>, + fallback: T, + ): Promise<T> { + return (await options.hasTeamIdentity(principal)) ? fn() : fallback; + } + + function resourceIdsFor(projectId: string, principal?: ResourceHubPrincipal | null): string[] { + const primary = resourceIdFor(projectId, principal); + if (!principal) return [primary]; + const legacy = resourceIdFor(projectId, null); + return legacy === primary ? [primary] : [primary, legacy]; + } + + return { + publish({ projectId, principal }) { + return gated(principal, async () => { + const dir = await options.resolveProjectDir(projectId); + const args = ['push', kind, resourceIdFor(projectId, principal), dir, '--ref', PUBLISHED_REF, '--json']; + for (const name of MEMBER_MIRROR_EXCLUDED_ENTRIES) { + args.push('--exclude', name); + } + for (const prefix of MEMBER_MIRROR_EXCLUDED_PREFIXES) { + args.push('--exclude-prefix', prefix); + } + const metadata = await options.describeProject?.(projectId); + const resourceMetadata = kind === PROJECT_KIND + ? { projectId, ...(metadata ?? {}) } + : metadata; + if (resourceMetadata && Object.keys(resourceMetadata).length > 0) { + args.push('--metadata-json', JSON.stringify(resourceMetadata)); + } + const out = await run(args, principal?.teamId); + return parseVersion(out); + }, null); + }, + + syncLatest({ projectId, principal }) { + return gated(principal, async () => { + // `head` reports the published version without downloading — a null + // version means nothing is published yet. + for (const resourceId of resourceIdsFor(projectId, principal)) { + const out = await run( + ['head', resourceId, '--ref', PUBLISHED_REF, '--json'], + principal?.teamId, + ); + const version = parseVersion(out); + if (version != null) return version; + } + return null; + }, null); + }, + + async pull({ projectId, principal }) { + return gated(principal, async () => { + const dir = await resolvePullDir(projectId); + let lastError: unknown; + const resourceIds = resourceIdsFor(projectId, principal); + for (const [index, resourceId] of resourceIds.entries()) { + try { + const out = await run( + ['pull', kind, resourceId, dir, '--ref', PUBLISHED_REF, '--json'], + principal?.teamId, + ); + const materialized = parseVersion(out); + if (!materialized) { + throw new Error( + 'vela resource pull response is missing the materialized version', + ); + } + return materialized; + } catch (error) { + lastError = error; + if ( + index === resourceIds.length - 1 || + !isMissingResourceError(error) + ) throw error; + } + } + throw lastError; + }, null); + }, + + async unpublish({ projectId, principal }) { + await gated(principal, async () => { + try { + await run( + ['remove', resourceIdFor(projectId, principal), '--json'], + principal?.teamId, + ); + } catch (error) { + // Unpublish is a retraction toward one end state: "the hub no + // longer serves this resource". A hub answer that the resource is + // already absent IS that end state, so it must read as success. + // Propagating it made retraction non-idempotent: an unshare whose + // two hub writes (resource remove → team-projects catalog remove) + // half-landed could then NEVER be completed — every retry died + // re-removing the already-tombstoned resource, the catalog row + // outlived it, and after a reinstall the retracted project revived + // as a ghost team card (reproduced live on the feature-test hub, + // 2026-07-27; 飞书 recvqA6qhV7St1). + if (!isRetractedHubResourceError(error)) throw error; + } + }, undefined); + }, + }; +} + +function isMissingResourceError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /resource_not_found|status\s+404|ref_not_found/u.test(message); +} + +/** + * True when the hub itself answered `resource_not_found` — its tombstone gate + * refuses every resource-scoped call once `resources.deleted_at` is set (and + * answers the same for an id that never existed). Deliberately NARROWER than + * {@link isMissingResourceError}: `ref_not_found` / bare-404 shapes can mean + * "live resource with nothing published yet", which must never be read as + * "this resource was retracted". + */ +export function isRetractedHubResourceError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /resource_not_found/u.test(message); +} + +/** Parse the `version` field out of a `vela resource` --json line. Returns null + * when the field is absent or explicitly null (e.g. `head` on an unpublished + * resource), so callers treat "nothing published" as a clean empty result. */ +function parseVersion( + stdout: string, +): { version: number; versionId?: string } | null { + const trimmed = stdout.trim(); + if (!trimmed) return null; + const parsed = JSON.parse(trimmed) as VelaVersionRecord; + if (parsed.version == null) return null; + if (typeof parsed.version !== 'number') { + throw new Error('vela resource response has an invalid version'); + } + const versionId = typeof parsed.versionId === 'string' && parsed.versionId.trim() + ? parsed.versionId.trim() + : typeof parsed.id === 'string' && parsed.id.trim() + ? parsed.id.trim() + : null; + return { + version: parsed.version, + ...(versionId ? { versionId } : {}), + }; +} + +export function parseVelaResourceSnapshot(stdout: string): VelaResourceSnapshotRecord | null { + const trimmed = stdout.trim(); + if (!trimmed) return null; + try { + const parsed = JSON.parse(trimmed) as Partial<VelaResourceSnapshotRecord>; + return typeof parsed.slug === 'string' && parsed.slug + ? { + slug: parsed.slug, + name: typeof parsed.name === 'string' ? parsed.name : '', + kind: typeof parsed.kind === 'string' ? parsed.kind : '', + versionId: typeof parsed.versionId === 'string' ? parsed.versionId : '', + createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : '', + } + : null; + } catch { + return null; + } +} + +export const runVelaResourceCommand: RunVelaResource = (args, workspaceId) => { + const workspaceOptions = velaWorkspaceCommandOptions(workspaceId); + const profilePull = + args[0] === 'pull' && sharedProjectPullProfileEnabled(process.env); + return runVelaCommand( + ['resource', ...args], + { + ...workspaceOptions, + configuredEnv: { + ...workspaceOptions.configuredEnv, + ...(profilePull ? { VELA_RESOURCE_PULL_PROFILE: '1' } : {}), + }, + ...(args[0] === 'pull' ? { timeoutMs: RESOURCE_PULL_TIMEOUT_MS } : {}), + ...(profilePull + ? { + onStderr: (stderr: string) => + emitVelaResourcePullProfile(stderr, process.env), + } + : {}), + }, + ); +}; + +const defaultRunVelaResource: RunVelaResource = runVelaResourceCommand; + +/** + * Whether this run should drive resource sharing through the `vela resource` CLI + * transport instead of the in-process SDK. An explicit `OD_RESOURCE_TRANSPORT` + * wins; otherwise the Vela-backed team/collab modes imply the same CLI identity + * for bytes so the daemon does not publish catalog rows through Vela while + * leaving project content on the local stub. + */ +export function shouldUseVelaCliResourceTransport(env: NodeJS.ProcessEnv = process.env): boolean { + if (env.OD_WORKSPACE_CONTEXT_SOURCE?.trim() === 'vela') return true; + const explicitTransport = env.OD_RESOURCE_TRANSPORT?.trim(); + if (explicitTransport) return explicitTransport === 'vela-cli'; + return env.OD_TEAM_PROJECTS_TRANSPORT?.trim() === 'vela-cli' || + env.OD_COLLAB_TRANSPORT?.trim() === 'vela-cli'; +} + +/** + * Derive the resource-identity gate from the one workspace context: does this + * daemon's live vela session currently belong to an ACTIVE member of the + * project's team? + * + * `workspaceContextHasTeamIdentity` alone is not enough here. It only proves + * the context can ADDRESS the resource hub — `workspaceType`/`workspaceId`/ + * `workspaceMemberId` all resolve — and those fields keep resolving for a + * member B has already removed from the team; only `memberStatus` flips to + * `'removed'`. The publish/pull/syncLatest/unpublish operations this gates all + * shell out to `vela resource …`, authenticated by the same vela CLI login + * session AMR uses, which does not itself re-derive OD's team membership per + * call. Without the explicit `memberStatus` check below, a member removed + * from a team while their daemon keeps running would keep passing this gate + * on every project they used to own, and the file watcher in + * `collab-publish-watcher.ts` would keep pushing their local edits to the + * team's resource hub through a vela session that is still locally valid. + * + * `hasTeamIdentity` is re-evaluated fresh on every publish/pull/syncLatest/ + * unpublish attempt. The runtime supplies the immutable principal captured by + * the request or project watch; it never re-targets through daemon-global + * active Workspace state. + */ +export function contextHasTeamIdentity(context: WorkspaceCollabContext | null): boolean { + return workspaceContextHasTeamIdentity(context) && context?.memberStatus === 'active'; +} diff --git a/apps/daemon/src/collab/vela-cli-team-projects.ts b/apps/daemon/src/collab/vela-cli-team-projects.ts new file mode 100644 index 00000000000..6f1acfe8fb6 --- /dev/null +++ b/apps/daemon/src/collab/vela-cli-team-projects.ts @@ -0,0 +1,604 @@ +import type { ProjectMetadata, TeamProject } from '@open-design/contracts'; +import type { + UpsertVelaTeamProjectInput, + VelaTeamProjectCatalogClient, + VelaTeamProjectRecord, + VelaTeamProjectSyncState, +} from '../integrations/vela-team-projects.js'; +import { + runVelaCommand, + velaWorkspaceCommandOptions, +} from '../integrations/vela-command.js'; +import type { ResourceHubPrincipal } from './resource-principal.js'; +import { createSwrCache, type SwrCache } from './swr-cache.js'; + +const PROJECT_RESOURCE_PREFIX = 'project-'; + +export type RunVelaTeamProjects = ( + args: string[], + workspaceId?: string, +) => Promise<string>; +export type RunVelaResources = ( + args: string[], + workspaceId?: string, +) => Promise<string>; + +interface VelaCliTeamProjectCatalogOptions { + run?: RunVelaTeamProjects; + runResource?: RunVelaResources; + supportsTeamProjects?: () => boolean | Promise<boolean>; +} + +export interface VelaTeamProjectCatalog { + list(workspaceId: string): Promise<TeamProject[]>; + get(projectId: string, workspaceId: string): Promise<TeamProject | null>; + upsert(input: { + projectId: string; + resourceId?: string; + displayName?: string | null; + syncState?: 'pending_upload' | 'syncing' | 'synced' | 'failed'; + lastSyncedVersionId?: string | null; + metadata?: Record<string, unknown> | null; + }, principal?: ResourceHubPrincipal | null): Promise<void>; + remove(projectId: string, principal?: ResourceHubPrincipal | null): Promise<void>; +} + +type TeamProjectWire = { + projectId?: unknown; + resourceId?: unknown; + ownerMemberId?: unknown; + displayName?: unknown; + syncState?: unknown; + lastSyncedVersionId?: unknown; + metadata?: unknown; + createdAt?: unknown; + updatedAt?: unknown; +}; + +type TeamProjectsListWire = { + workspaceId?: unknown; + projects?: unknown; +}; + +type SharedResourceWire = { + id: string; + teamId: string; + kind: 'project'; + ownerMemberId: string; + metadata?: unknown; + createdAt: string; + deletedAt?: null; +}; + +type SharedResourcesListWire = { + resources?: unknown; +}; + +export function projectResourceId(projectId: string): string { + return `${PROJECT_RESOURCE_PREFIX}${projectId}`; +} + +export function createVelaCliTeamProjectCatalog( + options: VelaCliTeamProjectCatalogOptions = {}, +): VelaTeamProjectCatalog { + const run = options.run ?? defaultRunVelaTeamProjects; + const runResource = options.runResource ?? defaultRunVelaResources; + const supportsTeamProjects = createTeamProjectsCapabilityCheck( + run, + options.supportsTeamProjects, + ); + + async function runJson<T>( + args: string[], + workspaceId: string, + ): Promise<T> { + const stdout = await run(args, workspaceId); + const trimmed = stdout.trim(); + if (!trimmed) return {} as T; + return JSON.parse(trimmed) as T; + } + + async function list(workspaceId: string): Promise<TeamProject[]> { + const resolvedWorkspaceId = requireWorkspaceId(workspaceId); + if (!(await supportsTeamProjects(resolvedWorkspaceId))) { + const resources = await listSharedProjectResources( + runResource, + resolvedWorkspaceId, + ); + return resources.map(toFallbackTeamProject); + } + const stdout = await run(['list'], resolvedWorkspaceId); + const payload = stdout.trim() + ? JSON.parse(stdout.trim()) as TeamProjectsListWire + : {}; + return Array.isArray(payload.projects) + ? payload.projects.map(toTeamProject).filter((project): project is TeamProject => project != null) + : []; + } + + let exactLookupUnavailable = false; + + return { + list, + + async get(projectId, workspaceId): Promise<TeamProject | null> { + const resolvedWorkspaceId = requireWorkspaceId(workspaceId); + if (exactLookupUnavailable) { + return (await list(resolvedWorkspaceId)) + .find((project) => project.projectId === projectId) ?? null; + } + + try { + const payload = await runJson<TeamProjectWire>([ + 'get', + projectId, + '--json', + ], resolvedWorkspaceId); + return toTeamProject(payload); + } catch (error) { + if (isAuthoritativeTeamProjectNotFound(error)) return null; + if (!isExactTeamProjectLookupUnavailable(error)) throw error; + exactLookupUnavailable = true; + return (await list(resolvedWorkspaceId)) + .find((project) => project.projectId === projectId) ?? null; + } + }, + + async upsert(input, principal): Promise<void> { + const workspaceId = requirePrincipalWorkspaceId(principal); + // Older packaged Vela builds expose only the resource index. The project + // push writes the same discovery metadata there, so no second catalog + // write is required in compatibility mode. + if (!(await supportsTeamProjects(workspaceId))) return; + const args = [ + 'upsert', + input.projectId, + '--resource-id', + input.resourceId ?? projectResourceId(input.projectId), + ]; + if (input.displayName?.trim()) args.push('--display-name', input.displayName.trim()); + if (input.syncState) args.push('--sync-state', input.syncState); + if (input.lastSyncedVersionId?.trim()) { + args.push('--last-synced-version-id', input.lastSyncedVersionId.trim()); + } + if (input.metadata && Object.keys(input.metadata).length > 0) { + args.push('--metadata-json', JSON.stringify(input.metadata)); + } + await run(args, workspaceId); + }, + + async remove(projectId, principal): Promise<void> { + const workspaceId = requirePrincipalWorkspaceId(principal); + // The resource adapter removes the resource-index row in compatibility + // mode; there is no separate catalog row to delete. + if (!(await supportsTeamProjects(workspaceId))) return; + await run(['remove', projectId], workspaceId); + }, + }; +} + +export function createVelaCliTeamProjectCatalogClient( + options: VelaCliTeamProjectCatalogOptions = {}, +): VelaTeamProjectCatalogClient { + const run = options.run ?? defaultRunVelaTeamProjects; + const runResource = options.runResource ?? defaultRunVelaResources; + const supportsTeamProjects = createTeamProjectsCapabilityCheck( + run, + options.supportsTeamProjects, + ); + + async function runJson<T>(args: string[], workspaceId: string): Promise<T> { + const stdout = await run(args, workspaceId); + const trimmed = stdout.trim(); + if (!trimmed) return {} as T; + return JSON.parse(trimmed) as T; + } + + return { + async list(principal): Promise<VelaTeamProjectRecord[]> { + // Capture before the first await. Capability detection may itself await, + // during which another tab can switch the daemon's active Workspace. + const workspaceId = principal.teamId.trim(); + if (!workspaceId) throw new Error('explicit workspace scope is required'); + if (!(await supportsTeamProjects(workspaceId))) { + const resources = await listSharedProjectResources( + runResource, + workspaceId, + ); + const records = resources.map(toFallbackVelaTeamProjectRecord); + if (records.some((record) => record.workspaceId !== workspaceId)) { + throw new Error('incomplete team project catalog: workspace mismatch'); + } + return records; + } + const payload = await runJson<TeamProjectsListWire>(['list'], workspaceId); + if (!Array.isArray(payload.projects)) { + throw new Error('incomplete team project catalog: projects are missing'); + } + const records = payload.projects.map((project) => + toVelaTeamProjectRecord(project), + ); + if (records.some((project) => project == null)) { + throw new Error('incomplete team project catalog: invalid project row'); + } + const completeRecords = records as VelaTeamProjectRecord[]; + if (completeRecords.some((project) => project.workspaceId !== workspaceId)) { + throw new Error('incomplete team project catalog: workspace mismatch'); + } + return completeRecords; + }, + + async upsert( + input: UpsertVelaTeamProjectInput, + principal, + ): Promise<VelaTeamProjectRecord | null> { + const workspaceId = principal.teamId.trim(); + if (!workspaceId) throw new Error('explicit workspace scope is required'); + // See the catalog adapter above: resource push owns the fallback index. + if (!(await supportsTeamProjects(workspaceId))) return null; + const args = [ + 'upsert', + input.projectId, + '--resource-id', + input.resourceId, + ]; + if (input.displayName?.trim()) args.push('--display-name', input.displayName.trim()); + if (input.syncState) args.push('--sync-state', input.syncState); + if (input.lastSyncedVersionId?.trim()) { + args.push('--last-synced-version-id', input.lastSyncedVersionId.trim()); + } + const stdout = await run(args, workspaceId); + return toVelaTeamProjectRecord(JSON.parse(stdout.trim()) as unknown); + }, + }; +} + +/** + * Short-lived read cache for request-scoped project catalogs. + * + * One cache instance is created per complete principal identity. The fetcher + * closes over that immutable principal, so a daemon-wide Workspace switch can + * neither change the request sent to Vela nor place B's response in A's entry. + */ +export function createScopedVelaTeamProjectCatalogClientCache( + client: VelaTeamProjectCatalogClient, + freshMs = 3000, +): VelaTeamProjectCatalogClient { + const lists = new Map<string, SwrCache<VelaTeamProjectRecord[]>>(); + const scopeKey = (principal: ResourceHubPrincipal): string => + JSON.stringify([ + principal.teamId, + principal.memberId, + principal.role, + principal.lifecycleState, + principal.workspaceType ?? null, + ]); + + return { + list(principal) { + const key = scopeKey(principal); + let list = lists.get(key); + if (!list) { + const capturedPrincipal = { ...principal }; + list = createSwrCache( + () => client.list(capturedPrincipal), + () => key, + freshMs, + ); + lists.set(key, list); + } + return list(); + }, + upsert: (input, principal) => client.upsert(input, principal), + }; +} + +export function createVelaCliTeamProjectCatalogClientFromEnv( + options: VelaCliTeamProjectCatalogOptions = {}, +): VelaTeamProjectCatalogClient | null { + return shouldUseVelaCliTeamProjectCatalog() + ? createVelaCliTeamProjectCatalogClient(options) + : null; +} + +export function createVelaCliTeamProjectCatalogFromEnv( + options: VelaCliTeamProjectCatalogOptions = {}, +): VelaTeamProjectCatalog | null { + return shouldUseVelaCliTeamProjectCatalog() + ? createVelaCliTeamProjectCatalog(options) + : null; +} + +export function shouldUseVelaCliTeamProjectCatalog(env: NodeJS.ProcessEnv = process.env): boolean { + if (env.OD_WORKSPACE_CONTEXT_SOURCE?.trim() === 'vela') return true; + const explicitTransport = env.OD_TEAM_PROJECTS_TRANSPORT?.trim(); + if (explicitTransport) return explicitTransport === 'vela-cli'; + return env.OD_RESOURCE_TRANSPORT?.trim() === 'vela-cli'; +} + +function toTeamProject(input: unknown): TeamProject | null { + if (!input || typeof input !== 'object') return null; + const record = input as TeamProjectWire; + // A catalog row is discoverable only after project bytes are durable in the + // resource hub. Older local data may still contain pending rows from the + // previous fire-and-forget share flow; hide them so teammates do not open + // empty project shells. + if (typeof record.syncState === 'string' && record.syncState !== 'synced') { + return null; + } + if ( + typeof record.projectId !== 'string' || + typeof record.ownerMemberId !== 'string' || + typeof record.createdAt !== 'string' + ) { + return null; + } + const project: TeamProject = { + projectId: record.projectId, + ownerMemberId: record.ownerMemberId, + sharedAt: record.createdAt, + }; + if (typeof record.displayName === 'string' && record.displayName.trim()) { + project.name = record.displayName.trim(); + } + const metadata = recordObject(record.metadata); + if (metadata) { + if (typeof metadata.skillId === 'string') project.skillId = metadata.skillId; + if (typeof metadata.designSystemId === 'string') project.designSystemId = metadata.designSystemId; + const projectMetadata = recordObject(metadata.metadata); + if (projectMetadata) project.metadata = projectMetadata as unknown as ProjectMetadata; + if (typeof metadata.createdAt === 'number') project.createdAt = metadata.createdAt; + if (typeof metadata.updatedAt === 'number') project.updatedAt = metadata.updatedAt; + } + if (typeof record.updatedAt === 'string') { + const updatedAt = Date.parse(record.updatedAt); + if (Number.isFinite(updatedAt) && project.updatedAt === undefined) project.updatedAt = updatedAt; + } + const createdAt = Date.parse(record.createdAt); + if (Number.isFinite(createdAt) && project.createdAt === undefined) project.createdAt = createdAt; + return project; +} + +function toVelaTeamProjectRecord(input: unknown): VelaTeamProjectRecord | null { + if (!input || typeof input !== 'object') return null; + const record = input as TeamProjectWire & { + id?: unknown; + workspaceId?: unknown; + access?: unknown; + lastSyncedVersionId?: unknown; + }; + if ( + typeof record.id !== 'string' || + typeof record.workspaceId !== 'string' || + typeof record.projectId !== 'string' || + typeof record.resourceId !== 'string' || + typeof record.ownerMemberId !== 'string' || + typeof record.syncState !== 'string' || + typeof record.createdAt !== 'string' || + typeof record.updatedAt !== 'string' + ) { + return null; + } + const access = record.access && typeof record.access === 'object' && !Array.isArray(record.access) + ? record.access as Partial<VelaTeamProjectRecord['access']> + : {}; + return { + id: record.id, + workspaceId: record.workspaceId, + projectId: record.projectId, + resourceId: record.resourceId, + ownerMemberId: record.ownerMemberId, + displayName: typeof record.displayName === 'string' ? record.displayName : null, + syncState: toVelaSyncState(record.syncState), + lastSyncedVersionId: typeof record.lastSyncedVersionId === 'string' ? record.lastSyncedVersionId : null, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + access: { + canView: access.canView ?? true, + canComment: access.canComment ?? true, + canEdit: access.canEdit ?? false, + frozen: access.frozen ?? false, + }, + }; +} + +function toVelaSyncState(value: string): VelaTeamProjectSyncState { + if (value === 'syncing' || value === 'synced' || value === 'failed') return value; + return 'pending_upload'; +} + +function recordObject(value: unknown): Record<string, unknown> | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record<string, unknown> + : null; +} + +function isAuthoritativeTeamProjectNotFound(error: unknown): boolean { + return errorMessage(error).includes('team_project_not_found'); +} + +/** + * Compatibility is deliberately narrow. Only a CLI that lacks `get` (or its + * `--json` flag), or a pre-endpoint API returning an untyped 404, may fall back + * to the full catalog. Auth, permission, server, and network failures remain + * hard failures so a pull can never authorize itself from stale/partial data. + */ +function isExactTeamProjectLookupUnavailable(error: unknown): boolean { + const message = errorMessage(error); + if (isAuthoritativeTeamProjectNotFound(error)) return false; + return /unknown command ["']?(?:get|team-projects)["']?/i.test(message) || + /unknown flag:\s*--json/i.test(message) || + /API request failed with status 404(?!\s*:)/i.test(message); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function createTeamProjectsCapabilityCheck( + run: RunVelaTeamProjects, + injected?: () => boolean | Promise<boolean>, +): (workspaceId: string) => Promise<boolean> { + // The packaged dependency can lag the source-built CLI. Probe once per + // adapter so source builds use the richer catalog while older builds retain + // resource-index discovery without a version pin. + let result: Promise<boolean> | null = null; + return (workspaceId) => { + const exactWorkspaceId = requireWorkspaceId(workspaceId); + if (!injected && run === defaultRunVelaTeamProjects) { + defaultTeamProjectsCapability ??= run( + ['--help'], + exactWorkspaceId, + ).then( + () => true, + () => false, + ); + return defaultTeamProjectsCapability; + } + result ??= injected + ? Promise.resolve().then(injected) + : run(['--help'], exactWorkspaceId).then( + () => true, + () => false, + ); + return result; + }; +} + +let defaultTeamProjectsCapability: Promise<boolean> | null = null; + +async function listSharedProjectResources( + runResource: RunVelaResources, + workspaceId: string, +): Promise<SharedResourceWire[]> { + const stdout = await runResource(['shared', '--json'], workspaceId); + const trimmed = stdout.trim(); + if (!trimmed) { + throw new Error('incomplete shared project catalog: empty response'); + } + const payload = JSON.parse(trimmed) as SharedResourcesListWire; + if (!Array.isArray(payload.resources)) { + throw new Error('incomplete shared project catalog: resources are missing'); + } + const projectRows = payload.resources.filter( + (value) => + Boolean(value) + && typeof value === 'object' + && (value as Record<string, unknown>).kind === 'project', + ); + if (projectRows.some((value) => !isSharedProjectResource(value))) { + throw new Error('incomplete shared project catalog: invalid project row'); + } + return projectRows as SharedResourceWire[]; +} + +function requireWorkspaceId(workspaceId: string): string { + const exactWorkspaceId = workspaceId.trim(); + if (!exactWorkspaceId) { + throw new Error('explicit workspace scope is required'); + } + return exactWorkspaceId; +} + +function requirePrincipalWorkspaceId( + principal: ResourceHubPrincipal | null | undefined, +): string { + return requireWorkspaceId(principal?.teamId ?? ''); +} + +function isSharedProjectResource(value: unknown): value is SharedResourceWire { + if (!value || typeof value !== 'object') return false; + const resource = value as Record<string, unknown>; + return resource.kind === 'project' && + typeof resource.id === 'string' && + typeof resource.teamId === 'string' && + typeof resource.ownerMemberId === 'string' && + typeof resource.createdAt === 'string' && + (resource.deletedAt === null || resource.deletedAt === undefined); +} + +function toFallbackTeamProject(resource: SharedResourceWire): TeamProject { + const metadata = recordObject(resource.metadata) ?? {}; + const project: TeamProject = { + projectId: fallbackProjectId(resource, metadata), + ownerMemberId: resource.ownerMemberId, + sharedAt: resource.createdAt, + }; + if (typeof metadata.name === 'string' && metadata.name.trim()) { + project.name = metadata.name.trim(); + } + if (metadata.skillId === null || typeof metadata.skillId === 'string') { + project.skillId = metadata.skillId; + } + if (metadata.designSystemId === null || typeof metadata.designSystemId === 'string') { + project.designSystemId = metadata.designSystemId; + } + if (typeof metadata.createdAt === 'number') project.createdAt = metadata.createdAt; + if (typeof metadata.updatedAt === 'number') project.updatedAt = metadata.updatedAt; + const projectMetadata = recordObject(metadata.metadata); + if (projectMetadata) project.metadata = projectMetadata as unknown as ProjectMetadata; + return project; +} + +function toFallbackVelaTeamProjectRecord( + resource: SharedResourceWire, +): VelaTeamProjectRecord { + const metadata = recordObject(resource.metadata) ?? {}; + const project = toFallbackTeamProject(resource); + const createdAt = resource.createdAt; + const updatedAt = typeof metadata.updatedAt === 'number' && Number.isFinite(metadata.updatedAt) + ? new Date(metadata.updatedAt).toISOString() + : createdAt; + return { + id: resource.id, + workspaceId: resource.teamId, + projectId: project.projectId, + resourceId: resource.id, + ownerMemberId: resource.ownerMemberId, + displayName: project.name ?? null, + syncState: 'synced', + lastSyncedVersionId: null, + createdAt, + updatedAt, + access: { + canView: true, + canComment: true, + canEdit: false, + frozen: false, + }, + }; +} + +function fallbackProjectId( + resource: SharedResourceWire, + metadata: Record<string, unknown>, +): string { + if (typeof metadata.projectId === 'string' && metadata.projectId.trim()) { + return metadata.projectId.trim(); + } + const resourceId = resource.id; + const suffix = resourceId.startsWith(PROJECT_RESOURCE_PREFIX) + ? resourceId.slice(PROJECT_RESOURCE_PREFIX.length) + : resourceId; + try { + const decoded = JSON.parse(Buffer.from(suffix, 'base64url').toString('utf8')) as unknown; + if (Array.isArray(decoded) && typeof decoded[2] === 'string' && decoded[2].trim()) { + return decoded[2].trim(); + } + } catch { + // Legacy resource ids are simply `project-<projectId>`. + } + return suffix; +} + +const defaultRunVelaTeamProjects: RunVelaTeamProjects = (args, workspaceId) => + runVelaCommand( + ['team-projects', ...args], + velaWorkspaceCommandOptions(workspaceId), + ); + +const defaultRunVelaResources: RunVelaResources = (args, workspaceId) => + runVelaCommand( + ['resource', ...args], + velaWorkspaceCommandOptions(workspaceId), + ); diff --git a/apps/daemon/src/collab/vela-workspace-context.ts b/apps/daemon/src/collab/vela-workspace-context.ts new file mode 100644 index 00000000000..1a5e8e03823 --- /dev/null +++ b/apps/daemon/src/collab/vela-workspace-context.ts @@ -0,0 +1,757 @@ +import { createHash } from 'node:crypto'; +import { + buildWorkspacePermissions, + buildWorkspaceSeatSummary, +} from '@open-design/contracts'; +import type { + CollabMemberRole, + WorkspaceBillingState, + WorkspaceCollabContext, + WorkspaceDirectoryItem, + WorkspaceLifecycleState, + WorkspaceMemberStatus, + WorkspacePermissions, + WorkspaceProviderMode, + WorkspaceSeatSummary, + WorkspaceType, +} from '@open-design/contracts'; +import { readVelaControlApiContext, type VelaUser } from '../integrations/vela.js'; +import { + createDevWorkspaceContextProvider, + resolveWorkspaceSettingsUrl, + type WorkspaceContextProvider, + type WorkspaceContextRequest, +} from './workspace-context.js'; + +// Real B-integration provider (T2). The daemon reuses the SAME vela login session +// that AMR / the vela CLI use — `readVelaControlApiContext` reads the control key +// + api url from ~/.amr/config.json (or env) — and calls B's authoritative +// `GET /api/v1/workspaces/current`, which authenticates that session and returns +// the CurrentWorkspaceContext. No second identity: one vela session drives AMR, +// resource sharing, and the workspace context. Any failure (no session, signed +// out, B unreachable) degrades to null → collab stays single-player, never throws. + +const WORKSPACE_CURRENT_PATH = '/api/v1/workspaces/current'; +const DEFAULT_TIMEOUT_MS = 8_000; +// Read authorization is display-only and is polled every 5s by an open shared +// project. Keep the successful lease comfortably wider than that cadence so a +// poll cannot expire the lease at the exact instant it is meant to reuse it. +// Mutations never consume this lease: `fresh()` below always performs (or joins) +// an unsettled authoritative read. +const DEFAULT_DIRECTORY_CACHE_TTL_MS = 15_000; +// After a failed legacy default-workspace bootstrap, avoid repeating the +// directory read on every compatibility request. +const BOOTSTRAP_FAILURE_COOLDOWN_MS = 60_000; + +const WORKSPACE_TYPES = new Set<WorkspaceType>(['personal', 'team']); +const ROLES = new Set<CollabMemberRole>(['owner', 'admin', 'member']); +const MEMBER_STATUSES = new Set<WorkspaceMemberStatus>(['active', 'removed']); +const LIFECYCLE_STATES = new Set<WorkspaceLifecycleState>([ + 'active', + 'billing_past_due', + 'locked', + 'deleting', + 'deleted', +]); +const BILLING_STATES = new Set<WorkspaceBillingState>([ + 'free', + 'active', + 'past_due', + 'canceled', + 'inactive', + 'locked', +]); +const PROVIDER_MODES = new Set<WorkspaceProviderMode>(['platform_credits', 'personal_byok']); + +interface VelaWorkspaceContextOptions { + /** Injectable for tests. */ + fetch?: typeof fetch; + /** Injectable for tests; defaults to reading ~/.amr/config.json + env. */ + readSession?: typeof readVelaControlApiContext; + /** + * Legacy default for no-argument `current()` and fresh-account bootstrap. + * Exact request resolution never reads it. + */ + getActiveWorkspaceId?: () => string | null | undefined; + /** + * Persist a LOCAL default selection (fresh account with no selection + * anywhere). Never writes B's account-level Active Workspace — per the + * explicit-workspace handoff only a deliberate user switch PUTs current. + */ + setLocalSelection?: (workspaceId: string) => void | Promise<void>; + /** + * Purge a CONFIRMED-stale local pin: the membership directory was + * successfully read and no longer lists this workspace as an active + * membership (removed member, or the workspace itself is gone). Never + * called on a merely unreachable B — see `resolvePinnedWorkspace` below. + */ + clearLocalSelection?: () => void | Promise<void>; + timeoutMs?: number; +} + +/** + * Map B's `GET /api/v1/workspaces/current` body onto our WorkspaceCollabContext. + * The shape is a faithful mirror of B's CurrentWorkspaceContext, so this is a + * near pass-through with two adjustments: + * - `teamId` is derived as `workspaceId` for a team workspace: B has no separate + * team id — the workspace IS the team scope the resource hub keys resources by. + * - `permissions` / `seatSummary` are trusted from B when well-formed, and + * defensively re-derived (so read-only gating never breaks) if B omits them. + * Returns null when a required field is missing or an enum is out of range — + * collab then stays dormant rather than acting on a malformed context. + */ +export function mapVelaWorkspaceContext(input: unknown): WorkspaceCollabContext | null { + if (!input || typeof input !== 'object') return null; + const raw = input as Record<string, unknown>; + + const workspaceId = str(raw.workspaceId); + const workspaceMemberId = str(raw.workspaceMemberId); + if (!workspaceId || !workspaceMemberId) return null; + if (!WORKSPACE_TYPES.has(raw.workspaceType as WorkspaceType)) return null; + if (!ROLES.has(raw.role as CollabMemberRole)) return null; + if (!MEMBER_STATUSES.has(raw.memberStatus as WorkspaceMemberStatus)) return null; + if (!LIFECYCLE_STATES.has(raw.lifecycleState as WorkspaceLifecycleState)) return null; + if (!PROVIDER_MODES.has(raw.providerMode as WorkspaceProviderMode)) return null; + + const workspaceType = raw.workspaceType as WorkspaceType; + const role = raw.role as CollabMemberRole; + const memberStatus = raw.memberStatus as WorkspaceMemberStatus; + const lifecycleState = raw.lifecycleState as WorkspaceLifecycleState; + const billingState = BILLING_STATES.has(raw.billingState as WorkspaceBillingState) + ? raw.billingState as WorkspaceBillingState + : billingStateFromLifecycle(lifecycleState); + + const context: WorkspaceCollabContext = { + workspaceId, + workspaceType, + workspaceMemberId, + role, + memberStatus, + lifecycleState, + billingState, + planId: str(raw.planId) || null, + providerMode: raw.providerMode as WorkspaceProviderMode, + seatSummary: parseSeatSummary(raw.seatSummary), + permissions: + parsePermissions(raw.permissions) ?? + buildWorkspacePermissions({ role, lifecycleState, memberStatus }), + }; + const billingRecovery = parseBillingRecovery(raw.billingRecovery); + if (billingRecovery) context.billingRecovery = billingRecovery; + const lastActive = str(raw.lastActiveWorkspaceId); + if (lastActive) context.lastActiveWorkspaceId = lastActive; + // The team workspace IS the team scope; carry its id as teamId so the resource + // hub principal derives from this one context. + const settingsUrl = resolveWorkspaceSettingsUrl( + workspaceId, + (raw as { workspaceSettingsUrl?: unknown }).workspaceSettingsUrl, + ); + if (settingsUrl) context.workspaceSettingsUrl = settingsUrl; + + if (workspaceType === 'team') { + context.teamId = workspaceId; + } + const workspaceName = str((raw as { workspaceName?: unknown }).workspaceName); + // B names EVERY workspace, personal included, so the name belongs on the + // context for both types — that is what lets a surface label the current + // workspace off the startup context alone. `teamName` stays team-only: it is + // the team switcher's field and doubles as an "is a team" signal. + if (workspaceName) context.workspaceName = workspaceName; + if (workspaceName && workspaceType === 'team') context.teamName = workspaceName; + const displayName = str((raw as { displayName?: unknown }).displayName); + if (displayName) context.displayName = displayName; + return context; +} + +export function mapVelaWorkspaceDirectory(input: unknown): WorkspaceDirectoryItem[] { + if (!input || typeof input !== 'object') return []; + const raw = input as { items?: unknown }; + if (!Array.isArray(raw.items)) return []; + const items: WorkspaceDirectoryItem[] = []; + for (const entry of raw.items) { + const mapped = mapVelaWorkspaceDirectoryItem(entry); + if (mapped) items.push(mapped); + } + return items; +} + +function mapVelaWorkspaceDirectoryItem(input: unknown): WorkspaceDirectoryItem | null { + if (!input || typeof input !== 'object') return null; + const raw = input as Record<string, unknown>; + const workspaceId = str(raw.workspaceId); + const workspaceName = str(raw.workspaceName); + const workspaceMemberId = str(raw.workspaceMemberId); + if (!workspaceId || !workspaceName || !workspaceMemberId) return null; + if (!WORKSPACE_TYPES.has(raw.workspaceType as WorkspaceType)) return null; + if (!ROLES.has(raw.role as CollabMemberRole)) return null; + if (!MEMBER_STATUSES.has(raw.memberStatus as WorkspaceMemberStatus)) return null; + if (!LIFECYCLE_STATES.has(raw.lifecycleState as WorkspaceLifecycleState)) return null; + const item: WorkspaceDirectoryItem = { + workspaceId, + workspaceName, + workspaceType: raw.workspaceType as WorkspaceType, + workspaceMemberId, + role: raw.role as CollabMemberRole, + memberStatus: raw.memberStatus as WorkspaceMemberStatus, + lifecycleState: raw.lifecycleState as WorkspaceLifecycleState, + }; + const workspaceIconKey = str(raw.workspaceIconKey); + if (workspaceIconKey) item.workspaceIconKey = workspaceIconKey; + return item; +} + +/** + * Provider that fetches the workspace context from B using the local vela + * session. Swap this in for the dev stub once a B-backed vela is reachable. + */ +export function createVelaWorkspaceContextProvider( + options: VelaWorkspaceContextOptions = {}, +): WorkspaceContextProvider { + const fetchImpl = options.fetch ?? fetch; + const readSession = options.readSession ?? readVelaControlApiContext; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + type VelaSession = NonNullable<ReturnType<typeof readVelaControlApiContext>>; + let lastBootstrapFailureAt = 0; + + /** + * Read the context for the workspace THIS daemon is pinned to. + * + * The workspace travels as `x-vela-workspace-id`, which is the per-request + * workspace scope B honours across its resource plane, its billing scope + * routes and the Link gateway (and which the vela CLI already sends for + * scoped commands). A `?workspaceId=` query hint is NOT sent: B's + * `GET /workspaces/current` ignores URL hints by design and asserts that in + * its own suite, so a query param was only ever dead weight that made this + * look scoped when it was not. + * + * Without the header B answers from the ACCOUNT-LEVEL active workspace, + * which is one row per account (`active_workspace_selections` is keyed by + * app user) and therefore cannot describe an account whose clients are in + * different workspaces. Sending it is what lets two clients of one account + * each read their own workspace. + */ + async function fetchCurrent( + session: VelaSession, + activeWorkspaceId: string | undefined, + ): Promise<Response> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetchImpl(new URL(WORKSPACE_CURRENT_PATH, session.apiUrl), { + method: 'GET', + headers: { + authorization: `Bearer ${session.controlKey}`, + ...(activeWorkspaceId ? { 'x-vela-workspace-id': activeWorkspaceId } : {}), + }, + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } + } + + /** Pick the best default membership out of an already-fetched directory list. */ + function selectDefaultCandidate( + items: WorkspaceDirectoryItem[], + preferredId: string | undefined, + ): WorkspaceDirectoryItem | undefined { + const candidates = items.filter( + (item) => item.memberStatus === 'active' && item.lifecycleState === 'active', + ); + return ( + (preferredId ? candidates.find((item) => item.workspaceId === preferredId) : undefined) ?? + candidates.find((item) => item.workspaceType === 'personal') ?? + candidates[0] + ); + } + + /** + * Fresh-account default pick. B's workspace selection is server-side state + * and a new account has NO current workspace, so every workspace-scoped + * call fails `403 missing_principal` until something selects one. The + * client picks a LOCAL default — the OD-active selection when listed, else + * the personal workspace, else the first active membership — and persists + * it locally only. It never PUTs B's Active Workspace (handoff rule: only a + * deliberate user switch may), with a failure cooldown so the poller can't + * hammer the directory. + * + * `prefetched` lets a caller that already fetched the directory this same + * tick (`resolvePinnedWorkspace`, right after confirming the old pin is + * gone) reuse that result instead of round-tripping B a second time. + */ + async function pickDefaultWorkspace( + session: VelaSession, + prefetched?: WorkspaceDirectoryFetchResult, + ): Promise<WorkspaceDirectoryItem | null> { + if (Date.now() - lastBootstrapFailureAt < BOOTSTRAP_FAILURE_COOLDOWN_MS) return null; + const result = + prefetched ?? + (await fetchVelaWorkspaceDirectory({ fetch: fetchImpl, readSession: () => session, timeoutMs })); + const preferredId = options.getActiveWorkspaceId?.()?.trim(); + const pick = selectDefaultCandidate(result.items, preferredId); + if (!pick) { + lastBootstrapFailureAt = Date.now(); + return null; + } + return pick; + } + + /** + * Resolve the LOCALLY pinned workspace against the membership directory. + * This is the ONLY place that may clear a bad pin, and it must tell apart + * two very different situations behind `contextFromDirectory` returning + * null before this fix — B genuinely confirming the membership is gone, + * vs. B simply being unreachable for this one request: + * + * - The directory request itself FAILS (network error, timeout, non-2xx) + * → B did not answer, so nothing was confirmed. The pin is left exactly + * as-is and this resolves to null, matching the existing degrade-to- + * single-player behavior for one poll tick. A momentary B outage must + * never evict an online user from their current workspace. + * - The directory request SUCCEEDS and the pinned workspace IS listed + * with an active membership → synthesize its context; the pin is + * correct and stays untouched. + * - The directory request SUCCEEDS and the pinned workspace is ABSENT (or + * listed with a non-active membership / deleted lifecycle) → this is a + * CONFIRMED removal. The stale pin is cleared and this same call falls + * through to the same local-default bootstrap a fresh account gets + * (personal workspace first), so the very next context read already + * recovers to a workspace the user can actually use — instead of + * `current()` returning null forever, which the web client reads as + * "signed out" (recvqbbQ4yljNC: member removed from a team could not + * log back into ANY workspace, including personal). + */ + async function resolvePinnedWorkspace( + session: VelaSession, + workspaceId: string, + ): Promise<WorkspaceCollabContext | null> { + const result = await fetchVelaWorkspaceDirectory({ + fetch: fetchImpl, + readSession: () => session, + timeoutMs, + }); + if (!result.ok) return null; // B unreachable — preserve the pin, confirm nothing. + const item = result.items.find( + (entry) => + entry.workspaceId === workspaceId && + entry.memberStatus === 'active' && + entry.lifecycleState !== 'deleted', + ); + if (item) return workspaceContextFromDirectoryItem(item); + // Confirmed stale: the directory answered and this workspace no longer + // has the caller as an active member. Purge the pin before anything else + // reads it, then recover exactly like the fresh-account bootstrap. + await options.clearLocalSelection?.(); + const fallback = await pickDefaultWorkspace(session, result); + if (!fallback) return null; + await options.setLocalSelection?.(fallback.workspaceId); + return workspaceContextFromDirectoryItem(fallback); + } + + async function resolveCurrent( + req: WorkspaceContextRequest, + ): Promise<WorkspaceCollabContext | null> { + const session = readSession(); + if (!session || !session.controlKey || !session.apiUrl) return null; + try { + const explicitSelection = req.workspaceId?.trim() || undefined; + // The no-argument fallback is legacy compatibility only. Client-facing + // routes use resolveExact and cannot borrow this daemon-local pin. + const localSelection = + explicitSelection ?? (options.getActiveWorkspaceId?.()?.trim() || undefined); + // B's current is enrichment, not authority (explicit-workspace + // handoff): the daemon serves the LOCALLY pinned workspace. B's + // answer is adopted only when it matches — a switch made on another + // device/surface must not re-aim this daemon. + const response = await fetchCurrent(session, localSelection); + if (response.ok) { + const body: unknown = await response.json(); + const mapped = mapVelaWorkspaceContext(body); + if (mapped && (!localSelection || mapped.workspaceId === localSelection)) { + return withDisplayName(mapped, session); + } + if (localSelection) { + // Server disagrees with the pinned scope → synthesize from the + // membership directory instead of silently following the server. + return withDisplayName(await resolvePinnedWorkspace(session, localSelection), session); + } + return null; + } + // 401 = signed out at the vela layer → single-player, never bootstrap. + if (response.status === 401) return null; + const missingPrincipal = + response.status === 403 && (await responseIsMissingPrincipal(response)); + if (localSelection) { + // The pinned workspace could not be read from current — resolve it + // from the directory (clears the pin only on a CONFIRMED removal). + return withDisplayName(await resolvePinnedWorkspace(session, localSelection), session); + } + if (missingPrincipal) { + // Fresh account: B has no current workspace and the client has no + // selection. Pick a LOCAL default (personal first) — no PUT. + const picked = await pickDefaultWorkspace(session); + if (!picked) return null; + await options.setLocalSelection?.(picked.workspaceId); + return withDisplayName(workspaceContextFromDirectoryItem(picked), session); + } + return null; + } catch { + // Never let a workspace-context failure throw into collab — degrade to + // single-player. A transient B outage must not break the local editor. + return null; + } + } + + async function resolveExact( + req: WorkspaceContextRequest & { workspaceId: string }, + ): Promise<WorkspaceCollabContext | null> { + const session = readSession(); + const workspaceId = req.workspaceId.trim(); + if (!session || !session.controlKey || !session.apiUrl || !workspaceId) return null; + try { + const response = await fetchCurrent(session, workspaceId); + if (response.ok) { + const mapped = mapVelaWorkspaceContext(await response.json()); + if (mapped?.workspaceId === workspaceId) { + return withDisplayName(mapped, session); + } + } else if (response.status === 401) { + return null; + } + const directory = await fetchVelaWorkspaceDirectory({ + fetch: fetchImpl, + readSession: () => session, + timeoutMs, + }); + if (!directory.ok) return null; + const item = directory.items.find( + (entry) => + entry.workspaceId === workspaceId + && entry.memberStatus === 'active' + && entry.lifecycleState !== 'deleted', + ); + return item + ? withDisplayName(workspaceContextFromDirectoryItem(item), session) + : null; + } catch { + return null; + } + } + + return { + current: resolveCurrent, + resolveExact, + }; +} + +async function responseIsMissingPrincipal(response: Response): Promise<boolean> { + try { + const body: unknown = await response.json(); + return JSON.stringify(body).includes('missing_principal'); + } catch { + return false; + } +} + +/** + * Synthesize a workspace context from a membership directory item — the + * explicit-workspace path where B's `current` is absent or disagrees with the + * client's pinned scope. The directory carries identity + role + lifecycle; + * billing-plane fields default conservatively (no plan, derived permissions) + * until a per-workspace context endpoint exists on B. + */ +export function workspaceContextFromDirectoryItem( + item: WorkspaceDirectoryItem, +): WorkspaceCollabContext { + const context: WorkspaceCollabContext = { + workspaceId: item.workspaceId, + workspaceType: item.workspaceType, + workspaceMemberId: item.workspaceMemberId, + role: item.role, + memberStatus: item.memberStatus, + lifecycleState: item.lifecycleState, + billingState: billingStateFromLifecycle(item.lifecycleState), + planId: null, + providerMode: 'platform_credits', + seatSummary: buildWorkspaceSeatSummary({ seatLimit: 0, usedSeats: 0 }), + permissions: buildWorkspacePermissions({ + role: item.role, + lifecycleState: item.lifecycleState, + memberStatus: item.memberStatus, + }), + }; + const settingsUrl = resolveWorkspaceSettingsUrl(item.workspaceId, undefined); + if (settingsUrl) context.workspaceSettingsUrl = settingsUrl; + if (item.workspaceName) context.workspaceName = item.workspaceName; + if (item.workspaceType === 'team') { + context.teamId = item.workspaceId; + context.teamName = item.workspaceName; + } + return context; +} + +function withDisplayName( + context: WorkspaceCollabContext | null, + session: { user: VelaUser | null }, +): WorkspaceCollabContext | null { + if (context && !context.displayName) { + const displayName = velaUserDisplayName(session.user); + if (displayName) context.displayName = displayName; + } + return context; +} + +function velaUserDisplayName(user: VelaUser | null): string { + const name = str(user?.name); + if (name) return name; + const email = str(user?.email); + if (email) return email; + return str(user?.id); +} + +/** + * Result of a directory fetch attempt. `ok` is the load-bearing bit for + * anything that decides whether to trust an absence as a CONFIRMED removal + * (see `resolvePinnedWorkspace`): true only when B actually answered with a + * 2xx — false for a network error, an abort/timeout, or any non-2xx status, + * regardless of what (if anything) `items` ends up holding. + */ +export interface WorkspaceDirectoryFetchResult { + ok: boolean; + items: WorkspaceDirectoryItem[]; +} + +/** + * Cheap, non-secret cache partition for the local Vela session. A credential + * rotation/account switch must never reuse the prior member directory, even + * within the short success TTL. + */ +export function velaWorkspaceDirectoryIdentity( + readSession: typeof readVelaControlApiContext = readVelaControlApiContext, +): string { + const session = readSession(); + if (!session?.controlKey || !session.apiUrl) return 'signed-out'; + const credentialFingerprint = createHash('sha256') + .update(session.controlKey) + .digest('hex') + .slice(0, 16); + return [ + session.profile ?? '', + session.apiUrl, + session.user?.id ?? '', + session.configMtimeMs ?? '', + credentialFingerprint, + ].join(':'); +} + +/** + * One daemon-owned authority broker shared by idempotent reads and mutations. + * + * Successful authority reads seed a bounded display-read lease. Mutations + * ignore that settled lease and always perform a fresh directory read, while + * still sharing an already-unsettled request from the same Vela session. This + * keeps the 5s status poll off the control plane without weakening mutation + * freshness, and prevents a status/heartbeat boundary from launching duplicate + * directory requests. + */ +export function createWorkspaceDirectoryAuthorityBroker(options: { + fetchDirectory?: () => Promise<WorkspaceDirectoryFetchResult>; + identityKey?: () => string; + ttlMs?: number; + now?: () => number; +} = {}): { + read: () => Promise<WorkspaceDirectoryFetchResult>; + fresh: () => Promise<WorkspaceDirectoryFetchResult>; + refreshAfterMutation: () => Promise<WorkspaceDirectoryFetchResult>; +} { + const fetchDirectory = + options.fetchDirectory ?? (() => fetchVelaWorkspaceDirectory()); + const identityKey = options.identityKey ?? velaWorkspaceDirectoryIdentity; + const ttlMs = Math.max(0, options.ttlMs ?? DEFAULT_DIRECTORY_CACHE_TTL_MS); + const now = options.now ?? Date.now; + const cached = new Map< + string, + { expiresAt: number; result: WorkspaceDirectoryFetchResult } + >(); + const inFlight = new Map<string, Promise<WorkspaceDirectoryFetchResult>>(); + + const start = ( + identity: string, + ): Promise<WorkspaceDirectoryFetchResult> => { + const pending = inFlight.get(identity); + if (pending) return pending; + const request = fetchDirectory() + .then((result) => { + if (result.ok) { + cached.set(identity, { expiresAt: now() + ttlMs, result }); + } + return result; + }) + .finally(() => { + if (inFlight.get(identity) === request) inFlight.delete(identity); + }); + inFlight.set(identity, request); + return request; + }; + + return { + read: () => { + const identity = identityKey(); + const cachedEntry = cached.get(identity); + if (cachedEntry && now() < cachedEntry.expiresAt) { + return Promise.resolve(cachedEntry.result); + } + return start(identity); + }, + fresh: () => start(identityKey()), + refreshAfterMutation: async () => { + // A read that started before the remote mutation can still be in flight + // after the mutation commits. Drain it, then deliberately start another + // fetch so the settled lease is based on post-mutation authority. + const identity = identityKey(); + const pending = inFlight.get(identity); + if (pending) await pending.catch(() => undefined); + cached.delete(identity); + return start(identityKey()); + }, + }; +} + +/** + * Compatibility wrapper for callers that only need the bounded read lease. + * Production daemon wiring uses one shared broker for reads and mutations. + */ +export function createCachedWorkspaceDirectoryFetcher(options: { + fetchDirectory?: () => Promise<WorkspaceDirectoryFetchResult>; + identityKey?: () => string; + ttlMs?: number; + now?: () => number; +} = {}): () => Promise<WorkspaceDirectoryFetchResult> { + return createWorkspaceDirectoryAuthorityBroker(options).read; +} + +/** + * Mutation authorization must not reuse a settled directory result, but + * concurrent checks from the same Vela session may share one authority read. + * Partitioning the in-flight request by session identity prevents an account + * switch from authorizing account B with account A's membership directory. + */ +export function createFreshWorkspaceDirectoryFetcher(options: { + fetchDirectory?: () => Promise<WorkspaceDirectoryFetchResult>; + identityKey?: () => string; +} = {}): () => Promise<WorkspaceDirectoryFetchResult> { + return createWorkspaceDirectoryAuthorityBroker(options).fresh; +} + +export async function fetchVelaWorkspaceDirectory( + options: VelaWorkspaceContextOptions = {}, +): Promise<WorkspaceDirectoryFetchResult> { + const fetchImpl = options.fetch ?? fetch; + const readSession = options.readSession ?? readVelaControlApiContext; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const session = readSession(); + // No local Vela session is an authoritative signed-out identity, not an + // authority outage. Returning a successful empty directory lets clients + // clear a previously cached Team selection instead of preserving it forever. + if (!session || !session.controlKey || !session.apiUrl) return { ok: true, items: [] }; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(new URL('/api/v1/workspaces', session.apiUrl), { + method: 'GET', + headers: { authorization: `Bearer ${session.controlKey}` }, + signal: controller.signal, + }); + if (!response.ok) return { ok: false, items: [] }; + return { ok: true, items: mapVelaWorkspaceDirectory(await response.json()) }; + } catch { + return { ok: false, items: [] }; + } finally { + clearTimeout(timeout); + } +} + +export async function listVelaWorkspaceDirectory( + options: VelaWorkspaceContextOptions = {}, +): Promise<WorkspaceDirectoryItem[]> { + return (await fetchVelaWorkspaceDirectory(options)).items; +} + +/** + * Select the workspace-context provider for this run. `OD_WORKSPACE_CONTEXT_SOURCE + * =vela` opts into the real B-backed provider (production / e2e against a live + * vela); every other value keeps the dev stub, so demo and tools-dev runs — which + * have no B and drive the context via the dev PUT — are unaffected. + */ +export function createWorkspaceContextProviderFromEnv( + env: NodeJS.ProcessEnv = process.env, + options: Pick< + VelaWorkspaceContextOptions, + 'getActiveWorkspaceId' | 'setLocalSelection' | 'clearLocalSelection' + > = {}, +): WorkspaceContextProvider { + if (env.OD_WORKSPACE_CONTEXT_SOURCE?.trim() === 'vela') { + return createVelaWorkspaceContextProvider(options); + } + return createDevWorkspaceContextProvider(); +} + +function str(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function billingStateFromLifecycle( + lifecycleState: WorkspaceLifecycleState, +): WorkspaceBillingState { + if (lifecycleState === 'billing_past_due') return 'past_due'; + if (lifecycleState === 'locked') return 'locked'; + if (lifecycleState === 'deleting' || lifecycleState === 'deleted') { + return 'inactive'; + } + return 'active'; +} + +function parseSeatSummary(value: unknown): WorkspaceSeatSummary { + if (value && typeof value === 'object') { + const raw = value as Record<string, unknown>; + if (typeof raw.seatLimit === 'number' && typeof raw.usedSeats === 'number') { + // Re-derive availableSeats/isSeatFull from the authoritative counts so a + // stale or inconsistent summary can never disagree with itself. + return buildWorkspaceSeatSummary({ seatLimit: raw.seatLimit, usedSeats: raw.usedSeats }); + } + } + return buildWorkspaceSeatSummary({ seatLimit: 0, usedSeats: 0 }); +} + +function parsePermissions(value: unknown): WorkspacePermissions | null { + if (!value || typeof value !== 'object') return null; + const raw = value as Record<string, unknown>; + const keys: (keyof WorkspacePermissions)[] = [ + 'canManageMembers', + 'canManageBilling', + 'canInviteMembers', + 'canManageAutoRecharge', + 'canShareProjects', + 'canWriteSyncedFiles', + 'canViewWorkspaceSettings', + 'canManageSharedResources', + ]; + const permissions = {} as WorkspacePermissions; + for (const key of keys) { + if (typeof raw[key] !== 'boolean') return null; + permissions[key] = raw[key] as boolean; + } + return permissions; +} + +function parseBillingRecovery( + value: unknown, +): { canEnterBillingRecovery: boolean; recoveryUrl: string | null } | null { + if (!value || typeof value !== 'object') return null; + const raw = value as Record<string, unknown>; + if (typeof raw.canEnterBillingRecovery !== 'boolean') return null; + return { + canEnterBillingRecovery: raw.canEnterBillingRecovery, + recoveryUrl: typeof raw.recoveryUrl === 'string' ? raw.recoveryUrl : null, + }; +} diff --git a/apps/daemon/src/collab/workspace-billing-runtime.ts b/apps/daemon/src/collab/workspace-billing-runtime.ts new file mode 100644 index 00000000000..2d3b01c133f --- /dev/null +++ b/apps/daemon/src/collab/workspace-billing-runtime.ts @@ -0,0 +1,1430 @@ +import type { + WorkspaceBillingRevisionClock, + WorkspaceBillingRuntimeState, + WorkspaceBillingSnapshot, + WorkspaceWalletBalance, +} from '@open-design/contracts'; +import type { VelaWorkspaceBillingProjection } from '../integrations/vela-billing.js'; + +const DEFAULT_POLL_INTERVAL_MS = 30_000; +const DEFAULT_RETRY_DELAYS_MS = [5_000, 15_000, 30_000] as const; +const DEFAULT_INTEREST_LEASE_MS = 60_000; +const DEFAULT_INTEREST_SWEEP_INTERVAL_MS = 5_000; +const DEFAULT_ENTRY_RETENTION_MS = 5 * 60_000; +const DEFAULT_MAX_CLIENTS = 64; +const DEFAULT_MAX_INTERESTS_PER_CLIENT = 16; +const DEFAULT_MAX_ENTRIES = 128; +const DEFAULT_MAX_CONCURRENT_REFRESHES = 4; +const DEFAULT_MAX_REFRESH_STARTS_PER_WINDOW = 8; +const DEFAULT_REFRESH_START_WINDOW_MS = 1_000; +const DEFAULT_HARD_TTL_MULTIPLIER = 4; +const MAX_RETIRED_REVISION_EPOCHS = 32; + +export interface WorkspaceBillingRuntimeKey { + workspaceId: string; + workspaceMemberId: string; +} + +export interface WorkspaceBillingRuntimeReadOptions { + reason?: string; + clientId?: string; + clientGeneration?: string; + /** Execution/precharge reads must complete a new authoritative projection. */ + requireFresh?: boolean; +} + +export interface WorkspaceBillingRuntimeInterestSet { + clientId: string; + clientGeneration: string; + interests: WorkspaceBillingRuntimeKey[]; +} + +export interface WorkspaceBillingRuntimeInterestLease { + clientId: string; + acceptedGeneration: string; + leaseExpiresAt: string; +} + +export interface WorkspaceBillingRuntimeResult { + projection: VelaWorkspaceBillingProjection; + state: WorkspaceBillingRuntimeState; +} + +export type WorkspaceBillingInvalidationDomain = + | 'legacy' + | 'subscription' + | 'wallet'; + +export interface WorkspaceBillingRuntimeInvalidation { + domain: WorkspaceBillingInvalidationDomain; + workspaceId?: string; + workspaceMemberId?: string; + revision?: string; + revisionClock?: WorkspaceBillingRevisionClock; + reason?: string; +} + +export interface WorkspaceBillingRuntimeScheduler { + now(): number; + setTimeout(callback: () => void, delayMs: number): ReturnType<typeof setTimeout>; + clearTimeout(timer: ReturnType<typeof setTimeout>): void; + setInterval(callback: () => void, delayMs: number): ReturnType<typeof setInterval>; + clearInterval(timer: ReturnType<typeof setInterval>): void; +} + +export interface WorkspaceBillingRuntimeCoordinatorOptions { + fetchProjection(key: WorkspaceBillingRuntimeKey): Promise<VelaWorkspaceBillingProjection>; + scheduler?: WorkspaceBillingRuntimeScheduler; + pollIntervalMs?: number; + retryDelaysMs?: readonly number[]; + onStateChange?: (state: WorkspaceBillingRuntimeState) => void; + interestLeaseMs?: number; + interestSweepIntervalMs?: number; + entryRetentionMs?: number; + maxClients?: number; + maxInterestsPerClient?: number; + maxEntries?: number; + softTtlMs?: number; + hardTtlMs?: number; + maxConcurrentRefreshes?: number; + maxRefreshStartsPerWindow?: number; + refreshStartWindowMs?: number; + onInterestSetChange?: (interests: WorkspaceBillingRuntimeKey[]) => void; +} + +interface ClientInterest { + generation: bigint; + keys: Set<string>; + expiresAt: number; +} + +interface RuntimeEntry { + key: WorkspaceBillingRuntimeKey; + /** + * Headerless clients predate explicit leases. Their successful reads renew a + * bounded compatibility lease instead of pinning this entry until process + * exit. + */ + legacyInterestExpiresAt: number | null; + uninterestedAt: number | null; + projection: VelaWorkspaceBillingProjection; + status: WorkspaceBillingRuntimeState['status']; + revision: bigint; + observedAt: number | null; + retryAt: number | null; + errorCode: string | null; + reason: string; + sourceGapDetected: boolean; + sourceRevisions: Partial<Record<WorkspaceBillingInvalidationDomain, string>>; + sourceRevisionClocks: Partial< + Record<WorkspaceBillingInvalidationDomain, WorkspaceBillingRevisionClock> + >; + retiredRevisionClockEpochs: { + subscription: Set<string>; + wallet: Set<string>; + }; + inFlight: Promise<void> | null; + queued: boolean; + queuedReason: string | null; + pending: boolean; + pendingReason: string | null; + attempt: bigint; + retryAttempt: number; + retryTimer: ReturnType<typeof setTimeout> | null; + waiters: Array<() => void>; +} + +const EMPTY_PROJECTION: VelaWorkspaceBillingProjection = { + snapshot: null, + workspaceBalance: null, +}; + +export class WorkspaceBillingInterestError extends Error { + constructor( + readonly code: + | 'invalid_generation' + | 'stale_generation' + | 'generation_payload_mismatch' + | 'interest_capacity_exceeded', + readonly acceptedGeneration?: string, + ) { + super(code); + this.name = 'WorkspaceBillingInterestError'; + } +} + +export class WorkspaceBillingAccessRevokedError extends Error { + readonly code = 'workspace_not_authorized'; + + constructor() { + super('workspace billing access revoked'); + this.name = 'WorkspaceBillingAccessRevokedError'; + } +} + +/** + * Sole writer for exact workspace/member billing projections. + * + * Network reads, SSE events, reconnect hooks, and poll timers only submit + * invalidations here. Per-key single-flight plus one trailing refresh prevents + * event bursts from serializing unrelated work or allowing a late read for one + * workspace/member to overwrite another. + */ +export class WorkspaceBillingRuntimeCoordinator { + private readonly entries = new Map<string, RuntimeEntry>(); + private readonly clients = new Map<string, ClientInterest>(); + private readonly scheduler: WorkspaceBillingRuntimeScheduler; + private readonly pollIntervalMs: number; + private readonly retryDelaysMs: readonly number[]; + private readonly interestLeaseMs: number; + private readonly entryRetentionMs: number; + private readonly maxClients: number; + private readonly maxInterestsPerClient: number; + private readonly maxEntries: number; + private readonly softTtlMs: number; + private readonly hardTtlMs: number; + private readonly maxConcurrentRefreshes: number; + private readonly maxRefreshStartsPerWindow: number; + private readonly refreshStartWindowMs: number; + private readonly refreshQueue: RuntimeEntry[] = []; + private readonly refreshStarts: number[] = []; + private activeRefreshes = 0; + private refreshQueueTimer: ReturnType<typeof setTimeout> | null = null; + private readonly pollTimer: ReturnType<typeof setInterval>; + private readonly interestSweepTimer: ReturnType<typeof setInterval>; + private disposed = false; + + constructor(private readonly options: WorkspaceBillingRuntimeCoordinatorOptions) { + this.scheduler = options.scheduler ?? defaultScheduler(); + this.pollIntervalMs = Math.max(1, options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS); + this.retryDelaysMs = + options.retryDelaysMs?.map((delay) => Math.max(0, delay)) ?? + DEFAULT_RETRY_DELAYS_MS; + this.interestLeaseMs = Math.max(1, options.interestLeaseMs ?? DEFAULT_INTEREST_LEASE_MS); + this.entryRetentionMs = Math.max(0, options.entryRetentionMs ?? DEFAULT_ENTRY_RETENTION_MS); + this.maxClients = Math.max(1, options.maxClients ?? DEFAULT_MAX_CLIENTS); + this.maxInterestsPerClient = Math.max( + 1, + options.maxInterestsPerClient ?? DEFAULT_MAX_INTERESTS_PER_CLIENT, + ); + this.maxEntries = Math.max(1, options.maxEntries ?? DEFAULT_MAX_ENTRIES); + this.softTtlMs = Math.max(1, options.softTtlMs ?? this.pollIntervalMs); + this.hardTtlMs = Math.max( + this.softTtlMs, + options.hardTtlMs ?? this.softTtlMs * DEFAULT_HARD_TTL_MULTIPLIER, + ); + this.maxConcurrentRefreshes = Math.max( + 1, + options.maxConcurrentRefreshes ?? DEFAULT_MAX_CONCURRENT_REFRESHES, + ); + this.maxRefreshStartsPerWindow = Math.max( + 1, + options.maxRefreshStartsPerWindow ?? DEFAULT_MAX_REFRESH_STARTS_PER_WINDOW, + ); + this.refreshStartWindowMs = Math.max( + 1, + options.refreshStartWindowMs ?? DEFAULT_REFRESH_START_WINDOW_MS, + ); + this.pollTimer = this.scheduler.setInterval(() => { + this.refreshAll('poll-floor'); + }, this.pollIntervalMs); + this.pollTimer.unref?.(); + this.interestSweepTimer = this.scheduler.setInterval(() => { + this.sweepExpiredInterests(); + }, Math.max(1, options.interestSweepIntervalMs ?? DEFAULT_INTEREST_SWEEP_INTERVAL_MS)); + this.interestSweepTimer.unref?.(); + } + + setClientInterests( + input: WorkspaceBillingRuntimeInterestSet, + ): WorkspaceBillingRuntimeInterestLease { + this.assertUsable(); + const clientId = input.clientId.trim(); + const generationText = input.clientGeneration.trim(); + if (!clientId || !/^(?:0|[1-9]\d*)$/.test(generationText)) { + throw new WorkspaceBillingInterestError('invalid_generation'); + } + const generation = BigInt(generationText); + const current = this.clients.get(clientId); + if (current && generation < current.generation) { + throw new WorkspaceBillingInterestError( + 'stale_generation', + current.generation.toString(), + ); + } + const keys = new Map<string, WorkspaceBillingRuntimeKey>(); + for (const interest of input.interests) { + const key = normalizeKey(interest); + keys.set(runtimeKey(key), key); + } + if (keys.size > this.maxInterestsPerClient) { + throw new WorkspaceBillingInterestError('interest_capacity_exceeded'); + } + if (current && generation === current.generation) { + if (!sameStringSet(current.keys, new Set(keys.keys()))) { + throw new WorkspaceBillingInterestError( + 'generation_payload_mismatch', + current.generation.toString(), + ); + } + current.expiresAt = this.scheduler.now() + this.interestLeaseMs; + return this.interestLease(clientId, current); + } + if (keys.size === 0) { + if (current) { + this.clients.delete(clientId); + this.handleInterestMutation(current.keys, new Set()); + } + return { + clientId, + acceptedGeneration: generation.toString(), + leaseExpiresAt: new Date(this.scheduler.now()).toISOString(), + }; + } + if (!current && this.clients.size >= this.maxClients) { + throw new WorkspaceBillingInterestError('interest_capacity_exceeded'); + } + + const prospectiveKeys = new Set([ + ...this.activeRuntimeKeysExcept(clientId), + ...keys.keys(), + ]); + if (prospectiveKeys.size > this.maxEntries) { + throw new WorkspaceBillingInterestError('interest_capacity_exceeded'); + } + for (const key of keys.values()) this.entryFor(key); + this.clients.set(clientId, { + generation, + keys: new Set(keys.keys()), + expiresAt: this.scheduler.now() + this.interestLeaseMs, + }); + this.handleInterestMutation(current?.keys ?? new Set(), new Set(keys.keys())); + return this.interestLease(clientId, this.clients.get(clientId)!); + } + + releaseClientInterests(clientIdInput: string, generationText?: string): boolean { + this.assertUsable(); + const clientId = clientIdInput.trim(); + const current = this.clients.get(clientId); + if (!current) return false; + if (generationText != null) { + const requested = generationText.trim(); + if (!/^(?:0|[1-9]\d*)$/.test(requested)) { + throw new WorkspaceBillingInterestError('invalid_generation'); + } + if (BigInt(requested) < current.generation) return false; + } + this.clients.delete(clientId); + this.handleInterestMutation(current.keys, new Set()); + return true; + } + + interestedKeys(): WorkspaceBillingRuntimeKey[] { + this.sweepExpiredInterests(); + const keys = new Set<string>(); + for (const interest of this.clients.values()) { + for (const key of interest.keys) keys.add(key); + } + for (const entry of this.entries.values()) { + if ( + entry.legacyInterestExpiresAt != null && + entry.legacyInterestExpiresAt > this.scheduler.now() + ) { + keys.add(runtimeKey(entry.key)); + } + } + return [...keys] + .map((key) => this.entries.get(key)?.key) + .filter((key): key is WorkspaceBillingRuntimeKey => Boolean(key)) + .map((key) => ({ ...key })); + } + + async read( + keyInput: WorkspaceBillingRuntimeKey, + options: WorkspaceBillingRuntimeReadOptions = {}, + ): Promise<WorkspaceBillingRuntimeResult> { + this.assertUsable(); + const key = normalizeKey(keyInput); + const entry = this.entryFor(key); + const forceForInterest = this.acceptClientInterest(entry, options); + const requireFresh = options.requireFresh === true; + if ( + entry.status === 'fresh' && + entry.observedAt != null && + this.scheduler.now() - entry.observedAt >= this.softTtlMs + ) { + this.markStatus(entry, 'stale', 'soft-ttl-expired'); + } + if (entry.retryTimer && !forceForInterest && !requireFresh) { + return this.result(entry); + } + if ( + requireFresh || + entry.status !== 'fresh' || + entry.observedAt == null || + this.scheduler.now() - entry.observedAt >= this.softTtlMs || + forceForInterest + ) { + this.requestRefresh( + entry, + options.reason ?? 'explicit-read', + forceForInterest || requireFresh, + ); + await this.waitForIdle(entry); + } + if (requireFresh && entry.status !== 'fresh') { + throw Object.assign( + new Error(entry.errorCode ?? 'workspace_billing_authoritative_unavailable'), + { + code: entry.errorCode ?? 'workspace_billing_authoritative_unavailable', + }, + ); + } + return this.result(entry); + } + + invalidate(invalidation: WorkspaceBillingRuntimeInvalidation): void { + if (this.disposed) return; + const workspaceId = invalidation.workspaceId?.trim() ?? ''; + const workspaceMemberId = invalidation.workspaceMemberId?.trim() ?? ''; + for (const entry of this.entries.values()) { + if (workspaceId && entry.key.workspaceId !== workspaceId) continue; + if (!this.hasActiveInterest(entry)) continue; + if (invalidation.domain === 'wallet') { + if (!workspaceId || !workspaceMemberId) continue; + if (entry.key.workspaceMemberId !== workspaceMemberId) continue; + } + if (entry.status === 'access-revoked') continue; + const revisionClock = normalizeRevisionClock(invalidation.revisionClock); + const clockDomain = + invalidation.domain === 'wallet' ? 'wallet' : 'subscription'; + const previousClock = entry.sourceRevisionClocks[clockDomain]; + const revisionResult = revisionClock + ? acceptFencedSourceRevisionClock( + previousClock, + revisionClock, + entry.retiredRevisionClockEpochs[clockDomain], + ) + : acceptSourceRevision( + entry.sourceRevisions[invalidation.domain], + invalidation.revision, + ); + if (!revisionResult.accepted) continue; + if (revisionClock) { + if (clockDomain === 'subscription') { + // Current producers emit one subscription write as both the v2 + // event and its legacy billing alias. A valid billing clock is the + // shared identity across those aliases, independent of arrival + // order, so the second frame must not schedule a trailing refresh. + entry.sourceRevisionClocks.subscription = revisionClock; + entry.sourceRevisionClocks.legacy = revisionClock; + } else { + entry.sourceRevisionClocks.wallet = revisionClock; + } + } else if (invalidation.revision) { + entry.sourceRevisions[invalidation.domain] = invalidation.revision; + } + entry.sourceGapDetected = revisionResult.gap; + const reason = + revisionResult.gap + ? 'revision-gap' + : revisionResult.epochChanged + ? 'revision-epoch-change' + : invalidation.reason ?? `${invalidation.domain}-invalidation`; + this.markStatus(entry, hasProjection(entry) ? 'stale' : 'loading', reason); + this.requestRefresh(entry, reason, true); + } + } + + reconnect(workspaceId?: string): void { + if (this.disposed) return; + const requested = workspaceId?.trim() ?? ''; + for (const entry of this.entries.values()) { + if (requested && entry.key.workspaceId !== requested) continue; + if (!this.hasActiveInterest(entry)) continue; + if (entry.status === 'access-revoked') continue; + this.markStatus(entry, hasProjection(entry) ? 'stale' : 'loading', 'reconnect'); + this.requestRefresh(entry, 'reconnect', true); + } + } + + refreshAll(reason = 'catch-up'): void { + if (this.disposed) return; + for (const entry of this.entries.values()) { + if (!this.hasActiveInterest(entry)) continue; + if (entry.status === 'access-revoked') continue; + if (reason === 'poll-floor' && entry.retryTimer) continue; + this.markStatus(entry, hasProjection(entry) ? 'stale' : 'loading', reason); + this.requestRefresh(entry, reason, true); + } + } + + markWorkspaceUnavailable( + workspaceId: string, + error = 'workspace_directory_unavailable', + ): void { + const requested = workspaceId.trim(); + if (!requested) return; + for (const entry of this.entries.values()) { + if (entry.key.workspaceId !== requested || entry.status === 'access-revoked') continue; + if ( + entry.status === 'error' && + entry.errorCode === error && + entry.retryTimer + ) { + continue; + } + entry.status = 'error'; + entry.errorCode = error; + entry.reason = error; + entry.revision += 1n; + this.scheduleRetry(entry); + this.publish(entry); + } + } + + revokeWorkspace(workspaceId: string, reason = 'workspace-not-authorized'): void { + const requested = workspaceId.trim(); + if (!requested) return; + for (const entry of this.entries.values()) { + if (entry.key.workspaceId !== requested) continue; + this.revokeEntry(entry, reason); + } + } + + retainWorkspaceMember(workspaceId: string, workspaceMemberId: string): void { + const requestedWorkspaceId = workspaceId.trim(); + const requestedMemberId = workspaceMemberId.trim(); + if (!requestedWorkspaceId || !requestedMemberId) return; + for (const entry of this.entries.values()) { + if ( + entry.key.workspaceId === requestedWorkspaceId && + entry.key.workspaceMemberId !== requestedMemberId + ) { + this.revokeEntry(entry, 'workspace-member-changed'); + } + } + } + + authorizeWorkspaceMember(keyInput: WorkspaceBillingRuntimeKey): void { + const key = normalizeKey(keyInput); + const entry = this.entries.get(runtimeKey(key)); + if (!entry || entry.status !== 'access-revoked') return; + entry.status = 'loading'; + entry.errorCode = null; + entry.reason = 'membership-reauthorized'; + entry.observedAt = null; + entry.revision += 1n; + this.publish(entry); + } + + peek(keyInput: WorkspaceBillingRuntimeKey): WorkspaceBillingRuntimeResult | null { + const key = normalizeKey(keyInput); + const entry = this.entries.get(runtimeKey(key)); + return entry ? this.result(entry) : null; + } + + peekForClient(clientId: string, workspaceId: string): WorkspaceBillingRuntimeResult | null { + const interest = this.clients.get(clientId.trim()); + if (!interest) return null; + const requestedWorkspaceId = workspaceId.trim(); + const key = [...interest.keys].find( + (candidate) => + this.entries.get(candidate)?.key.workspaceId === requestedWorkspaceId, + ); + const entry = key ? this.entries.get(key) : null; + if (!entry) return null; + return this.result(entry); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.scheduler.clearInterval(this.pollTimer); + this.scheduler.clearInterval(this.interestSweepTimer); + if (this.refreshQueueTimer) this.scheduler.clearTimeout(this.refreshQueueTimer); + this.refreshQueueTimer = null; + this.refreshQueue.length = 0; + for (const entry of this.entries.values()) { + if (entry.retryTimer) this.scheduler.clearTimeout(entry.retryTimer); + entry.retryTimer = null; + entry.queued = false; + entry.queuedReason = null; + entry.pending = false; + this.resolveWaiters(entry); + } + this.entries.clear(); + this.clients.clear(); + } + + private acceptClientInterest( + entry: RuntimeEntry, + options: WorkspaceBillingRuntimeReadOptions, + ): boolean { + const clientId = options.clientId?.trim() ?? ''; + const generationText = options.clientGeneration?.trim() ?? ''; + if (!clientId && !generationText) { + const wasInterested = this.hasActiveInterest(entry); + entry.legacyInterestExpiresAt = this.scheduler.now() + this.interestLeaseMs; + entry.uninterestedAt = null; + if (!wasInterested) this.publishInterestSet(); + return false; + } + if (!clientId || !/^(?:0|[1-9]\d*)$/.test(generationText)) { + throw new WorkspaceBillingInterestError('invalid_generation'); + } + const generation = BigInt(generationText); + const keyText = runtimeKey(entry.key); + const current = this.clients.get(clientId); + if (current) { + if (generation < current.generation) { + throw new WorkspaceBillingInterestError( + 'stale_generation', + current.generation.toString(), + ); + } + if (generation === current.generation) { + if (!current.keys.has(keyText)) { + throw new WorkspaceBillingInterestError( + 'generation_payload_mismatch', + current.generation.toString(), + ); + } + current.expiresAt = this.scheduler.now() + this.interestLeaseMs; + return false; + } + } + this.setClientInterests({ + clientId, + clientGeneration: generationText, + interests: [entry.key], + }); + return current != null; + } + + private entryFor(key: WorkspaceBillingRuntimeKey): RuntimeEntry { + const keyText = runtimeKey(key); + const existing = this.entries.get(keyText); + if (existing) return existing; + this.evictUninterestedEntries(); + if (this.entries.size >= this.maxEntries) { + throw new WorkspaceBillingInterestError('interest_capacity_exceeded'); + } + const entry: RuntimeEntry = { + key, + legacyInterestExpiresAt: null, + uninterestedAt: this.scheduler.now(), + projection: EMPTY_PROJECTION, + status: 'loading', + revision: 0n, + observedAt: null, + retryAt: null, + errorCode: null, + reason: 'first-interest', + sourceGapDetected: false, + sourceRevisions: {}, + sourceRevisionClocks: {}, + retiredRevisionClockEpochs: { + subscription: new Set(), + wallet: new Set(), + }, + inFlight: null, + queued: false, + queuedReason: null, + pending: false, + pendingReason: null, + attempt: 0n, + retryAttempt: 0, + retryTimer: null, + waiters: [], + }; + this.entries.set(keyText, entry); + return entry; + } + + private hasActiveInterest(entry: RuntimeEntry): boolean { + if ( + entry.legacyInterestExpiresAt != null && + entry.legacyInterestExpiresAt > this.scheduler.now() + ) { + return true; + } + const keyText = runtimeKey(entry.key); + for (const interest of this.clients.values()) { + if ( + interest.expiresAt > this.scheduler.now() && + interest.keys.has(keyText) + ) return true; + } + return false; + } + + private quietIfUninterested(entry: RuntimeEntry): void { + if (this.hasActiveInterest(entry)) return; + if (entry.retryTimer) this.scheduler.clearTimeout(entry.retryTimer); + entry.retryTimer = null; + entry.retryAt = null; + const canceledQueuedRead = this.removeQueuedEntry(entry); + entry.pending = false; + entry.pendingReason = null; + entry.uninterestedAt ??= this.scheduler.now(); + if (canceledQueuedRead && !entry.inFlight) this.resolveWaiters(entry); + } + + private activeRuntimeKeysExcept(excludedClientId: string): Set<string> { + const keys = new Set<string>(); + const now = this.scheduler.now(); + for (const [clientId, interest] of this.clients) { + if (clientId === excludedClientId || interest.expiresAt <= now) continue; + for (const key of interest.keys) keys.add(key); + } + for (const entry of this.entries.values()) { + if ( + entry.legacyInterestExpiresAt != null && + entry.legacyInterestExpiresAt > now + ) { + keys.add(runtimeKey(entry.key)); + } + } + return keys; + } + + private handleInterestMutation(previous: Set<string>, next: Set<string>): void { + for (const key of previous) { + if (next.has(key)) continue; + const entry = this.entries.get(key); + if (entry) this.quietIfUninterested(entry); + } + for (const key of next) { + const entry = this.entries.get(key); + if (entry) entry.uninterestedAt = null; + } + this.publishInterestSet(); + } + + private sweepExpiredInterests(): void { + if (this.disposed) return; + const now = this.scheduler.now(); + let mutated = false; + for (const [clientId, interest] of this.clients) { + if (interest.expiresAt > now) continue; + this.clients.delete(clientId); + for (const key of interest.keys) { + const entry = this.entries.get(key); + if (entry) this.quietIfUninterested(entry); + } + mutated = true; + } + for (const entry of this.entries.values()) { + if ( + entry.legacyInterestExpiresAt != null && + entry.legacyInterestExpiresAt <= now + ) { + entry.legacyInterestExpiresAt = null; + this.quietIfUninterested(entry); + mutated = true; + } + } + this.evictUninterestedEntries(); + if (mutated) this.publishInterestSet(); + } + + private evictUninterestedEntries(): void { + const now = this.scheduler.now(); + const candidates = [...this.entries.entries()] + .filter( + ([, entry]) => + !this.hasActiveInterest(entry) && !entry.inFlight && !entry.queued, + ) + .sort(([, left], [, right]) => + (left.uninterestedAt ?? left.observedAt ?? 0) - + (right.uninterestedAt ?? right.observedAt ?? 0), + ); + for (const [key, entry] of candidates) { + const inactiveAt = entry.uninterestedAt ?? entry.observedAt ?? now; + const overCapacity = this.entries.size >= this.maxEntries; + if (!overCapacity && now - inactiveAt < this.entryRetentionMs) continue; + if (entry.retryTimer) this.scheduler.clearTimeout(entry.retryTimer); + this.entries.delete(key); + } + } + + private publishInterestSet(): void { + this.options.onInterestSetChange?.(this.interestedKeysWithoutSweep()); + } + + private interestedKeysWithoutSweep(): WorkspaceBillingRuntimeKey[] { + const keys = new Set<string>(); + const now = this.scheduler.now(); + for (const interest of this.clients.values()) { + if (interest.expiresAt <= now) continue; + for (const key of interest.keys) keys.add(key); + } + for (const entry of this.entries.values()) { + if ( + entry.legacyInterestExpiresAt != null && + entry.legacyInterestExpiresAt > now + ) { + keys.add(runtimeKey(entry.key)); + } + } + return [...keys] + .map((key) => this.entries.get(key)?.key) + .filter((key): key is WorkspaceBillingRuntimeKey => Boolean(key)) + .map((key) => ({ ...key })); + } + + private interestLease( + clientId: string, + interest: ClientInterest, + ): WorkspaceBillingRuntimeInterestLease { + return { + clientId, + acceptedGeneration: interest.generation.toString(), + leaseExpiresAt: new Date(interest.expiresAt).toISOString(), + }; + } + + private requestRefresh(entry: RuntimeEntry, reason: string, force: boolean): void { + if (entry.status === 'access-revoked') return; + if (entry.inFlight) { + if (force) { + entry.pending = true; + entry.pendingReason = strongerReason(entry.pendingReason, reason); + } + return; + } + if (entry.queued) { + entry.queuedReason = strongerReason(entry.queuedReason, reason); + return; + } + entry.queued = true; + entry.queuedReason = reason; + this.refreshQueue.push(entry); + this.drainRefreshQueue(); + } + + private drainRefreshQueue(): void { + if (this.disposed) return; + if (this.refreshQueueTimer) { + this.scheduler.clearTimeout(this.refreshQueueTimer); + this.refreshQueueTimer = null; + } + const now = this.scheduler.now(); + while ( + this.refreshStarts.length > 0 && + now - this.refreshStarts[0]! >= this.refreshStartWindowMs + ) { + this.refreshStarts.shift(); + } + while ( + this.activeRefreshes < this.maxConcurrentRefreshes && + this.refreshStarts.length < this.maxRefreshStartsPerWindow + ) { + const entry = this.refreshQueue.shift(); + if (!entry) break; + if (!entry.queued) continue; + entry.queued = false; + const reason = entry.queuedReason ?? 'queued-refresh'; + entry.queuedReason = null; + if (entry.status === 'access-revoked' || !this.hasActiveInterest(entry)) { + this.resolveWaiters(entry); + continue; + } + this.activeRefreshes += 1; + this.refreshStarts.push(this.scheduler.now()); + this.startRefresh(entry, reason); + } + if ( + this.refreshQueue.some((entry) => entry.queued) && + this.activeRefreshes < this.maxConcurrentRefreshes && + this.refreshStarts.length >= this.maxRefreshStartsPerWindow + ) { + const delay = Math.max( + 1, + this.refreshStartWindowMs - + (this.scheduler.now() - this.refreshStarts[0]!), + ); + this.refreshQueueTimer = this.scheduler.setTimeout(() => { + this.refreshQueueTimer = null; + this.drainRefreshQueue(); + }, delay); + this.refreshQueueTimer.unref?.(); + } + } + + private startRefresh(entry: RuntimeEntry, reason: string): void { + if (this.disposed) return; + if (entry.retryTimer) { + this.scheduler.clearTimeout(entry.retryTimer); + entry.retryTimer = null; + entry.retryAt = null; + } + const attempt = ++entry.attempt; + const refresh = Promise.resolve() + .then(() => this.options.fetchProjection(entry.key)) + .then((projection) => { + if (entry.attempt !== attempt || this.disposed) return; + validateProjectionScope(entry.key, projection); + validateProjectionRevision(entry, projection); + recordProjectionRevisions(entry, projection); + entry.projection = cloneProjection(projection); + entry.observedAt = this.scheduler.now(); + entry.retryAt = null; + entry.errorCode = null; + entry.retryAttempt = 0; + entry.revision += 1n; + entry.status = 'fresh'; + entry.reason = reason; + this.publish(entry); + }) + .catch((error: unknown) => { + if (entry.attempt !== attempt || this.disposed) return; + const code = errorCode(error); + if (isAccessRevokedError(error)) { + this.revokeEntry(entry, code); + return; + } + entry.status = 'error'; + entry.errorCode = code; + entry.reason = reason; + entry.revision += 1n; + this.scheduleRetry(entry); + this.publish(entry); + }) + .finally(() => { + this.activeRefreshes = Math.max(0, this.activeRefreshes - 1); + if (entry.attempt !== attempt) { + this.drainRefreshQueue(); + return; + } + entry.inFlight = null; + if (entry.pending && !this.disposed && entry.status !== 'access-revoked') { + const trailingReason = entry.pendingReason ?? 'trailing-invalidation'; + entry.pending = false; + entry.pendingReason = null; + this.requestRefresh(entry, trailingReason, true); + this.drainRefreshQueue(); + return; + } + this.resolveWaiters(entry); + this.drainRefreshQueue(); + }); + entry.inFlight = refresh; + this.markStatus( + entry, + hasProjection(entry) ? 'refreshing' : 'loading', + reason, + ); + } + + private scheduleRetry(entry: RuntimeEntry): void { + if (entry.retryTimer || this.retryDelaysMs.length === 0 || this.disposed) return; + if (entry.retryAttempt >= this.retryDelaysMs.length) return; + const delay = this.retryDelaysMs[entry.retryAttempt]!; + entry.retryAttempt += 1; + entry.retryAt = this.scheduler.now() + delay; + entry.retryTimer = this.scheduler.setTimeout(() => { + entry.retryTimer = null; + entry.retryAt = null; + if (entry.status === 'access-revoked' || this.disposed) return; + this.requestRefresh(entry, 'bounded-retry', true); + }, delay); + entry.retryTimer.unref?.(); + } + + private revokeEntry(entry: RuntimeEntry, reason: string): void { + const keyText = runtimeKey(entry.key); + let interestChanged = entry.legacyInterestExpiresAt != null; + entry.legacyInterestExpiresAt = null; + for (const [clientId, interest] of this.clients) { + if (!interest.keys.delete(keyText)) continue; + interestChanged = true; + if (interest.keys.size === 0) this.clients.delete(clientId); + } + entry.attempt += 1n; + entry.inFlight = null; + this.removeQueuedEntry(entry); + entry.pending = false; + entry.pendingReason = null; + if (entry.retryTimer) this.scheduler.clearTimeout(entry.retryTimer); + entry.retryTimer = null; + entry.retryAt = null; + entry.projection = EMPTY_PROJECTION; + entry.status = 'access-revoked'; + entry.errorCode = 'workspace_not_authorized'; + entry.reason = reason; + entry.observedAt = this.scheduler.now(); + entry.revision += 1n; + this.publish(entry); + this.resolveWaiters(entry); + this.quietIfUninterested(entry); + if (interestChanged) this.publishInterestSet(); + } + + private removeQueuedEntry(entry: RuntimeEntry): boolean { + const wasQueued = entry.queued; + entry.queued = false; + entry.queuedReason = null; + for (let index = this.refreshQueue.length - 1; index >= 0; index -= 1) { + if (this.refreshQueue[index] === entry) this.refreshQueue.splice(index, 1); + } + return wasQueued; + } + + private markStatus( + entry: RuntimeEntry, + status: WorkspaceBillingRuntimeState['status'], + reason: string, + ): void { + if (entry.status === status && entry.reason === reason) return; + entry.status = status; + entry.reason = reason; + entry.revision += 1n; + this.publish(entry); + } + + private waitForIdle(entry: RuntimeEntry): Promise<void> { + if (!entry.inFlight && !entry.queued && !entry.pending) return Promise.resolve(); + return new Promise((resolve) => { + entry.waiters.push(resolve); + }); + } + + private resolveWaiters(entry: RuntimeEntry): void { + const waiters = entry.waiters.splice(0); + for (const resolve of waiters) resolve(); + } + + private result(entry: RuntimeEntry): WorkspaceBillingRuntimeResult { + return { + projection: this.isHardExpired(entry) + ? EMPTY_PROJECTION + : cloneProjection(entry.projection), + state: this.state(entry), + }; + } + + private state(entry: RuntimeEntry): WorkspaceBillingRuntimeState { + return { + workspaceId: entry.key.workspaceId, + workspaceMemberId: entry.key.workspaceMemberId, + status: entry.status, + revision: entry.revision.toString(), + observedAt: timestamp(entry.observedAt), + softExpiresAt: timestamp( + entry.observedAt == null ? null : entry.observedAt + this.softTtlMs, + ), + hardExpiresAt: timestamp( + entry.observedAt == null ? null : entry.observedAt + this.hardTtlMs, + ), + retryAt: timestamp(entry.retryAt), + errorCode: entry.errorCode, + reason: entry.reason, + sourceGapDetected: entry.sourceGapDetected, + }; + } + + private publish(entry: RuntimeEntry): void { + this.options.onStateChange?.(this.state(entry)); + } + + private isHardExpired(entry: RuntimeEntry): boolean { + return ( + entry.observedAt != null && + this.scheduler.now() - entry.observedAt >= this.hardTtlMs + ); + } + + private assertUsable(): void { + if (this.disposed) throw new Error('workspace billing runtime is disposed'); + } +} + +export function createWorkspaceBillingRuntimeCoordinator( + options: WorkspaceBillingRuntimeCoordinatorOptions, +): WorkspaceBillingRuntimeCoordinator { + return new WorkspaceBillingRuntimeCoordinator(options); +} + +export function shouldEmitWorkspaceBillingRuntimeNudge( + state: WorkspaceBillingRuntimeState, +): boolean { + if ( + state.status === 'loading' || + state.status === 'stale' || + state.status === 'refreshing' + ) { + return false; + } + switch (state.reason) { + case 'explicit-billing-read': + case 'vela-billing-changed': + case 'vela-billing-subscription-changed': + case 'vela-wallet-balance-changed': + return false; + default: + return true; + } +} + +function defaultScheduler(): WorkspaceBillingRuntimeScheduler { + return { + now: () => Date.now(), + setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), + clearTimeout: (timer) => clearTimeout(timer), + setInterval: (callback, delayMs) => setInterval(callback, delayMs), + clearInterval: (timer) => clearInterval(timer), + }; +} + +function normalizeKey(input: WorkspaceBillingRuntimeKey): WorkspaceBillingRuntimeKey { + const workspaceId = input.workspaceId.trim(); + const workspaceMemberId = input.workspaceMemberId.trim(); + if (!workspaceId || !workspaceMemberId) { + throw new Error('workspace billing runtime requires workspace and member identity'); + } + return { workspaceId, workspaceMemberId }; +} + +function runtimeKey(key: WorkspaceBillingRuntimeKey): string { + return `${key.workspaceId}\0${key.workspaceMemberId}`; +} + +function sameStringSet(left: Set<string>, right: Set<string>): boolean { + if (left.size !== right.size) return false; + for (const value of left) { + if (!right.has(value)) return false; + } + return true; +} + +function hasProjection(entry: RuntimeEntry): boolean { + return Boolean(entry.projection.snapshot || entry.projection.workspaceBalance); +} + +function validateProjectionScope( + key: WorkspaceBillingRuntimeKey, + projection: VelaWorkspaceBillingProjection, +): void { + const snapshot = projection.snapshot; + const balance = projection.workspaceBalance; + if (!snapshot && !balance) throw new Error('workspace_billing_unavailable'); + if ( + snapshot && + ( + snapshot.billingScopeVersion !== 2 || + snapshot.workspaceId !== key.workspaceId || + snapshot.workspaceMemberId !== key.workspaceMemberId + ) + ) { + throw new Error('workspace_billing_scope_mismatch'); + } + if ( + balance && + ( + balance.billingScopeVersion !== 2 || + balance.workspaceId !== key.workspaceId || + balance.workspaceMemberId !== key.workspaceMemberId + ) + ) { + throw new Error('workspace_billing_scope_mismatch'); + } +} + +function validateProjectionRevision( + entry: RuntimeEntry, + projection: VelaWorkspaceBillingProjection, +): void { + const snapshot = projection.snapshot; + if (!snapshot) return; + const expectedBilling = + entry.sourceRevisions.subscription ?? entry.sourceRevisions.legacy; + const expectedWallet = entry.sourceRevisions.wallet; + const expectedBillingClock = + entry.sourceRevisionClocks.subscription ?? entry.sourceRevisionClocks.legacy; + const expectedWalletClock = entry.sourceRevisionClocks.wallet; + const actualBillingClock = normalizeRevisionClock( + snapshot.revisionClocks?.billing, + ); + const actualWalletClock = normalizeRevisionClock( + snapshot.revisionClocks?.wallet, + ); + const previousSnapshot = entry.projection.snapshot; + const previousBillingClock = normalizeRevisionClock( + previousSnapshot?.revisionClocks?.billing, + ); + const previousWalletClock = normalizeRevisionClock( + previousSnapshot?.revisionClocks?.wallet, + ); + const billingClockComparable = Boolean( + expectedBillingClock && actualBillingClock, + ); + const walletClockComparable = Boolean( + expectedWalletClock && actualWalletClock, + ); + if ( + (expectedBillingClock && + actualBillingClock && + fencedRevisionClockIsBehind( + actualBillingClock, + expectedBillingClock, + previousBillingClock, + entry.retiredRevisionClockEpochs.subscription, + )) || + (expectedWalletClock && + actualWalletClock && + fencedRevisionClockIsBehind( + actualWalletClock, + expectedWalletClock, + previousWalletClock, + entry.retiredRevisionClockEpochs.wallet, + )) || + (!billingClockComparable && + revisionIsBehind(snapshot.revisions.billing, expectedBilling)) || + (!walletClockComparable && + revisionIsBehind(snapshot.revisions.wallet, expectedWallet)) + ) { + throw Object.assign(new Error('workspace billing revision not caught up'), { + code: 'workspace_billing_revision_not_caught_up', + }); + } +} + +function recordProjectionRevisions( + entry: RuntimeEntry, + projection: VelaWorkspaceBillingProjection, +): void { + const snapshot = projection.snapshot; + if (!snapshot) return; + const billingClock = normalizeRevisionClock(snapshot.revisionClocks?.billing); + const walletClock = normalizeRevisionClock(snapshot.revisionClocks?.wallet); + const previousSnapshot = entry.projection.snapshot; + const previousBillingClock = normalizeRevisionClock( + previousSnapshot?.revisionClocks?.billing, + ); + const previousWalletClock = normalizeRevisionClock( + previousSnapshot?.revisionClocks?.wallet, + ); + if (billingClock) { + retireRevisionClockEpoch( + entry.retiredRevisionClockEpochs.subscription, + previousBillingClock, + billingClock, + ); + retireRevisionClockEpoch( + entry.retiredRevisionClockEpochs.subscription, + entry.sourceRevisionClocks.subscription ?? + entry.sourceRevisionClocks.legacy ?? + null, + billingClock, + ); + entry.sourceRevisionClocks.subscription = billingClock; + entry.sourceRevisionClocks.legacy = billingClock; + } + if (walletClock) { + retireRevisionClockEpoch( + entry.retiredRevisionClockEpochs.wallet, + previousWalletClock, + walletClock, + ); + retireRevisionClockEpoch( + entry.retiredRevisionClockEpochs.wallet, + entry.sourceRevisionClocks.wallet ?? null, + walletClock, + ); + entry.sourceRevisionClocks.wallet = walletClock; + } + if (revisionCanAdvance(entry.sourceRevisions.subscription, snapshot.revisions.billing)) { + entry.sourceRevisions.subscription = snapshot.revisions.billing; + } + if (revisionCanAdvance(entry.sourceRevisions.legacy, snapshot.revisions.billing)) { + entry.sourceRevisions.legacy = snapshot.revisions.billing; + } + if (revisionCanAdvance(entry.sourceRevisions.wallet, snapshot.revisions.wallet)) { + entry.sourceRevisions.wallet = snapshot.revisions.wallet; + } +} + +function revisionIsBehind(actual: string, expected: string | undefined): boolean { + if (!expected || !/^\d+$/.test(actual) || !/^\d+$/.test(expected)) return false; + return BigInt(actual) < BigInt(expected); +} + +function revisionCanAdvance(previous: string | undefined, next: string): boolean { + if (!previous) return true; + if (previous === next) return false; + if (/^\d+$/.test(previous) && /^\d+$/.test(next)) { + return BigInt(next) > BigInt(previous); + } + return true; +} + +function normalizeRevisionClock( + value: WorkspaceBillingRevisionClock | undefined, +): WorkspaceBillingRevisionClock | null { + const epoch = value?.epoch?.trim() ?? ''; + const counter = value?.counter?.trim() ?? ''; + if (!epoch || !/^(?:0|[1-9]\d*)$/.test(counter)) return null; + return { epoch, counter }; +} + +function fencedRevisionClockIsBehind( + actual: WorkspaceBillingRevisionClock, + expected: WorkspaceBillingRevisionClock, + previousAuthoritative: WorkspaceBillingRevisionClock | null, + retiredEpochs: ReadonlySet<string>, +): boolean { + if (retiredEpochs.has(actual.epoch)) return true; + if (actual.epoch === expected.epoch) { + return BigInt(actual.counter) < BigInt(expected.counter); + } + // Snapshot reads are producer-fenced: an unseen epoch is the new + // authoritative baseline even if it advanced again after the thin event. + // Remaining on the last committed epoch, however, proves the read model has + // not crossed the event's epoch fence and must fail closed. + return previousAuthoritative?.epoch === actual.epoch; +} + +function retireRevisionClockEpoch( + retired: Set<string>, + previous: WorkspaceBillingRevisionClock | null, + authoritative: WorkspaceBillingRevisionClock, +): void { + if (previous && previous.epoch !== authoritative.epoch) { + retired.add(previous.epoch); + while (retired.size > MAX_RETIRED_REVISION_EPOCHS) { + const oldest = retired.values().next().value as string | undefined; + if (!oldest) break; + retired.delete(oldest); + } + } +} + +function cloneProjection( + projection: VelaWorkspaceBillingProjection, +): VelaWorkspaceBillingProjection { + return { + snapshot: cloneSnapshot(projection.snapshot), + workspaceBalance: cloneBalance(projection.workspaceBalance), + }; +} + +function cloneSnapshot(snapshot: WorkspaceBillingSnapshot | null): WorkspaceBillingSnapshot | null { + if (!snapshot) return null; + return { + ...snapshot, + billing: { ...snapshot.billing }, + wallet: { ...snapshot.wallet }, + revisions: { ...snapshot.revisions }, + ...(snapshot.revisionClocks + ? { + revisionClocks: { + billing: { ...snapshot.revisionClocks.billing }, + wallet: { ...snapshot.revisionClocks.wallet }, + }, + } + : {}), + }; +} + +function cloneBalance(balance: WorkspaceWalletBalance | null): WorkspaceWalletBalance | null { + return balance ? { ...balance } : null; +} + +function timestamp(value: number | null): string | null { + return value == null ? null : new Date(value).toISOString(); +} + +function errorCode(error: unknown): string { + if ( + error && + typeof error === 'object' && + 'code' in error && + typeof error.code === 'string' && + error.code.trim() + ) { + return error.code.trim(); + } + if (error instanceof Error && error.message.trim()) { + return error.message.trim().replace(/\s+/g, '_').toLowerCase(); + } + return 'workspace_billing_refresh_failed'; +} + +function isAccessRevokedError(error: unknown): boolean { + const code = errorCode(error); + return ( + error instanceof WorkspaceBillingAccessRevokedError || + code === 'workspace_not_authorized' || + code === 'workspace_access_revoked' || + code === 'forbidden' + ); +} + +function acceptSourceRevision( + previous: string | undefined, + next: string | undefined, +): { accepted: boolean; gap: boolean; epochChanged: boolean } { + const normalized = next?.trim() ?? ''; + if (!normalized) return { accepted: true, gap: false, epochChanged: false }; + if (!previous) return { accepted: true, gap: false, epochChanged: false }; + if (previous === normalized) return { accepted: false, gap: false, epochChanged: false }; + if (/^\d+$/.test(previous) && /^\d+$/.test(normalized)) { + const oldValue = BigInt(previous); + const newValue = BigInt(normalized); + if (newValue <= oldValue) { + return { accepted: false, gap: false, epochChanged: false }; + } + return { + accepted: true, + gap: newValue > oldValue + 1n, + epochChanged: false, + }; + } + // Opaque tokens support equality dedupe only. A changed token is a safe + // invalidation; the authoritative read decides the actual value. + return { accepted: true, gap: false, epochChanged: false }; +} + +function acceptSourceRevisionClock( + previous: WorkspaceBillingRevisionClock | undefined, + next: WorkspaceBillingRevisionClock, +): { accepted: boolean; gap: boolean; epochChanged: boolean } { + if (!previous) return { accepted: true, gap: false, epochChanged: false }; + if (previous.epoch !== next.epoch) { + return { accepted: true, gap: false, epochChanged: true }; + } + const oldValue = BigInt(previous.counter); + const newValue = BigInt(next.counter); + if (newValue <= oldValue) { + return { accepted: false, gap: false, epochChanged: false }; + } + return { + accepted: true, + gap: newValue > oldValue + 1n, + epochChanged: false, + }; +} + +function acceptFencedSourceRevisionClock( + previous: WorkspaceBillingRevisionClock | undefined, + next: WorkspaceBillingRevisionClock, + retiredEpochs: ReadonlySet<string>, +): { accepted: boolean; gap: boolean; epochChanged: boolean } { + if (retiredEpochs.has(next.epoch) && previous?.epoch !== next.epoch) { + return { accepted: false, gap: false, epochChanged: false }; + } + return acceptSourceRevisionClock(previous, next); +} + +function strongerReason(current: string | null, next: string): string { + if (!current) return next; + const rank = (reason: string): number => { + if (reason === 'revision-gap' || reason === 'reconnect') return 3; + if (reason.includes('invalidation')) return 2; + if (reason === 'poll-floor') return 0; + return 1; + }; + return rank(next) >= rank(current) ? next : current; +} diff --git a/apps/daemon/src/collab/workspace-context.ts b/apps/daemon/src/collab/workspace-context.ts new file mode 100644 index 00000000000..5b3ce3b9612 --- /dev/null +++ b/apps/daemon/src/collab/workspace-context.ts @@ -0,0 +1,326 @@ +import { + buildWorkspacePermissions, + buildWorkspaceSeatSummary, +} from '@open-design/contracts'; +import type { + CollabMemberRole, + WorkspaceBillingState, + WorkspaceCollabContext, + WorkspaceLifecycleState, + WorkspaceMemberStatus, + WorkspaceProviderMode, + WorkspaceType, +} from '@open-design/contracts'; + +// The daemon's single B-integration point . Presence + sync need the +// caller's workspace identity (workspaceMemberId + role + lifecycle). In +// production this provider verifies the request's auth against the B service and +// returns B's CurrentWorkspaceContext for that user; until B is reachable, the +// dev provider below holds an in-memory context that a demo/tools-dev run can +// set. Swapping the provider is the only change when B ships — routes and the +// web client stay put. + +export interface WorkspaceContextRequest { + /** The caller's bearer token (a real provider verifies this against B). */ + authorization?: string | undefined; + /** + * The workspace this caller explicitly selected. Client-facing routes must + * always provide it; it must never be inferred from daemon-global active + * state because one daemon can serve multiple browser tabs concurrently. + */ + workspaceId?: string | undefined; +} + +export interface WorkspaceContextProvider { + current(req: WorkspaceContextRequest): Promise<WorkspaceCollabContext | null>; + /** + * Resolve one request-selected Workspace without reading or mutating any + * daemon-global selection. Client-facing routes use this API exclusively. + */ + resolveExact?( + req: WorkspaceContextRequest & { workspaceId: string }, + ): Promise<WorkspaceCollabContext | null>; + /** + * Dev/demo seam: override the returned context. Absent on a real B-backed + * provider (whose context is derived per-request from the token). + */ + set?(context: WorkspaceCollabContext | null): void; + /** + * Legacy synchronous observation of the most recently resolved context. + * It performs no network I/O and is populated by `current`, `resolveExact`, + * or the dev-only `set` seam. Request authorization must not use it because + * it is neither keyed by Workspace nor guaranteed fresh. + */ + lastKnown?(): WorkspaceCollabContext | null; + /** + * Same cached identity plus a monotonic generation that advances whenever + * an observed authorization identity changes, including A -> B -> A between + * two callers' snapshots. + */ + lastKnownSnapshot?(): { + context: WorkspaceCollabContext | null; + generation: number; + }; +} + +/** + * Preserve the legacy observation API while client-facing routes migrate to + * exact request authority. The wrapper performs no extra network I/O. + * + * `lastKnown()` reflects whichever Workspace the provider most recently + * resolved. It is deliberately not scoped per Workspace and therefore is not + * authority for reads, writes, billing, or background subscriptions. + * + * Authorization generations have a narrower meaning than raw availability. + * Vela maps both a transient timeout and a real signed-out response to `null`, + * so treating every null as a new identity turns A -> transient null -> A into + * a false workspace switch. The raw context still becomes null (and therefore + * fails closed while unavailable), but generation advances only when a + * non-null authoritative identity differs. A later successful A read restores + * availability without fabricating drift. Dev/demo `set(null)` remains an + * explicit authoritative clear and does advance the generation. + */ +export function withLastKnownWorkspaceContext( + provider: WorkspaceContextProvider, +): WorkspaceContextProvider { + let lastKnown: WorkspaceCollabContext | null = null; + let lastIdentityKey: string | null | undefined; + let generation = 0; + const observe = ( + context: WorkspaceCollabContext | null, + nullIsAuthoritative: boolean, + ): void => { + lastKnown = context; + if (context === null && !nullIsAuthoritative) return; + const identityKey = context + ? JSON.stringify([ + context.workspaceId, + context.teamId ?? context.workspaceId, + context.workspaceMemberId, + context.memberStatus, + context.lifecycleState, + ]) + : null; + if (identityKey !== lastIdentityKey) { + generation += 1; + lastIdentityKey = identityKey; + } + }; + return { + ...provider, + async current(req: WorkspaceContextRequest): Promise<WorkspaceCollabContext | null> { + const context = await provider.current(req); + observe(context, false); + return context; + }, + ...(provider.resolveExact + ? { + async resolveExact( + req: WorkspaceContextRequest & { workspaceId: string }, + ): Promise<WorkspaceCollabContext | null> { + const context = await provider.resolveExact!(req); + observe(context, false); + return context; + }, + } + : {}), + // Dev/demo provider only (see `set?` above): a direct override is also a + // same-process source of truth, so update the observation immediately. + ...(provider.set + ? { + set: (context: WorkspaceCollabContext | null) => { + observe(context, true); + provider.set!(context); + }, + } + : {}), + lastKnown: () => lastKnown, + lastKnownSnapshot: () => ({ context: lastKnown, generation }), + }; +} + +const WORKSPACE_TYPES: ReadonlySet<WorkspaceType> = new Set(['personal', 'team']); +const ROLES: ReadonlySet<CollabMemberRole> = new Set(['owner', 'admin', 'member']); +const MEMBER_STATUSES: ReadonlySet<WorkspaceMemberStatus> = new Set(['active', 'removed']); +const LIFECYCLE_STATES: ReadonlySet<WorkspaceLifecycleState> = new Set([ + 'active', + 'billing_past_due', + 'locked', + 'deleting', + 'deleted', +]); +const PROVIDER_MODES: ReadonlySet<WorkspaceProviderMode> = new Set([ + 'platform_credits', + 'personal_byok', +]); +const BILLING_STATES: ReadonlySet<WorkspaceBillingState> = new Set([ + 'free', + 'active', + 'past_due', + 'canceled', + 'inactive', + 'locked', +]); + +/** Fallback billing state derived from lifecycle, used when a dev payload omits + * it. Production always carries B's authoritative `billingState`. */ +function billingStateForLifecycle(lifecycle: WorkspaceLifecycleState): WorkspaceBillingState { + switch (lifecycle) { + case 'active': + return 'active'; + case 'billing_past_due': + return 'past_due'; + case 'locked': + return 'locked'; + default: + return 'inactive'; + } +} + +function nonNegativeInt(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : fallback; +} + +/** + * The URL of the workspace settings/management console on the cloud web app. + * Team actions (create, invite, members, billing) live there — the local client + * only links out to them. Prefers an explicit value the upstream context carries; + * otherwise builds one from `OD_VELA_WEB_URL` when configured. Undefined when + * neither is available (the client then hides the settings entry). + */ +export function resolveWorkspaceSettingsUrl( + workspaceId: string, + explicit: unknown, + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + // B's web console supports workspace deep links (?workspaceId=…): the target + // page opens directly when it matches the account's Active Workspace, and + // otherwise asks the user to confirm the switch — the confirmation IS the + // explicit user action that may change B's Active Workspace (the client + // itself never PUTs it). A bare /settings link would depend on whatever + // workspace another device left active, so every console link pins the id. + if (typeof explicit === 'string' && explicit.trim()) { + return withWorkspaceDeepLink(explicit.trim(), workspaceId); + } + const base = env.OD_VELA_WEB_URL?.trim(); + if (!base) return undefined; + return withWorkspaceDeepLink(`${base.replace(/\/$/, '')}/settings`, workspaceId); +} + +function withWorkspaceDeepLink(url: string, workspaceId: string): string { + try { + const parsed = new URL(url); + if (!parsed.searchParams.get('workspaceId') && workspaceId.trim()) { + parsed.searchParams.set('workspaceId', workspaceId.trim()); + } + return parsed.toString(); + } catch { + return url; + } +} + +/** + * Validate an untrusted workspace-context payload (dev PUT body / env). Returns + * the typed context or null if any required enum field is missing or out of enum. + * Permissions and the seat summary are DERIVED through the contract helpers + * (B's `buildWorkspacePermissions`/`buildWorkspaceSeatSummary` mirror) so a dev + * payload only needs role + lifecycle + seat counts; the real B proxy passes + * B's already-derived values straight through. + */ +export function parseWorkspaceCollabContext(input: unknown): WorkspaceCollabContext | null { + if (!input || typeof input !== 'object') return null; + const raw = input as Record<string, unknown>; + const workspaceMemberId = typeof raw.workspaceMemberId === 'string' ? raw.workspaceMemberId.trim() : ''; + if (!workspaceMemberId) return null; + if (!WORKSPACE_TYPES.has(raw.workspaceType as WorkspaceType)) return null; + if (!ROLES.has(raw.role as CollabMemberRole)) return null; + if (!MEMBER_STATUSES.has(raw.memberStatus as WorkspaceMemberStatus)) return null; + if (!LIFECYCLE_STATES.has(raw.lifecycleState as WorkspaceLifecycleState)) return null; + + const workspaceType = raw.workspaceType as WorkspaceType; + const role = raw.role as CollabMemberRole; + const memberStatus = raw.memberStatus as WorkspaceMemberStatus; + const lifecycleState = raw.lifecycleState as WorkspaceLifecycleState; + const teamId = typeof raw.teamId === 'string' && raw.teamId.trim() ? raw.teamId.trim() : undefined; + const workspaceId = + typeof raw.workspaceId === 'string' && raw.workspaceId.trim() + ? raw.workspaceId.trim() + : (teamId ?? workspaceMemberId); + const providerMode = PROVIDER_MODES.has(raw.providerMode as WorkspaceProviderMode) + ? (raw.providerMode as WorkspaceProviderMode) + : 'platform_credits'; + const billingState = BILLING_STATES.has(raw.billingState as WorkspaceBillingState) + ? (raw.billingState as WorkspaceBillingState) + : billingStateForLifecycle(lifecycleState); + const planId = typeof raw.planId === 'string' && raw.planId.trim() ? raw.planId.trim() : null; + const seatLimit = nonNegativeInt(raw.seatLimit, workspaceType === 'team' ? 5 : 1); + const usedSeats = nonNegativeInt(raw.usedSeats, 1); + + const context: WorkspaceCollabContext = { + workspaceId, + workspaceType, + workspaceMemberId, + role, + memberStatus, + lifecycleState, + billingState, + planId, + providerMode, + seatSummary: buildWorkspaceSeatSummary({ seatLimit, usedSeats }), + permissions: buildWorkspacePermissions({ role, lifecycleState, memberStatus }), + }; + if (teamId) { + context.teamId = teamId; + } else if (workspaceType === 'team') { + // Invariant (matches the vela provider): a team context always carries + // teamId — the workspace IS the team scope. Collab gates on `teamId`, so + // a dev PUT that omits it must not silently disable the collab plane. + context.teamId = workspaceId; + } + const settingsUrl = resolveWorkspaceSettingsUrl(workspaceId, raw.workspaceSettingsUrl); + if (settingsUrl) context.workspaceSettingsUrl = settingsUrl; + if (typeof raw.teamName === 'string' && raw.teamName.trim()) { + context.teamName = raw.teamName.trim(); + } + // Any workspace type may carry a name here — the dev/demo lane must be able + // to drive a personal workspace's label the same way B does. + if (typeof raw.workspaceName === 'string' && raw.workspaceName.trim()) { + context.workspaceName = raw.workspaceName.trim(); + } + if (typeof raw.displayName === 'string' && raw.displayName.trim()) { + context.displayName = raw.displayName.trim(); + } + if (typeof raw.lastActiveWorkspaceId === 'string' && raw.lastActiveWorkspaceId.trim()) { + context.lastActiveWorkspaceId = raw.lastActiveWorkspaceId.trim(); + } + return context; +} + +/** + * Dev/demo provider: holds a single in-memory context, optionally seeded from + * `OD_DEV_WORKSPACE_CONTEXT` (JSON). Ignores the request — a real B-backed + * provider derives the context per-caller from the token instead. + */ +export function createDevWorkspaceContextProvider( + seed?: WorkspaceCollabContext | null, +): WorkspaceContextProvider { + let context: WorkspaceCollabContext | null = seed ?? readEnvContext(); + return { + current: () => Promise.resolve(context), + resolveExact: ({ workspaceId }) => + Promise.resolve(context?.workspaceId === workspaceId ? context : null), + set: (next) => { + context = next; + }, + }; +} + +function readEnvContext(): WorkspaceCollabContext | null { + const raw = process.env.OD_DEV_WORKSPACE_CONTEXT; + if (!raw) return null; + try { + return parseWorkspaceCollabContext(JSON.parse(raw)); + } catch { + return null; + } +} diff --git a/apps/daemon/src/collab/workspace-hub-subscriptions.ts b/apps/daemon/src/collab/workspace-hub-subscriptions.ts new file mode 100644 index 00000000000..70d30504a69 --- /dev/null +++ b/apps/daemon/src/collab/workspace-hub-subscriptions.ts @@ -0,0 +1,87 @@ +import type { HubEventsSubscriber } from './hub-events-subscriber.js'; + +export interface WorkspaceHubSubscriptionManagerOptions { + start(workspaceId: string): HubEventsSubscriber; + /** Hard process cap; overflow workspaces recover through the poll floor. */ + maxSubscribers?: number; +} + +/** + * Owns the process-wide set of Vela workspace event streams. + * + * Every live stream is backed by an explicit, leased billing interest. A + * daemon-global UI selection is deliberately not a subscription authority: + * one tab switching to B must not stop another tab's A stream. + */ +export class WorkspaceHubSubscriptionManager { + private billingWorkspaceIds = new Set<string>(); + private readonly subscribers = new Map<string, HubEventsSubscriber>(); + private disposed = false; + private readonly maxSubscribers: number; + + constructor(private readonly options: WorkspaceHubSubscriptionManagerOptions) { + this.maxSubscribers = Math.max(1, options.maxSubscribers ?? 8); + } + + setBillingInterests(workspaceIds: Iterable<string>): void { + this.assertUsable(); + const next = new Set( + [...workspaceIds] + .map((workspaceId) => workspaceId.trim()) + .filter(Boolean), + ); + if (sameSet(this.billingWorkspaceIds, next)) return; + this.billingWorkspaceIds = next; + this.reconcile(); + } + + activeWorkspaceIds(): string[] { + return [...this.subscribers.keys()].sort(); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + for (const subscriber of this.subscribers.values()) subscriber.stop(); + this.subscribers.clear(); + this.billingWorkspaceIds.clear(); + } + + private reconcile(): void { + const ordered = [...this.billingWorkspaceIds]; + const desired = new Set<string>(); + for (const workspaceId of ordered) { + if (desired.size >= this.maxSubscribers) break; + desired.add(workspaceId); + } + for (const [workspaceId, subscriber] of this.subscribers) { + if (desired.has(workspaceId)) continue; + subscriber.stop(); + this.subscribers.delete(workspaceId); + } + for (const workspaceId of desired) { + if (this.subscribers.has(workspaceId)) continue; + this.subscribers.set(workspaceId, this.options.start(workspaceId)); + } + } + + private assertUsable(): void { + if (this.disposed) { + throw new Error('workspace hub subscription manager is disposed'); + } + } +} + +export function createWorkspaceHubSubscriptionManager( + options: WorkspaceHubSubscriptionManagerOptions, +): WorkspaceHubSubscriptionManager { + return new WorkspaceHubSubscriptionManager(options); +} + +function sameSet(left: Set<string>, right: Set<string>): boolean { + if (left.size !== right.size) return false; + for (const value of left) { + if (!right.has(value)) return false; + } + return true; +} diff --git a/apps/daemon/src/collab/workspace-invalidation-poller.ts b/apps/daemon/src/collab/workspace-invalidation-poller.ts new file mode 100644 index 00000000000..1254ac15130 --- /dev/null +++ b/apps/daemon/src/collab/workspace-invalidation-poller.ts @@ -0,0 +1,263 @@ +// Collab realtime hop-2 — daemon-side change source for the WORKSPACE-scoped +// thin invalidation events (`/api/workspace/events`). +// +// The daemon already learns cross-user workspace changes by reading Vela (team +// projects, member directory, workspace context). Today the web POLLS the daemon +// for each of those. This poller lets the daemon PUSH instead: it periodically +// reads the same sources the web reads, diffs them against the last-seen value, +// and emits a thin `{ type }` signal only when something actually changed. The +// web then re-fetches the affected resource through its existing loader. +// +// Design invariants: +// - THIN events only. We diff to decide WHETHER to emit; we never ship the +// diff or the list. The web re-fetches. +// - Poll-as-floor safe. This runs in ADDITION to the web polls; a client whose +// SSE never connects keeps polling with zero regression. This poller only +// accelerates delivery, it is not the sole source of truth. +// - Personal-user cheap. Team projects + members are only read when the +// current context is a team workspace; off-team the poller only reads the +// (already web-polled) workspace context to notice a team join. + +import type { + CollabCloudMemberDirectoryEntry, + TeamProject, + WorkspaceCollabContext, + WorkspaceInvalidationSsePayload, +} from '@open-design/contracts'; + +export interface WorkspaceInvalidationPollerDeps { + /** Current workspace context (proxies Vela/B in prod). Gates team reads and + * drives `workspace-context-changed`. Returns null off-team / signed out. */ + getWorkspaceContext: () => Promise<WorkspaceCollabContext | null>; + /** Team-shared project discovery (resource hub). Only called on a team context. */ + listTeamProjects: (context: WorkspaceCollabContext) => Promise<TeamProject[]>; + /** Team member directory. Only called on a team context. */ + listMembers: ( + context: WorkspaceCollabContext, + ) => Promise<CollabCloudMemberDirectoryEntry[]>; + /** Emit a thin workspace invalidation to the connected web sinks. */ + emit: ( + payload: WorkspaceInvalidationSsePayload, + context: WorkspaceCollabContext | null, + ) => void; + /** Poll cadence; defaults to 15s (matches the web team-projects/members poll). */ + pollIntervalMs?: number; + /** Ask the daemon recovery coordinator to inspect locally missing team + * projects. This is a fire-and-forget request: a slow recovery must never + * block workspace context, catalog, or member polling. `projects` is the + * display-cache observation, not pull authorization: the recovery coordinator + * must independently re-read authoritative identity + catalog state. */ + onTeamProjectsObserved?: (input: { + workspaceId: string; + projects: readonly TeamProject[]; + }) => void | Promise<void>; + /** Minimum cadence for the missing-project recovery request. */ + recoveryFloorIntervalMs?: number; + /** Injectable wall clock for deterministic recovery-floor tests. */ + now?: () => number; + onError?: (error: unknown) => void; +} + +const DEFAULT_POLL_INTERVAL_MS = 15_000; +const DEFAULT_RECOVERY_FLOOR_INTERVAL_MS = 30_000; + +/** Stable signature of the workspace context — any change to these fields is a + * meaningful `workspace-context-changed`. Whole-object stringify is fine here: + * the context is small and we only need change-detection, not a minimal diff. */ +function contextSignature(context: WorkspaceCollabContext | null): string { + if (!context) return 'null'; + return JSON.stringify(context); +} + +/** Is this a team workspace we should read team projects / members for? */ +function isTeamContext( + context: WorkspaceCollabContext | null, +): context is WorkspaceCollabContext { + if (!context) return false; + if (context.workspaceType === 'team') return true; + return typeof context.teamId === 'string' && context.teamId.trim().length > 0; +} + +/** Fail-closed prefilter for broad recovery. Keep this identity boundary + * aligned with `activeTeamWorkspaceIdentity` in proactive-content-pull.ts + * without changing `isTeamContext`'s existing invalidation/read semantics. */ +function activeRecoveryWorkspaceId(context: WorkspaceCollabContext): string | null { + const workspaceId = context.workspaceId?.trim() ?? ''; + const resourceTeamId = context.teamId?.trim() ?? ''; + const workspaceMemberId = context.workspaceMemberId?.trim() ?? ''; + if ( + context.workspaceType !== 'team' || + context.memberStatus !== 'active' || + context.lifecycleState !== 'active' || + !workspaceId || + !resourceTeamId || + !workspaceMemberId + ) { + return null; + } + return workspaceId; +} + +function teamProjectsSignature(projects: TeamProject[]): string { + // Sort by id so hub ordering churn does not read as a change; include the + // fields whose change the "全部项目" view must reflect (membership in the list = + // share/unshare, owner, display name, last update). + const rows = projects + .map((p) => ({ + id: p.projectId, + name: p.name ?? '', + owner: p.ownerMemberId ?? '', + updatedAt: p.updatedAt ?? 0, + })) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + return JSON.stringify(rows); +} + +function membersSignature(members: CollabCloudMemberDirectoryEntry[]): string { + const rows = members + .map((m) => ({ id: m.memberId, name: m.displayName ?? '', role: m.role ?? '' })) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + return JSON.stringify(rows); +} + +export interface WorkspaceInvalidationPoller { + /** Run one diff cycle (context → team reads → emit on change). */ + pollOnce(): Promise<void>; + start(): void; + stop(): void; +} + +export function createWorkspaceInvalidationPoller( + deps: WorkspaceInvalidationPollerDeps, +): WorkspaceInvalidationPoller { + const pollIntervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const recoveryFloorIntervalMs = + deps.recoveryFloorIntervalMs ?? DEFAULT_RECOVERY_FLOOR_INTERVAL_MS; + const now = deps.now ?? Date.now; + let timer: NodeJS.Timeout | null = null; + let running = false; + let recoveryWorkspaceId: string | null = null; + let recoveryRequestedAt: number | null = null; + + // `undefined` = never observed (first cycle establishes the baseline WITHOUT + // emitting, so a fresh daemon does not spam a synthetic "changed" on boot). + let contextSig: string | undefined; + let teamProjectsSig: string | undefined; + let membersSig: string | undefined; + + const emitIfChanged = ( + previous: string | undefined, + next: string, + payload: WorkspaceInvalidationSsePayload, + context: WorkspaceCollabContext | null, + ): string => { + if (previous !== undefined && previous !== next) deps.emit(payload, context); + return next; + }; + + const requestMissingProjectRecovery = ( + context: WorkspaceCollabContext, + projects: readonly TeamProject[], + at: number, + ): void => { + if (!deps.onTeamProjectsObserved) return; + const workspaceId = activeRecoveryWorkspaceId(context); + if (!workspaceId) return; + if ( + recoveryWorkspaceId === workspaceId && + recoveryRequestedAt != null && + at - recoveryRequestedAt < recoveryFloorIntervalMs + ) { + return; + } + recoveryWorkspaceId = workspaceId; + recoveryRequestedAt = at; + try { + void Promise.resolve( + deps.onTeamProjectsObserved({ workspaceId, projects }), + ).catch((error) => deps.onError?.(error)); + } catch (error) { + deps.onError?.(error); + } + }; + + async function pollOnce(): Promise<void> { + const observedAt = now(); + const context = await deps.getWorkspaceContext().catch((error) => { + deps.onError?.(error); + return null; + }); + contextSig = emitIfChanged(contextSig, contextSignature(context), { + type: 'workspace-context-changed', + at: observedAt, + }, context); + + if (!isTeamContext(context)) { + recoveryWorkspaceId = null; + recoveryRequestedAt = null; + // Off-team: fold team projects / members to empty so RE-entering a team + // re-emits, but never spawn the team reads for a personal user. + teamProjectsSig = emitIfChanged(teamProjectsSig, teamProjectsSignature([]), { + type: 'team-projects-changed', + at: observedAt, + }, context); + membersSig = emitIfChanged(membersSig, membersSignature([]), { + type: 'members-changed', + at: observedAt, + }, context); + return; + } + + const [projects, members] = await Promise.all([ + deps.listTeamProjects(context).catch((error) => { + deps.onError?.(error); + return null; + }), + deps.listMembers(context).catch((error) => { + deps.onError?.(error); + return null; + }), + ]); + // A transient read failure returns null — keep the last baseline rather than + // emitting a spurious "changed" or clearing the view. + if (projects) { + teamProjectsSig = emitIfChanged(teamProjectsSig, teamProjectsSignature(projects), { + type: 'team-projects-changed', + at: observedAt, + }, context); + requestMissingProjectRecovery(context, projects, observedAt); + } + if (members) { + membersSig = emitIfChanged(membersSig, membersSignature(members), { + type: 'members-changed', + at: observedAt, + }, context); + } + } + + function tick(): void { + if (running) return; + running = true; + void pollOnce() + .catch((error) => deps.onError?.(error)) + .finally(() => { + running = false; + }); + } + + return { + pollOnce, + start(): void { + if (timer) return; + timer = setInterval(tick, pollIntervalMs); + // Do not keep the event loop alive solely for polling. + timer.unref?.(); + }, + stop(): void { + if (timer) { + clearInterval(timer); + timer = null; + } + }, + }; +} diff --git a/apps/daemon/src/collab/workspace-project-home.ts b/apps/daemon/src/collab/workspace-project-home.ts new file mode 100644 index 00000000000..8d18e3e7cba --- /dev/null +++ b/apps/daemon/src/collab/workspace-project-home.ts @@ -0,0 +1,189 @@ +// THE INVARIANT: a project belongs to exactly ONE workspace. +// +// Product ruling (2026-07-21): 「草稿和分享的方案都是和 workspace 绑定的」 — both +// drafts and shared projects belong to a workspace. A project is created in a +// workspace and lives there. Sharing flips `visibility` WITHIN that workspace; +// it does not project the project into a second one. Switching workspaces must +// therefore change which drafts you see, and signing out must hide them. +// +// The live data disagrees, and the data is wrong. On the dogfood database 23 of +// 31 projects held rows in 2-4 workspaces. That is not history to preserve — it +// is the wreckage of `seedPersonalWorkspaceProjects`, which back-filled a row +// for EVERY local project on EVERY personal-workspace read. The proof is in the +// timestamps: a project with four rows has one identical `created_at` across +// all four — a single batch write, not four user actions. Drafts were not +// unbound; they were bound to everywhere, which reads the same as nowhere. +// +// A permissive schema is not evidence of intent either. `workspace_projects` +// keyed on `(workspace_id, project_id)` merely made the forbidden state +// REPRESENTABLE; the migration that widened it renamed the old table to +// `workspace_projects_legacy_single_project`, so `project_id` was the primary +// key first. `db.ts` narrows it back, which turns this invariant into something +// SQLite refuses rather than something a comment asks for. +// +// This module is the ONE place that decides which workspace a project belongs +// to when the rows disagree. It is pure so the rule is testable without a +// daemon. Two consumers: +// - the migration (db.ts) collapses the rows older builds already wrote; +// - the read path (routes/project/index.ts) binds projects that have no row. + +/** A `workspace_projects` row, as far as this invariant is concerned. */ +export interface WorkspaceProjectHomeRow { + projectId: string; + workspaceId: string; + visibility?: string | null; + createdByWorkspaceMemberId?: string | null; + createdAt?: number | null; +} + +/** + * True for a row that records no act — `visibility: 'personal'` with no creator. + * + * This is exactly the shape the old `ensureWorkspaceProjection(project, ctx, + * 'personal')` back-fill wrote, and nothing else writes it: project creation + * stamps the creating member (`routes/project/index.ts`), a share stamps the + * sharer, and a pull stamps the owner (`server.ts + * persistWorkspaceProjectVisibility`). So the predicate cannot mistake a real + * binding for a guess, which is what makes it safe to delete on. + */ +export function isBackfilledWorkspaceProjectRow(row: WorkspaceProjectHomeRow): boolean { + return row.visibility === 'personal' && row.createdByWorkspaceMemberId == null; +} + +/** + * Workspaces that provably host a team plane, inferred from the rows alone. + * + * A `visibility: 'team'` row can only exist in a team workspace — the share path + * refuses one anywhere else (collab/team-share-scope.ts) and startup heals the + * rows older builds wrote in violation. That makes "has ever hosted a share" a + * table-local proxy for "is a team workspace", which is what lets the collapse + * below run as a migration instead of waiting on the signed-in workspace + * directory. Workspaces it has no evidence about are simply absent, and every + * caller treats absence as "no opinion". + */ +export function teamWorkspaceIdsFromRows( + rows: readonly WorkspaceProjectHomeRow[], +): ReadonlySet<string> { + const ids = new Set<string>(); + for (const row of rows) { + if (row.visibility === 'team') ids.add(row.workspaceId); + } + return ids; +} + +/** What one project collapses to. */ +export interface WorkspaceProjectHomeDecision { + projectId: string; + /** + * The workspace the project ends up in. Null means every candidate row was an + * ownerless guess inside a team workspace, where no view would render it — + * the project is left unbound on purpose and the next personal-workspace read + * binds it. See {@link collapseWorkspaceProjectHomes}. + */ + keptWorkspaceId: string | null; + /** Rows to delete. */ + drop: WorkspaceProjectHomeRow[]; +} + +/** + * How strongly a row asserts that the project lives in its workspace. + * Higher wins. The order is evidence, not preference: + * + * 2 — a team share. It binds a hub resource (`resource_hub_resource_id`), so + * dropping it would strand that resource and silently unshare the project + * for every teammate. Nothing outranks it. + * 1 — a row naming who created or pulled the project there. A recorded act. + * 0 — an ownerless personal row. A back-fill guess. + */ +function rowEvidence(row: WorkspaceProjectHomeRow): number { + if (row.visibility === 'team') return 2; + return isBackfilledWorkspaceProjectRow(row) ? 0 : 1; +} + +/** + * Rank workspaces by how many recorded acts they hold across ALL projects. + * + * The tie-break of last resort needs a real signal, and this is the only one the + * table carries: the workspace where the user demonstrably creates and shares + * things is the workspace they actually work in. Without it, two guesses with + * the back-fill's identical `created_at` would fall straight through to an + * alphabetical comparison of opaque ids. + */ +function recordedActsPerWorkspace( + rows: readonly WorkspaceProjectHomeRow[], +): ReadonlyMap<string, number> { + const counts = new Map<string, number>(); + for (const row of rows) { + if (rowEvidence(row) === 0) continue; + counts.set(row.workspaceId, (counts.get(row.workspaceId) ?? 0) + 1); + } + return counts; +} + +/** + * Collapse every project's rows down to the one workspace it belongs to. + * + * The winner is the row with the strongest evidence ({@link rowEvidence}). Ties + * break on, in order: whether the workspace is provably a TEAM workspace (an + * ownerless personal row is suppressed there by + * `workspaceProjectRowBelongsToCurrentWorkspace`, so promoting one would hide + * the project in every view at once); how much real activity that workspace + * holds; the oldest row, so the workspace the user reached first wins; and + * finally the workspace id. That last step is genuinely arbitrary — the + * back-fill copied one `project.createdAt` onto every row it wrote, so two + * guesses in two personal workspaces really do carry identical evidence — but + * it is deterministic, which is what stops two daemons from disagreeing. + * + * NO PROJECT CAN BE LOST HERE. This function only ever names rows to delete + * from `workspace_projects`; it never touches `projects`, and the schema's + * cascade runs the other way (deleting a project deletes its rows, never the + * reverse). Every project that had a row keeps exactly one, with a single + * deliberate exception: when EVERY candidate is an ownerless guess sitting in a + * team workspace, all of them go and `keptWorkspaceId` is null. Keeping one + * would be worse than keeping none — a suppressed row renders nowhere, while an + * unbound project is still listed by `/api/projects`, still opens by id, and is + * bound to the user's personal workspace by the next read. The migration cannot + * make that binding itself: which workspace ids are personal is a signed-in + * fact it cannot see, and the read path can. + */ +export function collapseWorkspaceProjectHomes( + rows: readonly WorkspaceProjectHomeRow[], +): WorkspaceProjectHomeDecision[] { + const teamWorkspaceIds = teamWorkspaceIdsFromRows(rows); + const activity = recordedActsPerWorkspace(rows); + const byProject = new Map<string, WorkspaceProjectHomeRow[]>(); + for (const row of rows) { + const bucket = byProject.get(row.projectId); + if (bucket) bucket.push(row); + else byProject.set(row.projectId, [row]); + } + + const decisions: WorkspaceProjectHomeDecision[] = []; + for (const [projectId, projectRows] of byProject) { + const winner = [...projectRows].sort((a, b) => { + const byEvidence = rowEvidence(b) - rowEvidence(a); + if (byEvidence !== 0) return byEvidence; + const aSuppressed = rowEvidence(a) === 0 && teamWorkspaceIds.has(a.workspaceId); + const bSuppressed = rowEvidence(b) === 0 && teamWorkspaceIds.has(b.workspaceId); + if (aSuppressed !== bSuppressed) return aSuppressed ? 1 : -1; + const byActivity = (activity.get(b.workspaceId) ?? 0) - (activity.get(a.workspaceId) ?? 0); + if (byActivity !== 0) return byActivity; + const byCreatedAt = (a.createdAt ?? 0) - (b.createdAt ?? 0); + if (byCreatedAt !== 0) return byCreatedAt; + return a.workspaceId < b.workspaceId ? -1 : a.workspaceId > b.workspaceId ? 1 : 0; + })[0]; + if (!winner) continue; + + // The one case where keeping the winner is worse than keeping nothing. + const winnerRendersNowhere = + rowEvidence(winner) === 0 && teamWorkspaceIds.has(winner.workspaceId); + if (winnerRendersNowhere) { + decisions.push({ projectId, keptWorkspaceId: null, drop: projectRows }); + continue; + } + const drop = projectRows.filter((row) => row !== winner); + if (drop.length === 0) continue; + decisions.push({ projectId, keptWorkspaceId: winner.workspaceId, drop }); + } + return decisions; +} diff --git a/apps/daemon/src/collab/workspace-projects-reconciler.ts b/apps/daemon/src/collab/workspace-projects-reconciler.ts new file mode 100644 index 00000000000..33c35269060 --- /dev/null +++ b/apps/daemon/src/collab/workspace-projects-reconciler.ts @@ -0,0 +1,448 @@ +// Realtime reconciliation of the local `workspace_projects` SQLite table +// against Vela's team-project catalog — the remote source of truth for "is +// project X still shared to my team, and who owns it." +// +// Two existing daemon triggers already learn about a team-catalog change: +// - `startHubEventsSubscriber`'s `team-projects-changed` push (server.ts). +// - `workspaceInvalidationPoller`'s ~15s diff-and-signal cadence +// (collab/workspace-invalidation-poller.ts). +// Both used to do nothing more than invalidate the DISPLAY cache +// (`teamProjectsDisplayCache`) and nudge the web to refetch — the local +// `workspace_projects` row itself was never re-examined, so a row this +// daemon had already bound could silently disagree with reality forever +// (the concrete, repeatedly-reported case: an owner unshares a project and a +// member's stale local "team" mirror remains directly readable after its +// authoritative catalog membership disappears). This +// module closes that gap: `handleHubTeamProjectsChanged` and +// `handlePolledWorkspaceInvalidation` below hook BOTH existing triggers to +// also run `reconcileWorkspaceProjectsWithRemote`, a real read-modify-write +// pass over this daemon's own team rows. No new polling loop is introduced — +// both hooks ride the cadence their trigger already has. +// +// Scope: PROJECTS only (`workspace_projects`). Plugins / skills / design +// systems have their own generic `workspace_resources` table and are +// deliberately NOT covered here. +// +// Relationship to the two existing point fixes — neither is replaced: +// - `reconcileUnboundProjectBeforeMove` (routes/project/index.ts) stays: it +// is a synchronous, request-scoped correctness gate for ONE specific +// decision (`/move` to personal) that must not wait for the next +// background sweep — a user clicking "move to personal" right now needs +// the answer right now, not in up to 15s. +// - `reconcileLocalRowWithRemoteTeamAccess` (routes/project/index.ts) stays +// too: it runs on every `GET /api/workspaces/:workspaceId/projects` and +// has access to the RICHER `VelaTeamProjectRecord` (`resourceId`, +// `access.canEdit`, `frozen`) that the simpler `listTeamProjects()` this +// module consumes does not carry. It remains the precise, per-request +// completion pass for a brand-new row (in particular, the one that fills +// in the real `resourceHubResourceId` this module leaves null). This +// module additionally covers the direction that pass structurally never +// reaches — an ALREADY-bound row the remote catalog no longer confirms — +// and runs proactively instead of waiting for the next list request. + +import type { WorkspaceInvalidationSsePayload } from '@open-design/contracts'; +import type { ResourceHubPrincipal } from './resource-principal.js'; + +/** This daemon's one local `workspace_projects` row for a project, as far as + * reconciliation cares. */ +export interface LocalTeamProjectBinding { + projectId: string; + workspaceId: string; + visibility: 'personal' | 'team'; + resourceState?: string | null; + createdByWorkspaceMemberId: string | null; + resourceHubResourceId: string | null; +} + +/** What the remote catalog says about a shared project — the subset + * `listTeamProjects()` (contracts' `TeamProject`) actually carries. */ +export interface RemoteTeamProjectRef { + projectId: string; + ownerMemberId: string; +} + +/** The two remote reads a daemon can consult for catalog MEMBERSHIP. */ +export interface ReconcilerRemoteTeamProjectSources { + /** + * The raw workspace catalog (`vela team-projects list` via the richer + * `VelaTeamProjectCatalogClient`), which carries EVERY registered row — + * including rows whose `syncState` is `pending_upload`/`syncing`/`failed`. + * Null when the vela transport is not active for this daemon. + */ + listCatalogMembership: (() => Promise<readonly RemoteTeamProjectRef[]>) | null; + /** + * The display catalog (`teamProjectsForDisplay`), which deliberately DROPS + * rows whose latest publish is not `synced` so teammates never open empty + * project shells (see `toTeamProject` in collab/vela-cli-team-projects.ts). + */ + listDisplayTeamProjects: () => Promise<readonly RemoteTeamProjectRef[]>; +} + +/** + * THE INVARIANT: reconciliation must judge membership against the raw + * catalog, never against the display list. + * + * "Is project X still registered to my team, and who owns it" and "should + * project X render in the team space right now" are different questions. The + * display read answers the second one by hiding rows whose latest publish is + * not `synced` — but a row whose publish FAILED is still a registered, + * owner-occupied catalog row (the hub's `upsertTeamProject` keeps refusing + * every other member with `team_project_owner_conflict` for it). Feeding that + * display-filtered list to `planWorkspaceProjectReconciliation` made a mere + * sync failure indistinguishable from a real unshare, so the demote direction + * rewrote a teammate's mirror into a personal draft ATTRIBUTED TO THE LOCAL + * VIEWER (`createdByWorkspaceMemberId: workspaceMemberId`) — which then + * surfaced in the viewer's drafts as "created by me" with an enabled + * "move to team space" action that could only ever 403 (recvqzjnshIlOe). + * + * A genuine unshare/delete removes the hub row itself, so it disappears from + * the raw catalog too — using membership here does not delay legitimate + * revocation of a teammate's stale mirror or demotion of the original + * creator's own project. + * + * The display read remains available to callers that only need a best-effort + * view. Production reconciliation requires `listCatalogMembership`; when + * that authoritative transport is unavailable, its caller rejects the read + * so absence never triggers a destructive action. + */ +export async function reconcilerRemoteTeamProjects( + sources: ReconcilerRemoteTeamProjectSources, +): Promise<readonly RemoteTeamProjectRef[]> { + if (sources.listCatalogMembership) return sources.listCatalogMembership(); + return sources.listDisplayTeamProjects(); +} + +export interface WorkspaceProjectBindPatch { + workspaceId: string; + visibility: 'team'; + resourceState: 'active'; + createdByWorkspaceMemberId: string | null; + updatedByWorkspaceMemberId: string; + resourceHubResourceId: string | null; + cloudTombstonedAt: null; + syncState: 'synced'; +} + +export interface WorkspaceProjectDemotePatch { + visibility: 'personal'; + createdByWorkspaceMemberId: string; + resourceHubResourceId: null; + cloudTombstonedAt: null; + syncState: 'local_only'; +} + +export interface WorkspaceProjectRevokePatch { + visibility: 'team'; + resourceState: 'deleted'; + createdByWorkspaceMemberId: null; + resourceHubResourceId: string | null; + cloudTombstonedAt: null; + syncState: 'synced'; +} + +export type WorkspaceProjectReconcileAction = + | { kind: 'bind'; projectId: string; patch: WorkspaceProjectBindPatch } + | { kind: 'demote'; projectId: string; workspaceId: string; patch: WorkspaceProjectDemotePatch } + | { kind: 'revoke'; projectId: string; workspaceId: string; patch: WorkspaceProjectRevokePatch }; + +/** + * Pure planner: given what the remote catalog reports and what this daemon's + * OWN `workspace_projects` table currently claims for the active team + * workspace, decide which rows disagree and how to fix them. No I/O — the + * orchestrator below (`reconcileWorkspaceProjectsWithRemote`) is the only + * caller that touches the database, which is what keeps this function + * directly unit-testable. + * + * `localBindings` must carry an entry for every project this function needs + * an opinion on: every `remoteProjects[].projectId` (so a brand-new remote + * share can be told apart from an already-correct one) AND every row already + * bound `visibility: 'team'` under `workspaceId` (so a row remote no longer + * lists can be found at all). The caller builds this union — see + * `reconcileWorkspaceProjectsWithRemote`. + */ +export function planWorkspaceProjectReconciliation(input: { + workspaceId: string; + workspaceMemberId: string; + remoteProjects: readonly RemoteTeamProjectRef[]; + localBindings: ReadonlyMap<string, LocalTeamProjectBinding>; +}): WorkspaceProjectReconcileAction[] { + const { workspaceId, workspaceMemberId, remoteProjects, localBindings } = input; + const actions: WorkspaceProjectReconcileAction[] = []; + const remoteIds = new Set(remoteProjects.map((project) => project.projectId)); + + // Direction 1: remote reports the project as shared to my team — correct a + // missing, mis-owned, or wrong-visibility local row. Ownership drives + // `createdByWorkspaceMemberId` the same way `reconcileLocalRowWithRemoteTeamAccess` + // does: the hub's single writer per project is its `ownerMemberId`, so only + // a match against the CURRENT member earns an editable local binding. + for (const remote of remoteProjects) { + const local = localBindings.get(remote.projectId) ?? null; + const isOwner = remote.ownerMemberId === workspaceMemberId; + const wantCreatedBy = isOwner ? workspaceMemberId : null; + const alreadyCorrect = + local != null && + local.workspaceId === workspaceId && + local.visibility === 'team' && + local.createdByWorkspaceMemberId === wantCreatedBy; + if (alreadyCorrect) continue; + actions.push({ + kind: 'bind', + projectId: remote.projectId, + patch: { + workspaceId, + visibility: 'team', + resourceState: 'active', + createdByWorkspaceMemberId: wantCreatedBy, + updatedByWorkspaceMemberId: workspaceMemberId, + // Preserve a resourceId this daemon already learned (e.g. from the + // request-driven `reconcileLocalRowWithRemoteTeamAccess`, which reads + // the richer catalog client that DOES carry it); `listTeamProjects()` + // itself does not expose one. Leaving it null here is safe: the next + // `GET /api/workspaces/:workspaceId/projects` still completes it via + // that existing, higher-fidelity pass. + resourceHubResourceId: local?.resourceHubResourceId ?? null, + cloudTombstonedAt: null, + syncState: 'synced', + }, + }); + } + + // Direction 2: a local Team row remote no longer lists. The owner daemon's + // original project may safely become that owner's Personal draft, but a + // teammate's pulled mirror is not the reader's content. Promoting those + // stale bytes to Personal both leaks an unshared resource and fabricates + // authorship. Keep a foreign mirror bound to its exact Team identity and + // mark it deleted instead; the project bytes stay quarantined on disk and + // only a later authoritative re-share + materialization may reactivate it. + for (const [projectId, local] of localBindings) { + if (local.workspaceId !== workspaceId || local.visibility !== 'team') continue; + if (remoteIds.has(projectId)) continue; + if (local.resourceState === 'deleted') continue; + if (local.createdByWorkspaceMemberId !== workspaceMemberId) { + actions.push({ + kind: 'revoke', + projectId, + workspaceId, + patch: { + visibility: 'team', + resourceState: 'deleted', + createdByWorkspaceMemberId: null, + resourceHubResourceId: local.resourceHubResourceId, + cloudTombstonedAt: null, + syncState: 'synced', + }, + }); + continue; + } + actions.push({ + kind: 'demote', + projectId, + workspaceId, + patch: { + visibility: 'personal', + createdByWorkspaceMemberId: workspaceMemberId, + resourceHubResourceId: null, + cloudTombstonedAt: null, + syncState: 'local_only', + }, + }); + } + + return actions; +} + +export interface WorkspaceProjectsReconcileIdentity { + workspaceId: string; + workspaceMemberId: string; + /** Optional transport identity captured by the authority resolver. The + * pure planner does not inspect it, but the remote catalog reader must + * receive the same captured principal instead of reconstructing one + * after an await from mutable ambient Workspace state. */ + principal?: ResourceHubPrincipal | null; +} + +export interface WorkspaceProjectsReconcilerDeps { + /** The signed-in team workspace + member this daemon is currently acting + * as, or null off-team / signed out / removed. Must gate on active + * membership (`memberStatus === 'active'`), the same defensive check + * `should-publish.ts`'s `createShouldPublish` uses — a context that can + * still ADDRESS a resource hub partition is not proof this member is still + * IN the team (see that file's doc comment). */ + getWorkspaceIdentity: () => Promise<WorkspaceProjectsReconcileIdentity | null>; + /** `listTeamProjects()` narrowed to what the planner needs — reuse the + * daemon's existing `teamProjectsForDisplay` (server.ts), the exact same + * read every other realtime consumer already shares, so this module never + * opens a second transport to Vela. */ + listRemoteTeamProjects: ( + identity: WorkspaceProjectsReconcileIdentity, + ) => Promise<readonly RemoteTeamProjectRef[]>; + /** + * True when this daemon has a local `projects` row for the id — i.e. the + * project's content has been materialized here (created locally, or pulled + * from the hub). Gates the bind direction: `workspace_projects.project_id` + * is a FOREIGN KEY into `projects(id)` (db.ts), so a bind INSERT for a + * never-materialized project cannot succeed — and must not be attempted. + * Materializing a project is the open/pull path's job + * (`ensureSharedProjectPlaceholder` / `registerPulledProject` in + * routes/collab-sync.ts), never this reconciler's; a remote catalog entry + * with no local `projects` row and no local binding is simply out of this + * daemon's scope until the member actually opens it (the team list already + * displays it from the remote catalog alone — see + * `listRemoteTeamProjectSummaries` in routes/project/index.ts). + */ + hasLocalProject: (projectId: string) => boolean; + /** Every row this daemon currently has bound `visibility: 'team'` for + * `workspaceId` (`listWorkspaceProjects(db, workspaceId)`, pre-filtered). */ + listLocalTeamRows: (workspaceId: string) => readonly LocalTeamProjectBinding[]; + /** This one project's current local binding (any workspace), or null if + * unbound (`getWorkspaceProjectByProjectId`). Only consulted for a remote + * project not already covered by `listLocalTeamRows`. */ + getLocalBinding: (projectId: string) => LocalTeamProjectBinding | null; + /** + * Write a 'bind' action. MUST handle both cases `db.ts`'s two primitives + * split across: a project with an existing (wrong) row (`rebindWorkspaceProject`, + * keyed on project id alone so a stale workspace_id is corrected too) AND a + * project with NO local row at all (`rebindWorkspaceProject` is a no-op in + * that case — it never inserts — so the caller must fall back to + * `ensureWorkspaceProject` with the same patch). See the wiring in + * server.ts for the reference implementation. + */ + applyBind: (projectId: string, patch: WorkspaceProjectBindPatch) => void; + applyDemote: (workspaceId: string, projectId: string, patch: WorkspaceProjectDemotePatch) => void; + /** + * Quarantine a foreign pulled mirror after a successful authoritative + * catalog read confirms it is absent. This must not delete project bytes. + */ + applyRevoke: (workspaceId: string, projectId: string, patch: WorkspaceProjectRevokePatch) => void; + onError?: (error: unknown) => void; +} + +export interface WorkspaceProjectsReconcileResult { + bound: number; + demoted: number; + revoked: number; +} + +const NO_OP_RESULT: WorkspaceProjectsReconcileResult = { + bound: 0, + demoted: 0, + revoked: 0, +}; + +/** + * Run one reconciliation pass: read the remote team-project list, diff it + * against this daemon's own `workspace_projects` rows, and write back + * whatever disagrees. Best-effort throughout — a failed identity read or a + * failed remote read returns a no-op result rather than throwing, so a + * transient Vela outage can never be misread as "remote reports zero + * projects" and demote every local team row on missing (as opposed to + * genuinely empty) data. + */ +export async function reconcileWorkspaceProjectsWithRemote( + deps: WorkspaceProjectsReconcilerDeps, +): Promise<WorkspaceProjectsReconcileResult> { + const identity = await deps.getWorkspaceIdentity().catch((error) => { + deps.onError?.(error); + return null; + }); + if (!identity) return NO_OP_RESULT; + + let remoteProjects: readonly RemoteTeamProjectRef[]; + try { + remoteProjects = await deps.listRemoteTeamProjects(identity); + } catch (error) { + deps.onError?.(error); + return NO_OP_RESULT; + } + + const localBindings = new Map<string, LocalTeamProjectBinding>(); + for (const row of deps.listLocalTeamRows(identity.workspaceId)) { + localBindings.set(row.projectId, row); + } + for (const remote of remoteProjects) { + if (localBindings.has(remote.projectId)) continue; + const existing = deps.getLocalBinding(remote.projectId); + if (existing) localBindings.set(remote.projectId, existing); + } + + // Reconciliation corrects the bindings of projects this daemon KNOWS — + // a remote catalog entry with neither a local binding nor a local + // `projects` row has nothing local to correct, and binding it anyway + // would violate `workspace_projects`' FOREIGN KEY into `projects(id)` + // (the recvqmnuxxKHaI loop: the same INSERT failing on every pass, for + // every never-opened teammate project). Excluding it here is safe for the + // demote direction too: demotes only ever come from `localBindings`, and + // a project excluded by this filter has, by construction, no entry there. + const knownRemoteProjects = remoteProjects.filter( + (remote) => localBindings.has(remote.projectId) || deps.hasLocalProject(remote.projectId), + ); + + const actions = planWorkspaceProjectReconciliation({ + workspaceId: identity.workspaceId, + workspaceMemberId: identity.workspaceMemberId, + remoteProjects: knownRemoteProjects, + localBindings, + }); + + for (const action of actions) { + try { + if (action.kind === 'bind') deps.applyBind(action.projectId, action.patch); + else if (action.kind === 'demote') { + deps.applyDemote(action.workspaceId, action.projectId, action.patch); + } else { + deps.applyRevoke(action.workspaceId, action.projectId, action.patch); + } + } catch (error) { + deps.onError?.(error); + } + } + + return { + bound: actions.filter((action) => action.kind === 'bind').length, + demoted: actions.filter((action) => action.kind === 'demote').length, + revoked: actions.filter((action) => action.kind === 'revoke').length, + }; +} + +/** + * Hub → daemon handling for the `team-projects-changed` push (see + * `startHubEventsSubscriber`'s `onEvent` in server.ts). Sibling of + * `handleHubWorkspaceContextChanged` just above it in that file: besides + * refreshing the display cache and sending the thin web signal + * (`emitTeamProjectsChangedDeduped`), this now ALSO runs a real + * `workspace_projects` reconciliation pass, so a member whose owner just + * unshared a project (or who just gained access to a new one) converges + * immediately instead of waiting for the ~15s poller. + * + * Extracted as its own named, exported step for the same reason + * `handleHubWorkspaceContextChanged` is: directly unit-testable without + * standing up a real hub connection. + */ +export function handleHubTeamProjectsChanged( + emitTeamProjectsChangedDeduped: () => void, + reconcileWorkspaceProjects: () => Promise<unknown>, +): void { + emitTeamProjectsChangedDeduped(); + void reconcileWorkspaceProjects().catch(() => undefined); +} + +/** + * `workspaceInvalidationPoller`'s `emit` wrapper (server.ts). The poller + * itself stays a pure "diff and signal" utility + * (`collab/workspace-invalidation-poller.ts`) with no opinion on + * `workspace_projects`; this is the one seam where a `team-projects-changed` + * signal ALSO kicks the real reconciliation — the poller's own ~15s-cadence + * twin of `handleHubTeamProjectsChanged` above, for daemons that are signed + * in but whose hub SSE channel is down (the poller is the sole delivery + * mechanism in that state, per `startHubEventsSubscriber`'s own doc comment). + */ +export function handlePolledWorkspaceInvalidation( + payload: WorkspaceInvalidationSsePayload, + emit: (payload: WorkspaceInvalidationSsePayload) => void, + reconcileWorkspaceProjects: () => Promise<unknown>, +): void { + emit(payload); + if (payload.type === 'team-projects-changed') { + void reconcileWorkspaceProjects().catch(() => undefined); + } +} diff --git a/apps/daemon/src/collab/workspace-resource-mutation.ts b/apps/daemon/src/collab/workspace-resource-mutation.ts new file mode 100644 index 00000000000..fca047349ad --- /dev/null +++ b/apps/daemon/src/collab/workspace-resource-mutation.ts @@ -0,0 +1,799 @@ +// Workspace-resource mutation gate, shared by every resource type that binds +// into the generic `workspace_resources` table (see `db.ts`): project, +// plugin, and (later) skill / design system. +// +// This module is an EXTRACTION, not a new design. It used to live entirely +// inside `apps/daemon/src/routes/project/index.ts` as +// `enforceWorkspaceProjectMutation` / `projectAccess`, hard-coded to +// "project". Project's own logic has been fixed three times this week alone +// from dogfood feedback — a mistake here is easy to make and expensive to +// repeat, so every other resource type should call THIS module rather than +// forking its own copy. Project's route file still owns the project-specific +// affordances (canMoveToTeam / canMoveToPersonal / canOpen / canExport / +// canSendTo) that only make sense for a project; this module owns the part +// that generalizes cleanly: reading the caller's workspace identity off +// headers, and deciding whether a caller may mutate a bound resource row. +import type { WorkspaceCollabContext } from '@open-design/contracts'; +import type { Response } from 'express'; + +export type WorkspaceResourceContext = { + workspaceId: string; + workspaceType: 'personal' | 'team'; + /** + * The caller's RAW `x-od-workspace-type` claim, before it is collapsed into + * `workspaceType` above. `workspaceType` defaults an absent header to + * 'personal', which is the right default for view filtering but must never + * be read as the caller ASSERTING "personal" — only an explicit header is + * evidence. Null means the caller made no claim. + */ + workspaceTypeAsserted: 'personal' | 'team' | null; + appUserId: string; + workspaceMemberId: string; + role: 'owner' | 'admin' | 'member'; + memberStatus: 'active' | 'removed'; + lifecycleState: 'active' | 'billing_past_due' | 'locked' | 'deleting' | 'deleted'; + canShareProjects: boolean; + canWriteSyncedFiles: boolean; +}; + +export type WorkspaceResourceMutationCapability = + | 'rename' + | 'delete' + | 'duplicate' + | 'writeFiles' + | 'comment'; + +export type WorkspaceRequestAuthorityResult = + | { ok: true; context: WorkspaceCollabContext } + | { + ok: false; + status: 400 | 403 | 503; + code: string; + message: string; + retryable?: true; + }; + +export type VerifyWorkspaceRequestAuthority = ( + req: unknown, +) => Promise<WorkspaceRequestAuthorityResult>; + +/** + * Browser navigation transports such as EventSource and iframe/src URLs + * cannot attach custom headers. For those read-only routes only, accept the + * same exact Workspace/member pair from query parameters and present it to the + * normal verifier as request headers. A mixed header/query request must agree + * exactly; callers cannot use query scope to override an existing identity. + */ +export function requestWithWorkspaceNavigationScope( + req: any, +): any | 'conflict' { + const workspaceId = typeof req.query?.workspaceId === 'string' + ? req.query.workspaceId.trim() + : ''; + const workspaceMemberId = typeof req.query?.workspaceMemberId === 'string' + ? req.query.workspaceMemberId.trim() + : ''; + if (!workspaceId && !workspaceMemberId) return req; + const headerWorkspaceId = req.get('x-od-workspace-id')?.trim() ?? ''; + const headerWorkspaceMemberId = + req.get('x-od-workspace-member-id')?.trim() ?? ''; + if ( + (headerWorkspaceId || headerWorkspaceMemberId) + && ( + headerWorkspaceId !== workspaceId + || headerWorkspaceMemberId !== workspaceMemberId + ) + ) { + return 'conflict'; + } + return { + get(name: string) { + const normalized = name.toLowerCase(); + if (normalized === 'x-od-workspace-id') return workspaceId || undefined; + if (normalized === 'x-od-workspace-member-id') { + return workspaceMemberId || undefined; + } + return req.get(name); + }, + }; +} + +export type OptionalWorkspaceRequestAuthorityResult = + | { ok: true; context: WorkspaceCollabContext | null } + | Exclude<WorkspaceRequestAuthorityResult, { ok: true }>; + +/** + * Resolve the three request-scope states shared by resource reads and writes: + * + * - no Workspace/member headers: the legacy Personal/global lane; + * - a partial identity: a structured 400; + * - a complete identity: fresh directory-backed authority. + * + * The caller-provided verifier is intentionally invoked for every complete + * request. Resource mutations must not reuse a previously settled membership + * success after the member has been removed. + */ +export async function resolveOptionalWorkspaceRequestAuthority( + req: any, + verifyWorkspaceRequestAuthority: VerifyWorkspaceRequestAuthority | undefined, +): Promise<OptionalWorkspaceRequestAuthorityResult> { + const claimed = workspaceResourceContextFromRequest(req); + if (claimed === null) return { ok: true, context: null }; + if (claimed === 'missing') { + return { + ok: false, + status: 400, + code: 'WORKSPACE_CONTEXT_INCOMPLETE', + message: 'both workspace and member identity are required', + }; + } + if (!verifyWorkspaceRequestAuthority) { + return { + ok: false, + status: 400, + code: 'WORKSPACE_CONTEXT_REQUIRED', + message: 'an explicit workspace context is required', + }; + } + return verifyWorkspaceRequestAuthority(req); +} + +/** + * Compatibility snapshot used only by the deprecated synchronous gate below. + * No production route calls that gate; its remaining direct tests document + * legacy behavior while current client data-plane routes use + * `enforceVerifiedWorkspaceResourceMutation`, which performs a fresh exact + * Workspace/member authority check and never consults this snapshot. + */ +export type WorkspaceMembershipSnapshot = { + workspaceId: string; + memberStatus: 'active' | 'removed'; +}; + +export type GetLastKnownWorkspaceMembership = () => WorkspaceMembershipSnapshot | null; + +/** + * Ambient identity shape retained only by the deprecated synchronous gate. + * It is absent from authoritative request gates and must never choose a + * client's Workspace. + */ +export type AmbientWorkspaceSnapshot = { + workspaceId: string; + workspaceType: 'personal' | 'team'; + workspaceMemberId: string; + role: WorkspaceResourceContext['role']; + memberStatus: WorkspaceResourceContext['memberStatus']; + lifecycleState: WorkspaceResourceContext['lifecycleState']; + permissions: { canShareProjects: boolean; canWriteSyncedFiles: boolean }; +}; + +export type GetAmbientWorkspace = () => AmbientWorkspaceSnapshot | null | undefined; + +/** + * The daemon's ambient identity as a resource context, or null when it has none. + * + * `workspaceTypeAsserted` is null and `appUserId` empty on purpose: both record + * what a CALLER claimed, and nobody claimed anything here. + */ +export function ambientWorkspaceResourceContext( + getAmbientWorkspace: GetAmbientWorkspace | undefined, +): WorkspaceResourceContext | null { + const ambient = getAmbientWorkspace?.(); + if (!ambient) return null; + const workspaceId = ambient.workspaceId?.trim(); + const workspaceMemberId = ambient.workspaceMemberId?.trim(); + if (!workspaceId || !workspaceMemberId) return null; + return { + workspaceId, + workspaceType: ambient.workspaceType === 'team' ? 'team' : 'personal', + workspaceTypeAsserted: null, + appUserId: '', + workspaceMemberId, + role: ambient.role, + memberStatus: ambient.memberStatus, + lifecycleState: ambient.lifecycleState, + canShareProjects: ambient.permissions.canShareProjects, + canWriteSyncedFiles: ambient.permissions.canWriteSyncedFiles, + }; +} + +/** + * Deprecated compatibility cross-check for the synchronous gate below. + * + * Current routes instead perform a fresh exact directory check. This helper + * remains for direct legacy tests and must not be wired into new routes. + */ +export function withLastKnownMembership( + ctx: WorkspaceResourceContext, + getLastKnownMembership: GetLastKnownWorkspaceMembership | undefined, +): WorkspaceResourceContext { + if (!getLastKnownMembership) return ctx; + const known = getLastKnownMembership(); + if (!known || known.workspaceId !== ctx.workspaceId) return ctx; + if (known.memberStatus === 'removed' && ctx.memberStatus !== 'removed') { + return { ...ctx, memberStatus: 'removed' }; + } + return ctx; +} + +export type WorkspaceResourceAccessInput = { + visibility?: string | null; + resourceState?: string | null; + createdByWorkspaceMemberId?: string | null; +}; + +export function headerValue(req: any, name: string): string | null { + const value = req.get(name); + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +export function headerBool(req: any, name: string, fallback: boolean): boolean { + const value = headerValue(req, name); + if (value === null) return fallback; + if (value === 'false') return false; + if (value === 'true') return true; + return fallback; +} + +// Temporary adapter until the B-owned CurrentWorkspaceContext is wired into +// the daemon. Keep resource CRUD behind this seam so the header fallback can +// be replaced without changing visibility and permission logic. +export function workspaceResourceContext(req: any, workspaceId: string): WorkspaceResourceContext | null { + const workspaceMemberId = headerValue(req, 'x-od-workspace-member-id'); + if (!workspaceMemberId) return null; + const workspaceTypeHeader = headerValue(req, 'x-od-workspace-type'); + const lifecycleState = headerValue(req, 'x-od-workspace-lifecycle-state') ?? 'active'; + const role = headerValue(req, 'x-od-workspace-role') ?? 'member'; + const legacyWriteEnabled = headerBool(req, 'x-od-workspace-write-enabled', true); + const canWriteSyncedFiles = headerBool(req, 'x-od-workspace-can-write-synced-files', legacyWriteEnabled); + return { + workspaceId, + workspaceType: workspaceTypeHeader === 'team' ? 'team' : 'personal', + workspaceTypeAsserted: + workspaceTypeHeader === 'team' || workspaceTypeHeader === 'personal' ? workspaceTypeHeader : null, + appUserId: headerValue(req, 'x-od-app-user-id') ?? 'local-user', + workspaceMemberId, + role: role === 'owner' || role === 'admin' ? role : 'member', + memberStatus: headerValue(req, 'x-od-workspace-member-status') === 'removed' ? 'removed' : 'active', + lifecycleState: lifecycleState === 'billing_past_due' || lifecycleState === 'locked' || lifecycleState === 'deleting' || lifecycleState === 'deleted' + ? lifecycleState + : 'active', + canShareProjects: headerBool(req, 'x-od-workspace-can-share-projects', canWriteSyncedFiles), + canWriteSyncedFiles, + }; +} + +export function workspaceResourceContextFromRequest(req: any): WorkspaceResourceContext | 'missing' | null { + const workspaceId = headerValue(req, 'x-od-workspace-id'); + const workspaceMemberId = headerValue(req, 'x-od-workspace-member-id'); + if (!workspaceId && !workspaceMemberId) return null; + if (!workspaceId || !workspaceMemberId) return 'missing'; + return workspaceResourceContext(req, workspaceId) ?? 'missing'; +} + +export function workspaceResourceContextFromVerified( + context: WorkspaceCollabContext, +): WorkspaceResourceContext { + return { + workspaceId: context.workspaceId, + workspaceType: context.workspaceType, + workspaceTypeAsserted: context.workspaceType, + appUserId: '', + workspaceMemberId: context.workspaceMemberId, + role: context.role, + memberStatus: context.memberStatus, + lifecycleState: context.lifecycleState, + canShareProjects: context.permissions.canShareProjects, + canWriteSyncedFiles: context.permissions.canWriteSyncedFiles, + }; +} + +export function isWorkspaceResourceLocked(ctx: WorkspaceResourceContext): boolean { + return ctx.lifecycleState === 'locked' || ctx.lifecycleState === 'deleted'; +} + +/** + * The core frozen/privilege/mutate computation shared by every resource + * type. Deliberately narrower than project's own `projectAccess` in + * `routes/project/index.ts` — it does not compute + * canMoveToTeam/canMoveToPersonal/canOpen/canExport/canSendTo, which are + * project-specific UX affordances project's own wrapper still builds on top + * of this. What it DOES compute is the part every resource type needs + * identically, and the part a correctness fix tends to land in. + */ +export function workspaceResourceAccess( + wp: WorkspaceResourceAccessInput, + ctx: WorkspaceResourceContext, +): { + frozen: boolean; + selfCreated: boolean; + privileged: boolean; + canMutate: boolean; + unattributed: boolean; + canShareLocal: boolean; + disabledReason?: 'workspace_deleted' | 'workspace_locked' | 'permission_denied'; +} { + const frozen = wp.resourceState === 'frozen' || wp.resourceState === 'deleted' || isWorkspaceResourceLocked(ctx); + const selfCreated = wp.createdByWorkspaceMemberId != null && wp.createdByWorkspaceMemberId === ctx.workspaceMemberId; + const privileged = ctx.role === 'owner' || ctx.role === 'admin'; + const canMutate = !frozen && ctx.canWriteSyncedFiles && ctx.memberStatus === 'active' && (privileged || selfCreated); + // Sharing is the one mutation that must ALSO work on an unattributed row: + // lazy projection never assigns ownership to the reader (adoption red + // line), yet a local resource physically exists only on this user's disk — + // sharing it stamps the sharer as owner. Without this, a plain member's own + // local resources could never be shared. Destructive actions + // (delete/rename/unshare) stay on the strict `canMutate`. + const unattributed = wp.createdByWorkspaceMemberId == null; + const canShareLocal = + !frozen && ctx.canWriteSyncedFiles && ctx.memberStatus === 'active' && + (privileged || selfCreated || unattributed); + const disabledReason: 'workspace_deleted' | 'workspace_locked' | 'permission_denied' | undefined = frozen + ? ctx.lifecycleState === 'deleted' || wp.resourceState === 'deleted' + ? 'workspace_deleted' + : 'workspace_locked' + : canMutate + ? undefined + : 'permission_denied'; + return { + frozen, + selfCreated, + privileged, + canMutate, + unattributed, + canShareLocal, + ...(disabledReason ? { disabledReason } : {}), + }; +} + +function workspaceResourceMutationAllowed( + resourceType: string, + row: WorkspaceResourceAccessInput | null | undefined, + ctx: WorkspaceResourceContext, + capability: WorkspaceResourceMutationCapability, +): boolean { + if (!row) return false; + const access = workspaceResourceAccess(row, ctx); + // `comment` is the one capability the product grants MORE WIDELY than + // resource ownership: sharing a resource into the team explicitly invites + // every active member to comment (the member-facing read-only banner + // promises "view and comment"), while rename/delete/duplicate/writeFiles + // stay creator/privileged-only. Gating comments on the strict `canMutate` + // bit 403'd every plain member's comment on someone else's shared project + // at the workspace layer (2026-07-28 dogfood: “评论保存失败,请重试。”), + // before the per-comment author rules in routes/project/comments.ts ever + // ran. Comments are intentionally independent from + // `canWriteSyncedFiles`: shared-project viewers are read-only for project + // files but the product still promises that they can comment. The sharing + // act (`visibility: 'team'`) grants comment standing to every active + // workspace member, while frozen resources and unshared personal bindings + // remain closed. + if (capability === 'comment') { + return ( + access.canMutate || + (!access.frozen && + ctx.memberStatus === 'active' && + row.visibility === 'team') + ); + } + // A shared Team project is a single-writer resource. Workspace governance + // (`owner` / `admin`) may manage the Team, but it does not transfer the + // project owner's authorship: every project-content mutation must come from + // the member recorded on the shared project row. Comments deliberately keep + // the broader rule above. Personal/unshared projects and non-project + // resources retain the ordinary privileged-or-creator mutation policy. + if (resourceType === 'project' && row.visibility === 'team') { + return access.canMutate && access.selfCreated; + } + // Every other mutation capability collapses to the same `canMutate` bit. + return access.canMutate; +} + +/** + * Deprecated synchronous mutation gate retained for direct legacy tests. + * + * `resourceType` ('project' | 'plugin' | 'skill' | 'design_system') feeds + * both the lookup callbacks' semantics and the permission-denied error code + * (`WORKSPACE_<RESOURCE_TYPE>_PERMISSION_DENIED`) — for `resourceType: + * 'project'` that reproduces the exact `WORKSPACE_PROJECT_PERMISSION_DENIED` + * code the project route already shipped and has tests pinned against. + * + * `getWorkspaceResource`/`getWorkspaceResourceByResourceId` are caller-bound + * closures over the specific resource's storage (e.g. `workspace_projects` or + * `workspace_resources` filtered to `resource_type = 'plugin'`) so this + * module never has to know which table backs which resource type. + * + * No production route calls this function. New code must use + * `enforceVerifiedWorkspaceResourceMutation`. + */ +/** + * The shape `createEnforceWorkspaceProjectMutation` (routes/project/index.ts) + * returns: `enforceWorkspaceResourceMutation` with `resourceType` (and, for + * project, the last-known-membership cross-check) already bound. Exported so a + * resource type with no workspace binding of its own — a project comment — can + * borrow another resource type's ALREADY-BUILT gate instance instead of + * re-deriving one, and so the two ends of that hand-off (the builder in + * routes/project/index.ts, the consumer in routes/project/comments.ts) share + * one type instead of drifting. + */ +export type BoundWorkspaceResourceMutationGate = ( + req: any, + res: Response, + sendApiError: (res: Response, status: number, code: string, message: string) => unknown, + getWorkspaceResource: (db: unknown, workspaceId: string, resourceId: string) => WorkspaceResourceAccessInput | null | undefined, + getWorkspaceResourceByResourceId: (db: unknown, resourceId: string) => WorkspaceResourceAccessInput | null | undefined, + db: unknown, + resourceId: string, + capability: WorkspaceResourceMutationCapability, +) => Promise<boolean>; + +/** + * Deprecated synchronous counterpart retained for legacy tests. Production + * routes use `requestCanMutateVerifiedWorkspaceResource`. + * + * For a READ route that writes as a side effect. The version-history GET + * bootstraps a baseline version whenever a file has no manifest yet + * (`ensureCurrentProjectFileVersion`), and on a member's mirror of someone + * else's shared project that write is doubly wrong: it writes into a project + * whose own banner says the member cannot modify it, and the version it + * creates then presents itself as the owner's history even though the owner's + * real history can never be there — `.file-versions` is in + * `MEMBER_MIRROR_EXCLUDED_ENTRIES`, so a mirror never receives it. Measured + * live (2026-07-27): owner 4 versions, member's panel 1, timestamped at the + * moment the member opened the panel. + * + * The read itself stays open. Browsing history is a read action and the entry + * point is deliberately un-gated (飞书 recvq56vFjQKfT); this answers only + * "may this read leave a write behind?", never "may this caller read?". + * + * Fail-open on absent or unrecognized identity, which is where it deliberately + * DIVERGES from `enforceWorkspaceResourceMutation`: that gate 401s a headerless + * caller on a bound resource, because a mutation must prove membership. Here + * the same absence must NOT suppress the bootstrap, or every legacy client, + * `od` CLI invocation, and signed-out read would silently lose version history + * for no security gain — the suppressed write is local-only either way + * (`.file-versions` never publishes). Only an authenticated "this member + * cannot write here" suppresses it. + */ +export function requestCanMutateWorkspaceResource( + req: any, + getWorkspaceResource: (db: unknown, workspaceId: string, resourceId: string) => WorkspaceResourceAccessInput | null | undefined, + db: unknown, + resourceId: string, + getLastKnownMembership?: GetLastKnownWorkspaceMembership, +): boolean { + const requestCtx = workspaceResourceContextFromRequest(req); + if (requestCtx === null || requestCtx === 'missing') return true; + const ctx = withLastKnownMembership(requestCtx, getLastKnownMembership); + const row = getWorkspaceResource(db, ctx.workspaceId, resourceId); + if (!row) return true; + return workspaceResourceAccess(row, ctx).canMutate; +} + +/** + * Authoritative counterpart used by Workspace-bound project data-plane + * routes. A persisted binding makes explicit Workspace/member identity + * mandatory; every authority-bearing field comes from the signed-in + * membership directory, never from request headers or daemon-global + * active/last-known state. Truly unbound legacy local resources keep their + * existing local-only behavior. + */ +export async function requestCanMutateVerifiedWorkspaceResource( + req: any, + getWorkspaceResource: ( + db: unknown, + workspaceId: string, + resourceId: string, + ) => WorkspaceResourceAccessInput | null | undefined, + getWorkspaceResourceByResourceId: ( + db: unknown, + resourceId: string, + ) => WorkspaceResourceAccessInput | null | undefined, + db: unknown, + resourceId: string, + verifyWorkspaceRequestAuthority: VerifyWorkspaceRequestAuthority | undefined, +): Promise<boolean> { + if (!getWorkspaceResourceByResourceId(db, resourceId)) return true; + if (!verifyWorkspaceRequestAuthority) return false; + const verified = await verifyWorkspaceRequestAuthority(req); + if (!verified.ok) return false; + const context = workspaceResourceContextFromVerified(verified.context); + const row = getWorkspaceResource(db, context.workspaceId, resourceId); + return workspaceResourceMutationAllowed( + 'project', + row, + context, + 'writeFiles', + ); +} + +export async function enforceVerifiedWorkspaceResourceMutation( + resourceType: string, + req: any, + res: Response, + sendApiError: (res: Response, status: number, code: string, message: string) => unknown, + getWorkspaceResource: ( + db: unknown, + workspaceId: string, + resourceId: string, + ) => WorkspaceResourceAccessInput | null | undefined, + getWorkspaceResourceByResourceId: ( + db: unknown, + resourceId: string, + ) => WorkspaceResourceAccessInput | null | undefined, + db: unknown, + resourceId: string, + capability: WorkspaceResourceMutationCapability, + verifyWorkspaceRequestAuthority: VerifyWorkspaceRequestAuthority | undefined, +): Promise<boolean> { + // No persisted Workspace binding means this is a genuine legacy/local + // resource. Preserve that path without inventing a Workspace from ambient + // navigation state. + if (!getWorkspaceResourceByResourceId(db, resourceId)) return true; + if (!verifyWorkspaceRequestAuthority) { + sendApiError(res, 400, 'WORKSPACE_CONTEXT_REQUIRED', 'an explicit workspace context is required'); + return false; + } + + const verified = await verifyWorkspaceRequestAuthority(req); + if (!verified.ok) { + sendApiError(res, verified.status, verified.code, verified.message); + return false; + } + + const context = workspaceResourceContextFromVerified(verified.context); + const row = getWorkspaceResource(db, context.workspaceId, resourceId); + if (!workspaceResourceMutationAllowed( + resourceType, + row, + context, + capability, + )) { + const code = row && isWorkspaceResourceLocked(context) + ? 'WORKSPACE_LOCKED' + : `WORKSPACE_${resourceType.toUpperCase()}_PERMISSION_DENIED`; + sendApiError(res, 403, code, `workspace ${resourceType} mutation is not allowed`); + return false; + } + return true; +} + +/** + * Fresh exact authority gate for the data plane of a Workspace-bound resource. + * + * Reads deliberately do not require creator/admin mutation standing: every + * active member may read a resource that is bound to the exact Workspace in + * the request. Locked/frozen Team resources intentionally remain readable but + * read-only; removed members, authority outages, cross-Workspace identities, + * and deleted resources fail closed. Truly unbound legacy local resources + * remain compatible. + */ +export async function enforceVerifiedWorkspaceResourceRead( + resourceType: string, + req: any, + res: Response, + sendApiError: ( + res: Response, + status: number, + code: string, + message: string, + details?: Record<string, unknown>, + ) => unknown, + getWorkspaceResource: ( + db: unknown, + workspaceId: string, + resourceId: string, + ) => WorkspaceResourceAccessInput | null | undefined, + getWorkspaceResourceByResourceId: ( + db: unknown, + resourceId: string, + ) => WorkspaceResourceAccessInput | null | undefined, + db: unknown, + resourceId: string, + verifyWorkspaceRequestAuthority: VerifyWorkspaceRequestAuthority | undefined, + options: { allowNavigationQuery?: boolean } = {}, +): Promise<boolean> { + if (!getWorkspaceResourceByResourceId(db, resourceId)) return true; + const scopedRequest = options.allowNavigationQuery + ? requestWithWorkspaceNavigationScope(req) + : req; + if (scopedRequest === 'conflict') { + sendApiError( + res, + 400, + 'WORKSPACE_CONTEXT_CONFLICT', + 'workspace header and navigation scope must match', + ); + return false; + } + if (!verifyWorkspaceRequestAuthority) { + sendApiError( + res, + 400, + 'WORKSPACE_CONTEXT_REQUIRED', + 'an explicit workspace context is required', + ); + return false; + } + const verified = await verifyWorkspaceRequestAuthority(scopedRequest); + if (!verified.ok) { + sendApiError( + res, + verified.status, + verified.code, + verified.message, + verified.retryable ? { retryable: true } : {}, + ); + return false; + } + const context = workspaceResourceContextFromVerified(verified.context); + const row = getWorkspaceResource(db, context.workspaceId, resourceId); + if (!row || context.memberStatus !== 'active') { + sendApiError( + res, + 403, + `WORKSPACE_${resourceType.toUpperCase()}_PERMISSION_DENIED`, + `workspace ${resourceType} read is not allowed`, + ); + return false; + } + if (context.lifecycleState === 'deleted' || row.resourceState === 'deleted') { + sendApiError( + res, + 403, + `WORKSPACE_${resourceType.toUpperCase()}_PERMISSION_DENIED`, + `workspace ${resourceType} read is not allowed`, + ); + return false; + } + return true; +} + +/** + * Decide a mutation whose request carries NO workspace identity at all. + * + * INVARIANT: every mutation resolves to a workspace identity. A request that + * ASSERTS one is judged on that assertion; a request that asserts NOTHING is the + * local daemon's own signed-in user, and is judged as that identity. Being + * unable to name a workspace is not the same as having no standing in one. + * + * Headerless is the `od` CLI's normal shape, not an anomaly: nothing in + * `apps/daemon/src/cli.ts` attaches `x-od-workspace-*` outside `od workspace …`, + * and `AGENTS.md` makes the CLI the embeddability contract that external agents + * drive Open Design through. This branch used to answer 401 for ANY bound + * resource, which was survivable only while headerless creates left projects + * unbound. Once every created project got a workspace home (#6201), the two + * rules combined into a project its own creator could not touch: + * `od project create` then `od project duplicate` -> 401. + * + * Resolving to the daemon's ambient identity — rather than to the request's + * claim, of which there is none — is the same fallback the create path already + * applies ("nothing asserted -> ambient"), so the gate and the creation paths + * now agree about what a headerless caller is. It does NOT weaken the two + * contracts that look adjacent: `authorizeCreatedProjectWorkspace` still refuses + * to let ambient stand in for a pair someone explicitly CLAIMED, and + * `resolveProjectWorkspaceScope` still resolves a PERSISTED binding without + * consulting ambient. Both govern cases where something was asserted; this is + * the third case. + * + * What stays refused, because the original branch protected something real + * (recvqbeDjAsejl / recvqbklNGDqYY, spec 04 §10): + * + * - a resource bound to a workspace the daemon is NOT currently in — a + * teammate's shared project, or one left behind by a previous identity. A + * headerless caller has no standing there and still gets 401. + * - a resource in the daemon's own workspace that the daemon's own identity + * may not mutate anyway. The SAME `workspaceResourceMutationAllowed` + * computation runs, so a plain member still cannot rename a teammate's + * project that happens to be shared into this workspace. + * - everything, when the daemon has no signed-in identity to resolve. Nothing + * can vouch for the caller, so the pre-existing answer stands. + * + * An unbound resource stays allowed, exactly as before. + */ +function headerlessMutationAllowed( + resourceType: string, + res: Response, + sendApiError: (res: Response, status: number, code: string, message: string) => unknown, + getWorkspaceResource: (db: unknown, workspaceId: string, resourceId: string) => WorkspaceResourceAccessInput | null | undefined, + getWorkspaceResourceByResourceId: (db: unknown, resourceId: string) => WorkspaceResourceAccessInput | null | undefined, + db: unknown, + resourceId: string, + capability: WorkspaceResourceMutationCapability, + getAmbientWorkspace: GetAmbientWorkspace | undefined, +): boolean { + const anyRow = getWorkspaceResourceByResourceId(db, resourceId); + // Never bound anywhere: nothing to have standing in. + if (!anyRow) return true; + + const ambient = ambientWorkspaceResourceContext(getAmbientWorkspace); + if (!ambient) { + sendApiError(res, 401, 'WORKSPACE_CONTEXT_REQUIRED', 'workspace context is required'); + return false; + } + const ownRow = getWorkspaceResource(db, ambient.workspaceId, resourceId); + if (!ownRow) { + // Bound, but to some other workspace. This is the case the 401 exists for. + sendApiError(res, 401, 'WORKSPACE_CONTEXT_REQUIRED', 'workspace context is required'); + return false; + } + if (!workspaceResourceMutationAllowed( + resourceType, + ownRow, + ambient, + capability, + )) { + const code = isWorkspaceResourceLocked(ambient) + ? 'WORKSPACE_LOCKED' + : `WORKSPACE_${resourceType.toUpperCase()}_PERMISSION_DENIED`; + sendApiError(res, 403, code, `workspace ${resourceType} mutation is not allowed`); + return false; + } + return true; +} + +export function enforceWorkspaceResourceMutation( + resourceType: string, + req: any, + res: Response, + sendApiError: (res: Response, status: number, code: string, message: string) => unknown, + getWorkspaceResource: (db: unknown, workspaceId: string, resourceId: string) => WorkspaceResourceAccessInput | null | undefined, + getWorkspaceResourceByResourceId: (db: unknown, resourceId: string) => WorkspaceResourceAccessInput | null | undefined, + db: unknown, + resourceId: string, + capability: WorkspaceResourceMutationCapability, + getLastKnownMembership?: GetLastKnownWorkspaceMembership, + getAmbientWorkspace?: GetAmbientWorkspace, +): boolean { + const requestCtx = workspaceResourceContextFromRequest(req); + if (requestCtx === null) { + return headerlessMutationAllowed( + resourceType, + res, + sendApiError, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + db, + resourceId, + capability, + getAmbientWorkspace, + ); + } + if (requestCtx === 'missing') { + sendApiError(res, 401, 'WORKSPACE_CONTEXT_REQUIRED', 'workspace context is required'); + return false; + } + const ctx = withLastKnownMembership(requestCtx, getLastKnownMembership); + const row = getWorkspaceResource(db, ctx.workspaceId, resourceId); + // "No row in MY workspace" is two different facts, and only one of them is a + // refusal. A resource NO workspace has claimed is outside the isolation regime + // altogether — the design's "no retroactive tagging" rule, which + // `routes/plugins/index.ts` already applies by skipping this gate entirely for + // an unbound plugin so it cannot become "permanently un-uninstallable the + // moment a caller happens to carry workspace headers", and which design + // systems' `designSystemVisibleFromWorkspace` follows too. + // + // Treating it as a refusal made the gate ASYMMETRIC: `headerlessMutationAllowed` + // short-circuits on "no row anywhere" before it even asks for an identity, so + // the same caller was allowed when it sent NO headers and refused when it + // identified itself. That protected nothing — dropping headers is trivial — and + // it is what forced the web client to tiptoe about when it may name itself, + // surfacing as 401 WORKSPACE_CONTEXT_REQUIRED on a first send. + // + // This only PERMITS the operation. Nothing here writes, so the resource is not + // adopted into the asserting caller's workspace; silently rebinding a + // pre-existing orphan (#6213) remains out of bounds. + if (!row && !getWorkspaceResourceByResourceId(db, resourceId)) return true; + if (!workspaceResourceMutationAllowed( + resourceType, + row, + ctx, + capability, + )) { + const code = row && isWorkspaceResourceLocked(ctx) + ? 'WORKSPACE_LOCKED' + : `WORKSPACE_${resourceType.toUpperCase()}_PERMISSION_DENIED`; + sendApiError(res, 403, code, `workspace ${resourceType} mutation is not allowed`); + return false; + } + return true; +} diff --git a/apps/daemon/src/collab/workspace-resources-reconciler.ts b/apps/daemon/src/collab/workspace-resources-reconciler.ts new file mode 100644 index 00000000000..8ef52b62a89 --- /dev/null +++ b/apps/daemon/src/collab/workspace-resources-reconciler.ts @@ -0,0 +1,181 @@ +// Realtime reconciliation of the local `workspace_resources` SQLite table +// against the resource hub's shared-listing for design-system / plugin / +// skill resources — the generic-table counterpart of +// `workspace-projects-reconciler.ts` for `workspace_projects`. +// +// The gap this closes: `syncSharedTeamDesignSystem` / `syncSharedTeamSkill` +// (server.ts) already handle DIRECTION 1 — a resource the hub still confirms +// as shared gets materialized + bound `visibility: 'team'` — every time a +// kind's `/team` listing is read. Nothing handles DIRECTION 2: a resource a +// workspace has ALREADY bound `visibility: 'team'` that the hub no longer +// lists at all (the owner unshared it, or this member's access was revoked). +// Before this module, that local row simply sat there forever, unexamined — +// the puller's copy vanished from the Team scope (the hub stopped listing +// it) without ever being told to leave "team" state, so it also never +// qualified to reappear anywhere else. This module is what would eventually +// need to run to converge that row, the same way +// `reconcileWorkspaceProjectsWithRemote` converges `workspace_projects`. +// +// Retraction semantic (spec decision, workspace-team continuous-sync 优先级3): +// deliberately NOT "demote to personal" (project's `workspace_projects` model +// is the one to NOT copy). A skill/design-system/plugin pulled copy is a +// materialized MIRROR of someone else's shared resource, not the caller's +// own draft — flipping `visibility` to `'personal'` would misattribute it as +// caller-authored, exactly the bug `SkillSummary.teamSynced` (this same +// continuous-sync effort, priority 2) was written to fix. Instead this marks +// `resourceState: 'deleted'` on the EXISTING `workspace_resources` row and +// leaves `visibility: 'team'` untouched — a tombstone, not a reclassification: +// - `visibility` staying `'team'` means every existing `teamSynced` / +// "is this a team-pulled copy" read (skills.ts's `listSkills`, +// design-systems' `isTeamSyncedUserDesignSystem`-style checks) keeps +// answering the same way it always has, with ZERO code changes needed on +// that side — a retired resource stays excluded from "Personal" exactly +// like an actively-shared one already was. +// - `resourceState: 'deleted'` is this reconciler's own bookkeeping: it is +// what makes a second reconciliation pass a no-op instead of re-writing +// the same row every ~15s poll tick, and it is the auditable "this used +// to be team-shared, then wasn't anymore" fact a future "make this mine" +// reclaim action would key off. Nothing reads it as an exclusion signal +// today because nothing needs to: `visibility` already carries that. +// - The local FILE on disk is never touched. Retraction is a binding-table +// state change only — this module does not delete, move, or rewrite +// anything under `USER_SKILLS_DIR` / `USER_DESIGN_SYSTEMS_DIR`. +// +// Scope: this module is resource-type-agnostic. Daemon wiring drives it for +// design systems, plugins, and skills; each materializer owns creating the +// active Team binding that this reconciler later retires. + +/** This daemon's one local `workspace_resources` row for a resource, as far + * as reconciliation cares. Only rows the caller has already filtered to + * `visibility: 'team'` for the target workspace are meaningful input — see + * `WorkspaceResourcesReconcilerDeps.listLocalActiveTeamRows`. */ +export interface LocalTeamResourceBinding { + resourceId: string; + workspaceId: string; + visibility: 'personal' | 'team'; + resourceState: string | null; +} + +/** What the remote hub says is currently shared — the subset + * `TeamResourceShareService.sharedResources()` (team-resource-share.ts) + * actually carries that the planner needs: the LOCAL resource id (already + * decoded by `parseSharedResourceRecords`, matching `workspace_resources. + * resource_id` directly). */ +export interface RemoteTeamResourceRef { + resourceId: string; +} + +export type WorkspaceResourceReconcileAction = { + kind: 'retire'; + resourceId: string; + workspaceId: string; +}; + +/** + * Pure planner: given what the resource hub currently lists as shared and + * what this daemon's OWN `workspace_resources` rows (already active-team- + * filtered by the caller) claim for the workspace, decide which local rows + * are stale and need retiring. No I/O — the orchestrator below + * (`reconcileWorkspaceResourcesWithRemote`) is the only caller that touches + * the database, which is what keeps this function directly unit-testable. + * + * Only one direction: a local row the remote listing no longer confirms. + * The other direction (remote confirms a resource this daemon has not yet + * materialized/bound) is already handled by `syncSharedTeamDesignSystem` / + * `syncSharedTeamSkill` every time a kind's `/team` listing is read — adding + * a second "confirm" action here would just duplicate that pull-and-bind + * logic under a different name. + */ +export function planWorkspaceResourceReconciliation(input: { + workspaceId: string; + remoteResources: readonly RemoteTeamResourceRef[]; + /** Every row this daemon currently has bound `visibility: 'team'` AND + * `resourceState` other than `'deleted'` for `workspaceId` — see + * `listLocalActiveTeamRows`'s doc comment for the exact prefilter this + * function relies on the caller to apply. */ + localActiveTeamRows: readonly LocalTeamResourceBinding[]; +}): WorkspaceResourceReconcileAction[] { + const remoteIds = new Set(input.remoteResources.map((r) => r.resourceId)); + const actions: WorkspaceResourceReconcileAction[] = []; + for (const local of input.localActiveTeamRows) { + if (local.workspaceId !== input.workspaceId) continue; + if (remoteIds.has(local.resourceId)) continue; + actions.push({ kind: 'retire', resourceId: local.resourceId, workspaceId: local.workspaceId }); + } + return actions; +} + +export interface WorkspaceResourcesReconcilerDeps { + /** The signed-in team workspace this daemon is currently acting as, or + * null off-team / signed out / removed. Must gate on active membership + * (`memberStatus === 'active'`) the same way + * `reconcileWorkspaceProjectsWithRemote`'s `getWorkspaceIdentity` does — a + * context that can still ADDRESS a resource hub partition is not proof + * this member is still IN the team. */ + getWorkspaceIdentity: () => Promise<{ workspaceId: string } | null>; + /** This kind's `TeamResourceShareService.sharedResources()` — the exact + * same hub read `/api/workspace/<kind>/team` already serves (through its + * own SWR cache), so this reconciler never opens a second transport. */ + listRemoteTeamResources: () => Promise<readonly RemoteTeamResourceRef[]>; + /** Every `workspace_resources` row for this resource type bound + * `visibility: 'team'` in `workspaceId`, whose `resourceState` is not + * already `'deleted'` (i.e. `listWorkspaceResources(db, resourceType, + * workspaceId)` filtered by the caller — kept out of this pure function + * so it stays synchronous and test-friendly without a real db handle). */ + listLocalActiveTeamRows: (workspaceId: string) => readonly LocalTeamResourceBinding[]; + /** Write a 'retire' action: flip `resourceState` to `'deleted'`, leaving + * `visibility` untouched. See this module's header comment for why that + * is the correct action and not a demote-to-personal. */ + applyRetire: (workspaceId: string, resourceId: string) => void; + onError?: (error: unknown) => void; +} + +export interface WorkspaceResourcesReconcileResult { + retired: number; +} + +const NO_OP_RESULT: WorkspaceResourcesReconcileResult = { retired: 0 }; + +/** + * Run one reconciliation pass for one resource kind: read the remote shared + * listing, diff it against this daemon's own active `workspace_resources` + * rows for that kind, and retire whatever disagrees. Best-effort throughout — + * a failed identity read or a failed remote read returns a no-op result + * rather than throwing, so a transient hub outage can never be misread as + * "remote reports nothing shared" and retire every local team row on missing + * (as opposed to genuinely empty) data. + */ +export async function reconcileWorkspaceResourcesWithRemote( + deps: WorkspaceResourcesReconcilerDeps, +): Promise<WorkspaceResourcesReconcileResult> { + const identity = await deps.getWorkspaceIdentity().catch((error) => { + deps.onError?.(error); + return null; + }); + if (!identity) return NO_OP_RESULT; + + let remoteResources: readonly RemoteTeamResourceRef[]; + try { + remoteResources = await deps.listRemoteTeamResources(); + } catch (error) { + deps.onError?.(error); + return NO_OP_RESULT; + } + + const localActiveTeamRows = deps.listLocalActiveTeamRows(identity.workspaceId); + const actions = planWorkspaceResourceReconciliation({ + workspaceId: identity.workspaceId, + remoteResources, + localActiveTeamRows, + }); + + for (const action of actions) { + try { + deps.applyRetire(action.workspaceId, action.resourceId); + } catch (error) { + deps.onError?.(error); + } + } + + return { retired: actions.length }; +} diff --git a/apps/daemon/src/collab/workspace-scope.ts b/apps/daemon/src/collab/workspace-scope.ts new file mode 100644 index 00000000000..0d9c82b47f0 --- /dev/null +++ b/apps/daemon/src/collab/workspace-scope.ts @@ -0,0 +1,52 @@ +// Single workspace-scope resolution entry for every workspace-scoped vela +// call, per the B-line explicit-workspace handoff. B's account-level Active +// Workspace is shared mutable state across a user's devices; a daemon task +// that re-read it per tick could silently flip workspaces mid-flight. The +// client therefore pins its own scope with a fixed priority and only lets the +// server's Active Workspace apply when it genuinely has no opinion: +// +// 1. `explicit` — the id this specific call was asked to target; +// 2. `projectWorkspaceId` — the workspace a project belongs to (its shared +// projection row), for project-scoped calls like +// presence and comments; +// 3. `localSelection` — the persisted OD-local workspace selection +// (workspace-selection.json); +// 4. `envWorkspaceId` — a VELA_WORKSPACE_ID inherited from the spawn +// environment; +// 5. none — send no header; the server resolves its stored +// Active Workspace (`source: 'server-current'`). +// +// The resolver is pure: it never invents an id and never mutates server state +// (resource calls must NOT PUT /workspaces/current — only an explicit user +// switch does). + +export type WorkspaceScopeSource = + | 'explicit' + | 'project' + | 'local-selection' + | 'environment' + | 'server-current'; + +export interface WorkspaceScope { + workspaceId?: string; + source: WorkspaceScopeSource; +} + +export interface WorkspaceScopeInputs { + explicit?: string | null; + projectWorkspaceId?: string | null; + localSelection?: string | null; + envWorkspaceId?: string | null; +} + +export function resolveWorkspaceScope(inputs: WorkspaceScopeInputs): WorkspaceScope { + const explicit = inputs.explicit?.trim(); + if (explicit) return { workspaceId: explicit, source: 'explicit' }; + const project = inputs.projectWorkspaceId?.trim(); + if (project) return { workspaceId: project, source: 'project' }; + const local = inputs.localSelection?.trim(); + if (local) return { workspaceId: local, source: 'local-selection' }; + const env = inputs.envWorkspaceId?.trim(); + if (env) return { workspaceId: env, source: 'environment' }; + return { source: 'server-current' }; +} diff --git a/apps/daemon/src/connectionTest.ts b/apps/daemon/src/connectionTest.ts index 6577a1b1309..29447fa3941 100644 --- a/apps/daemon/src/connectionTest.ts +++ b/apps/daemon/src/connectionTest.ts @@ -2182,6 +2182,31 @@ async function testAgentConnectionInternal( let timer: ReturnType<typeof setTimeout> | null = null; let abortHandler: (() => void) | null = null; const sink = createAgentSink(); + let providerConnectivitySettled = false; + let resolveProviderConnectivity!: (value: { + kind: 'providerConnectivity'; + detail: string; + }) => void; + const providerConnectivity = new Promise<{ + kind: 'providerConnectivity'; + detail: string; + }>((resolve) => { + resolveProviderConnectivity = resolve; + }); + const sendAgentEvent = (event: string, payload: unknown) => { + sink.send(event, payload); + if ( + providerConnectivitySettled || + input.agentId !== 'opencode' || + event !== 'stderr' + ) { + return; + } + const detail = openCodeProviderConnectivityDetail(sink.getStderrTail()); + if (!detail) return; + providerConnectivitySettled = true; + resolveProviderConnectivity({ kind: 'providerConnectivity', detail }); + }; // Phase tracker for structured diagnostics (#2248). The order matches // the lifecycle: binary_resolution → spawn → connection_smoke_test → @@ -2310,27 +2335,37 @@ async function testAgentConnectionInternal( }; }; + const resultFromProviderConnectivity = ( + providerDetail: string, + exit?: { code: number | null; signal: NodeJS.Signals | null }, + ): ConnectionTestResponse => { + const detail = redactSecrets(providerDetail); + console.warn(`[test:agent] ${def.name} → upstream_unavailable: ${detail}`); + return { + ok: false, + kind: 'upstream_unavailable', + latencyMs: Date.now() - start, + model, + agentName: def.name, + detail, + diagnostics: buildDiagnostics({ + phase: 'connection_smoke_test', + ...(exit ? { exitCode: exit.code, signal: exit.signal } : {}), + }), + }; + }; + const resultFromCancellation = ( kind: 'timeout' | 'aborted', ): ConnectionTestResponse => { - const latencyMs = Date.now() - start; if (kind === 'timeout' && input.agentId === 'opencode') { const rawDetail = `${sink.getStderrTail()}\n${sink.getRawStdoutTail()}`; const providerDetail = openCodeProviderConnectivityDetail(rawDetail); if (providerDetail) { - const detail = redactSecrets(providerDetail); - console.warn(`[test:agent] ${def.name} → upstream_unavailable: ${detail}`); - return { - ok: false, - kind: 'upstream_unavailable', - latencyMs, - model, - agentName: def.name, - detail, - diagnostics: buildDiagnostics({ phase: 'connection_smoke_test' }), - }; + return resultFromProviderConnectivity(providerDetail); } } + const latencyMs = Date.now() - start; console.warn(`[test:agent] ${def.name} → ${kind} in ${(latencyMs / 1000).toFixed(1)}s`); return { ok: false, @@ -2486,7 +2521,7 @@ async function testAgentConnectionInternal( model, env, liveModelScope, - sink.send, + sendAgentEvent, sink.appendRawStdout, ); @@ -2525,6 +2560,17 @@ async function testAgentConnectionInternal( // close event before all stdout chunks have reached the parser. await delay(AGENT_STDOUT_DRAIN_MS); const latencyMs = Date.now() - start; + if (input.agentId === 'opencode') { + const providerDetail = openCodeProviderConnectivityDetail( + `${sink.getStderrTail()}\n${sink.getRawStdoutTail()}`, + ); + if (providerDetail) { + return resultFromProviderConnectivity(providerDetail, { + code: winner.code, + signal: winner.signal, + }); + } + } const buffered = sink.getText().trim(); const claudeResult = input.agentId === 'claude' ? parseClaudeResultFrame(sink.getRawStdout()) @@ -2799,6 +2845,7 @@ async function testAgentConnectionInternal( sink.result, childExit, cancellationPromise, + providerConnectivity, ]); if (winner.kind === 'text') { @@ -2806,10 +2853,14 @@ async function testAgentConnectionInternal( streamError, childExit, cancellationPromise, + providerConnectivity, ]); if (completion.kind === 'streamError') { return resultFromStreamError(completion.error); } + if (completion.kind === 'providerConnectivity') { + return resultFromProviderConnectivity(completion.detail); + } if (completion.kind === 'timeout' || completion.kind === 'aborted') { return resultFromCancellation(completion.kind); } @@ -2818,6 +2869,9 @@ async function testAgentConnectionInternal( if (winner.kind === 'streamError') { return resultFromStreamError(winner.error); } + if (winner.kind === 'providerConnectivity') { + return resultFromProviderConnectivity(winner.detail); + } if (winner.kind === 'timeout' || winner.kind === 'aborted') { return resultFromCancellation(winner.kind); } diff --git a/apps/daemon/src/craft.ts b/apps/daemon/src/craft.ts index 02c232f2c59..01fe717763f 100644 --- a/apps/daemon/src/craft.ts +++ b/apps/daemon/src/craft.ts @@ -1,15 +1,73 @@ -// Craft references loader. The active skill declares which sections it -// needs via `od.craft.requires`; this module reads the matching files -// from <projectRoot>/craft/<slug>.md and returns a single concatenated -// body ready to splice into the system prompt. Missing files are -// dropped silently — a skill that lists `motion` before we ship a -// motion.md should still work, just without the motion section. +// Craft references loader and request resolver. Skills and design systems +// can opt into sections explicitly, while deck generation receives the +// typography baseline even when it arrived through a plugin or freeform +// prompt with no persisted skill id. Missing files are dropped silently — +// a caller that lists `motion` before we ship motion.md should still work. import { readFile } from "node:fs/promises"; import path from "node:path"; const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/; +export interface ResolveCraftRequirementsInput { + isWebCloneRun?: boolean; + metadataKind?: string | null; + skillModes?: Iterable<string>; + freeformDeckSignal?: boolean; + skillRequires?: readonly string[]; + designSystemApplies?: readonly string[]; + designSystemExemptions?: readonly string[]; +} + +function normalizeCraftSlugs(values: Iterable<string> | undefined): string[] { + if (!values) return []; + const seen = new Set<string>(); + const normalized: string[] = []; + for (const value of values) { + if (typeof value !== "string") continue; + const slug = value.trim().toLowerCase(); + if (!SLUG_RE.test(slug) || seen.has(slug)) continue; + seen.add(slug); + normalized.push(slug); + } + return normalized; +} + +/** + * Resolve the craft sections for one run. + * + * Deck typography is a runtime invariant, not only a skill opt-in: official + * deck plugins keep `project.skill_id` empty and freeform deck prompts can do + * the same. Without this fallback those paths miss the CJK leading rules in + * craft/typography.md and can render multi-line Chinese headings at Latin + * poster leading. + */ +export function resolveCraftRequirements({ + isWebCloneRun = false, + metadataKind, + skillModes, + freeformDeckSignal = false, + skillRequires = [], + designSystemApplies = [], + designSystemExemptions = [], +}: ResolveCraftRequirementsInput): string[] { + if (isWebCloneRun) return []; + + const modes = new Set(normalizeCraftSlugs(skillModes)); + const isDeckProject = metadataKind === "deck" || modes.has("deck"); + const isFreeformDeck = + modes.size === 0 + && (!metadataKind || metadataKind === "other") + && freeformDeckSignal; + const requested = normalizeCraftSlugs([ + ...skillRequires, + ...designSystemApplies, + ...(isDeckProject || isFreeformDeck ? ["typography"] : []), + ]); + const excluded = new Set(normalizeCraftSlugs(designSystemExemptions)); + return requested.filter((slug) => !excluded.has(slug)); +} + /** * @param {string} craftDir absolute path to the craft/ directory * @param {string[]} requested slugs from `od.craft.requires` diff --git a/apps/daemon/src/db.ts b/apps/daemon/src/db.ts index 0bb1c6c166f..8aa4d33aba8 100644 --- a/apps/daemon/src/db.ts +++ b/apps/daemon/src/db.ts @@ -8,8 +8,17 @@ import Database from 'better-sqlite3'; import path from 'node:path'; import fs from 'node:fs'; import { randomUUID } from 'node:crypto'; -import type { ProjectBrowserWorkspaceTab, ProjectTabsState } from '@open-design/contracts'; +import type { + CollabCloudComment, + ProjectBrowserWorkspaceTab, + ProjectTabsState, +} from '@open-design/contracts'; import { eventsEndedWithUnfinishedWork } from '@open-design/contracts'; +import { migrateCollabSyncSnapshots } from './collab/sync-snapshot-store.js'; +import { + collapseWorkspaceProjectHomes, + type WorkspaceProjectHomeRow, +} from './collab/workspace-project-home.js'; import { migrateCritique } from './critique/persistence.js'; import { migrateMediaTasks } from './media/tasks.js'; import { migrateLibrary } from './library-store.js'; @@ -66,6 +75,80 @@ function migrate(db: SqliteDb): void { updated_at INTEGER NOT NULL ); + -- A project belongs to exactly ONE workspace, so project_id is the key. + -- See collab/workspace-project-home.ts for the ruling and the repair path. + CREATE TABLE IF NOT EXISTS workspace_projects ( + project_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + visibility TEXT NOT NULL CHECK (visibility IN ('personal', 'team')), + resource_state TEXT NOT NULL CHECK (resource_state IN ('active', 'frozen', 'deleted')), + created_by_workspace_member_id TEXT, + updated_by_workspace_member_id TEXT, + resource_hub_resource_id TEXT, + cloud_tombstoned_at INTEGER, + sync_state TEXT, + version INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_workspace_projects_workspace_visibility + ON workspace_projects(workspace_id, visibility, updated_at DESC); + + CREATE TABLE IF NOT EXISTS team_project_materializations ( + workspace_id TEXT NOT NULL, + resource_team_id TEXT NOT NULL, + viewer_member_id TEXT NOT NULL, + owner_member_id TEXT NOT NULL, + project_id TEXT NOT NULL, + resource_id TEXT NOT NULL, + ref TEXT NOT NULL CHECK (ref = 'published'), + version INTEGER NOT NULL, + version_id TEXT NOT NULL, + manifest_digest TEXT NOT NULL, + lifecycle_state TEXT NOT NULL CHECK (lifecycle_state = 'active'), + authorized_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (workspace_id, project_id), + FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE + ); + + -- The generic workspace-binding table for resource types that do NOT get + -- their own dedicated table (plugin today; skill / design system are + -- planned follow-ups — see specs/current for the phased rollout). Same + -- "binding envelope" columns as workspace_projects, parameterized by + -- resource_type so one CRUD layer (see getWorkspaceResource and friends + -- below) and one mutation gate (collab/workspace-resource-mutation.ts) + -- serve every resource type instead of forking per type. + -- + -- Unlike workspace_projects, resource_id has no FOREIGN KEY here: which + -- table it points at depends on resource_type, and SQLite has no + -- polymorphic foreign key. Callers that delete a resource's underlying + -- record MUST also delete its workspace_resources row (by resource_type + + -- resource_id) themselves, or it becomes an orphan binding — the same + -- failure mode workspace_projects_legacy_single_project once hit. + CREATE TABLE IF NOT EXISTS workspace_resources ( + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + visibility TEXT NOT NULL CHECK (visibility IN ('personal', 'team')), + resource_state TEXT, + created_by_workspace_member_id TEXT, + updated_by_workspace_member_id TEXT, + resource_hub_resource_id TEXT, + cloud_tombstoned_at INTEGER, + sync_state TEXT, + version INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (resource_type, resource_id) + ); + + CREATE INDEX IF NOT EXISTS idx_workspace_resources_type_workspace + ON workspace_resources(resource_type, workspace_id, updated_at DESC); + CREATE TABLE IF NOT EXISTS templates ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -165,7 +248,10 @@ function migrate(db: SqliteDb): void { status TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, - UNIQUE(project_id, conversation_id, file_path, element_id, slide_key), + anchor_state TEXT, + anchored_version INTEGER, + author_member_id TEXT, + last_good_position_json TEXT, FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, FOREIGN KEY(conversation_id) REFERENCES conversations(id) ON DELETE CASCADE ); @@ -269,6 +355,14 @@ function migrate(db: SqliteDb): void { if (!cols.some((c: DbRow) => c.name === 'custom_instructions')) { db.exec(`ALTER TABLE projects ADD COLUMN custom_instructions TEXT`); } + const workspaceProjectCols = db.prepare(`PRAGMA table_info(workspace_projects)`).all() as DbRow[]; + if (!workspaceProjectCols.some((c: DbRow) => c.name === 'resource_hub_resource_id')) { + db.exec(`ALTER TABLE workspace_projects ADD COLUMN resource_hub_resource_id TEXT`); + } + if (!workspaceProjectCols.some((c: DbRow) => c.name === 'cloud_tombstoned_at')) { + db.exec(`ALTER TABLE workspace_projects ADD COLUMN cloud_tombstoned_at INTEGER`); + } + migrateWorkspaceProjectsSingleHome(db); const conversationCols = db.prepare(`PRAGMA table_info(conversations)`).all() as DbRow[]; if (!conversationCols.some((c: DbRow) => c.name === 'session_mode')) { db.exec(`ALTER TABLE conversations ADD COLUMN session_mode TEXT NOT NULL DEFAULT 'design'`); @@ -344,6 +438,43 @@ function migrate(db: SqliteDb): void { db.exec(`ALTER TABLE preview_comments ADD COLUMN slide_index INTEGER`); } migratePreviewCommentsSlideKey(db); + // Team collaboration anchor columns — added after the slide-key rebuild so a legacy + // table rebuild cannot drop them. + const previewCommentAnchorCols = db.prepare(`PRAGMA table_info(preview_comments)`).all() as DbRow[]; + if (!previewCommentAnchorCols.some((c: DbRow) => c.name === 'anchor_state')) { + db.exec(`ALTER TABLE preview_comments ADD COLUMN anchor_state TEXT`); + } + if (!previewCommentAnchorCols.some((c: DbRow) => c.name === 'anchored_version')) { + db.exec(`ALTER TABLE preview_comments ADD COLUMN anchored_version INTEGER`); + } + if (!previewCommentAnchorCols.some((c: DbRow) => c.name === 'author_member_id')) { + db.exec(`ALTER TABLE preview_comments ADD COLUMN author_member_id TEXT`); + } + if (!previewCommentAnchorCols.some((c: DbRow) => c.name === 'last_good_position_json')) { + db.exec(`ALTER TABLE preview_comments ADD COLUMN last_good_position_json TEXT`); + } + // Multiple comments per element: edit by explicit id; creating another note + // on the same element inserts a new row. + migratePreviewCommentsAllowMultiplePerElement(db); + // Stable canvas pin numbering + persisted sidebar order (recvq5BVsolIxi). + // Added after the multi-per-element rebuild so a legacy table rebuild can + // never drop them (same reasoning as the anchor columns above). + const previewCommentPinCols = db.prepare(`PRAGMA table_info(preview_comments)`).all() as DbRow[]; + if (!previewCommentPinCols.some((c: DbRow) => c.name === 'pin_seq')) { + db.exec(`ALTER TABLE preview_comments ADD COLUMN pin_seq INTEGER`); + } + if (!previewCommentPinCols.some((c: DbRow) => c.name === 'pin_seq_confirmed')) { + // 1 = final (no reconciliation pending). A NEW comment on a team-shared + // project starts at 0 until the collab-cloud push confirms the real + // cloud-assigned seq (see confirmPreviewCommentPinSeq) — see this file's + // upsertPreviewComment for why a locally-computed pin_seq can otherwise + // collide across two devices creating a comment in the same poll window. + db.exec(`ALTER TABLE preview_comments ADD COLUMN pin_seq_confirmed INTEGER NOT NULL DEFAULT 1`); + } + if (!previewCommentPinCols.some((c: DbRow) => c.name === 'sort_key')) { + db.exec(`ALTER TABLE preview_comments ADD COLUMN sort_key REAL`); + } + backfillPreviewCommentPinSeqAndSortKey(db); const deploymentCols = db.prepare(`PRAGMA table_info(deployments)`).all() as DbRow[]; if (!deploymentCols.some((c: DbRow) => c.name === 'status')) { db.exec(`ALTER TABLE deployments ADD COLUMN status TEXT NOT NULL DEFAULT 'ready'`); @@ -397,6 +528,108 @@ function migrate(db: SqliteDb): void { migrateMediaTasks(db); migrateLibrary(db); migratePlugins(db); + migrateCollabSyncSnapshots(db); +} + +/** + * Bind every project to exactly ONE workspace, and make any other state + * unrepresentable. + * + * Product ruling (2026-07-21): a project is created in a workspace and lives + * there; sharing flips `visibility` within that workspace rather than projecting + * the project into a second one. See collab/workspace-project-home.ts for the + * full statement and for the rule that picks the surviving row. + * + * Two steps, in this order, inside one transaction: + * 1. collapse the duplicate rows an older build's blanket back-fill wrote — + * on the dogfood database 23 of 31 projects had rows in 2-4 workspaces; + * 2. narrow the primary key from `(workspace_id, project_id)` back to + * `project_id`, which is what it was before a migration widened it (the + * table it renamed was called `workspace_projects_legacy_single_project`). + * + * The order matters: the rebuild's INSERT would fail on the narrowed key if the + * duplicates were still there. Step 1 therefore runs on every startup, not just + * on the one that narrows the key, so a row that predates this build is repaired + * even if the key was already narrow. It is idempotent and costs one indexed + * scan. + * + * A migration rather than the startup reconciliation used for impossible team + * shares (server.ts `reconcileImpossibleTeamShares`): that one needs the + * workspace DIRECTORY to decide, which is a signed-in network fact, so it cannot + * run before the first read. This one decides from the table alone, so it can — + * and it must, because the read path below now assumes at most one row. + */ +function migrateWorkspaceProjectsSingleHome(db: SqliteDb): void { + const collapse = db.transaction(() => { + const rows = db + .prepare( + `SELECT project_id AS projectId, + workspace_id AS workspaceId, + visibility, + created_by_workspace_member_id AS createdByWorkspaceMemberId, + created_at AS createdAt + FROM workspace_projects`, + ) + .all() as WorkspaceProjectHomeRow[]; + const decisions = collapseWorkspaceProjectHomes(rows); + if (decisions.length === 0) return 0; + const drop = db.prepare( + `DELETE FROM workspace_projects WHERE workspace_id = ? AND project_id = ?`, + ); + let dropped = 0; + for (const decision of decisions) { + for (const row of decision.drop) { + drop.run(row.workspaceId, row.projectId); + dropped += 1; + } + } + return dropped; + }); + const dropped = collapse(); + if (dropped > 0) { + console.warn( + `[od] bound ${dropped} duplicated workspace project row(s) to a single workspace each. ` + + 'A project belongs to one workspace; the extras came from an older blanket back-fill.', + ); + } + + const cols = db.prepare(`PRAGMA table_info(workspace_projects)`).all() as DbRow[]; + const projectPk = cols.find((c: DbRow) => c.name === 'project_id')?.pk ?? 0; + const workspacePk = cols.find((c: DbRow) => c.name === 'workspace_id')?.pk ?? 0; + if (projectPk === 1 && workspacePk === 0) return; + + db.exec(` + DROP INDEX IF EXISTS idx_workspace_projects_workspace_visibility; + ALTER TABLE workspace_projects RENAME TO workspace_projects_legacy_multi_workspace; + CREATE TABLE workspace_projects ( + project_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + visibility TEXT NOT NULL CHECK (visibility IN ('personal', 'team')), + resource_state TEXT NOT NULL CHECK (resource_state IN ('active', 'frozen', 'deleted')), + created_by_workspace_member_id TEXT, + updated_by_workspace_member_id TEXT, + resource_hub_resource_id TEXT, + cloud_tombstoned_at INTEGER, + sync_state TEXT, + version INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE + ); + INSERT INTO workspace_projects + (project_id, workspace_id, visibility, resource_state, + created_by_workspace_member_id, updated_by_workspace_member_id, + resource_hub_resource_id, cloud_tombstoned_at, + sync_state, version, created_at, updated_at) + SELECT project_id, workspace_id, visibility, resource_state, + created_by_workspace_member_id, updated_by_workspace_member_id, + resource_hub_resource_id, cloud_tombstoned_at, + sync_state, version, created_at, updated_at + FROM workspace_projects_legacy_multi_workspace; + DROP TABLE workspace_projects_legacy_multi_workspace; + CREATE INDEX IF NOT EXISTS idx_workspace_projects_workspace_visibility + ON workspace_projects(workspace_id, visibility, updated_at DESC); + `); } function migratePreviewCommentsSlideKey(db: SqliteDb): void { @@ -432,7 +665,6 @@ function migratePreviewCommentsSlideKey(db: SqliteDb): void { status TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, - UNIQUE(project_id, conversation_id, file_path, element_id, slide_key), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, FOREIGN KEY(conversation_id) REFERENCES conversations(id) ON DELETE CASCADE ); @@ -453,6 +685,129 @@ function migratePreviewCommentsSlideKey(db: SqliteDb): void { `); } +/** + * Rebuild `preview_comments` so comments are keyed by `id` only. + * + * Older schemas had a natural unique key on project/conversation/file/element/ + * slide/author. That prevented multiple notes by the same member on one + * element. Editing now requires the caller to send an explicit comment id. + */ +function migratePreviewCommentsAllowMultiplePerElement(db: SqliteDb): void { + const table = db + .prepare(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'preview_comments'`) + .get() as DbRow | undefined; + const tableSql = String(table?.sql ?? ''); + const hasNaturalUnique = /UNIQUE\s*\([^)]*\bproject_id\b[^)]*\belement_id\b[^)]*\)/i.test(tableSql); + if (!hasNaturalUnique) return; + + db.exec(` + CREATE TABLE preview_comments_multi_next ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + file_path TEXT NOT NULL, + element_id TEXT NOT NULL, + selector TEXT NOT NULL, + label TEXT NOT NULL, + text TEXT NOT NULL, + position_json TEXT NOT NULL, + html_hint TEXT NOT NULL, + selection_kind TEXT, + member_count INTEGER, + pod_members_json TEXT, + style_json TEXT, + attachments_json TEXT, + slide_index INTEGER, + slide_key INTEGER NOT NULL DEFAULT -1, + note TEXT NOT NULL, + status TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + anchor_state TEXT, + anchored_version INTEGER, + author_member_id TEXT, + last_good_position_json TEXT, + FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, + FOREIGN KEY(conversation_id) REFERENCES conversations(id) ON DELETE CASCADE + ); + + INSERT INTO preview_comments_multi_next + (id, project_id, conversation_id, file_path, element_id, selector, label, + text, position_json, html_hint, selection_kind, member_count, pod_members_json, + style_json, attachments_json, slide_index, slide_key, note, status, created_at, updated_at, + anchor_state, anchored_version, author_member_id, last_good_position_json) + SELECT id, project_id, conversation_id, file_path, element_id, selector, label, + text, position_json, html_hint, selection_kind, member_count, pod_members_json, + style_json, attachments_json, slide_index, slide_key, note, status, created_at, updated_at, + anchor_state, anchored_version, author_member_id, last_good_position_json + FROM preview_comments; + + DROP TABLE preview_comments; + ALTER TABLE preview_comments_multi_next RENAME TO preview_comments; + CREATE INDEX IF NOT EXISTS idx_preview_comments_conversation + ON preview_comments(project_id, conversation_id, updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_preview_comments_conversation_created + ON preview_comments(project_id, conversation_id, created_at ASC); + `); +} + +/** + * Backfill `pin_seq`/`sort_key` for rows written before those columns + * existed. Cheap no-op once every row is backfilled (the WHERE clause skips + * already-assigned rows), so it is safe to call on every startup. + * + * `pin_seq` is assigned per (project_id, file_path), ordered exactly like the + * pre-existing canvas numbering (`created_at ASC, rowid ASC` — see + * `listPreviewComments`), so an already-open project's pin numbers do not + * visibly change the moment this migration lands. + * + * `sort_key` backfills to `created_at` so the new sort-by-sort_key-descending + * default (see FileViewer's `visibleSideComments`) reproduces "newest first" + * for every pre-existing comment too, not just ones created after this ships. + */ +function backfillPreviewCommentPinSeqAndSortKey(db: SqliteDb): void { + const pending = db + .prepare( + `SELECT id, project_id AS projectId, file_path AS filePath, created_at AS createdAt + FROM preview_comments + WHERE pin_seq IS NULL + ORDER BY project_id ASC, file_path ASC, created_at ASC, rowid ASC`, + ) + .all() as DbRow[]; + if (pending.length === 0) return; + const setPinSeq = db.prepare(`UPDATE preview_comments SET pin_seq = ? WHERE id = ?`); + const setSortKey = db.prepare( + `UPDATE preview_comments SET sort_key = ? WHERE id = ? AND sort_key IS NULL`, + ); + // Seed each scope's counter from whatever is already assigned there (belt + // and suspenders — in the normal flow this backfill clears every NULL row in + // one pass at the first startup after the migration lands, so there is + // nothing already-assigned to seed from, but a partial prior run must not + // renumber from 1 and collide with rows that already have a real pin_seq). + const alreadyAssigned = db + .prepare( + `SELECT project_id AS projectId, file_path AS filePath, MAX(pin_seq) AS maxSeq + FROM preview_comments + WHERE pin_seq IS NOT NULL + GROUP BY project_id, file_path`, + ) + .all() as DbRow[]; + const nextPinSeqByScope = new Map<string, number>(); + for (const row of alreadyAssigned) { + nextPinSeqByScope.set(`${row.projectId} ${row.filePath}`, Number(row.maxSeq) || 0); + } + const backfill = db.transaction(() => { + for (const row of pending) { + const scopeKey = `${row.projectId} ${row.filePath}`; + const nextSeq = (nextPinSeqByScope.get(scopeKey) ?? 0) + 1; + nextPinSeqByScope.set(scopeKey, nextSeq); + setPinSeq.run(nextSeq, row.id); + setSortKey.run(row.createdAt, row.id); + } + }); + backfill(); +} + // ---------- deployments ---------- const DEPLOYMENT_COLS = `id, project_id AS projectId, file_name AS fileName, @@ -629,6 +984,588 @@ export function listProjects(db: SqliteDb) { return rows.map(normalizeProject); } +/** + * Every project with NO `workspace_projects` binding row at all — the "no + * scope" catalog `GET /api/projects` must actually serve (spec 04 §10.2#1). + * `listProjects` above stays the unfiltered form other internal callers + * (library-sync's inventory scan, `listProjectIds` for the design-run + * cross-reference) legitimately still want; this is the ONE new consumer that + * needs the join. A project bound to ANY workspace — personal or team — is + * someone's claimed resource and must not leak into a headerless read. + */ +export function listUnboundProjects(db: SqliteDb) { + const rows = db + .prepare( + `SELECT p.id, p.name, p.skill_id AS skillId, + p.design_system_id AS designSystemId, + p.pending_prompt AS pendingPrompt, + p.metadata_json AS metadataJson, + p.applied_plugin_snapshot_id AS appliedPluginSnapshotId, + p.custom_instructions AS customInstructions, + p.created_at AS createdAt, + p.updated_at AS updatedAt + FROM projects p + LEFT JOIN workspace_projects wp ON wp.project_id = p.id + WHERE wp.project_id IS NULL + ORDER BY p.updated_at DESC`, + ) + .all() as DbRow[]; + return rows.map(normalizeProject); +} + +export function getWorkspaceProject(db: SqliteDb, workspaceId: string, projectId: string) { + return db + .prepare( + `SELECT project_id AS projectId, + workspace_id AS workspaceId, + visibility, + resource_state AS resourceState, + created_by_workspace_member_id AS createdByWorkspaceMemberId, + updated_by_workspace_member_id AS updatedByWorkspaceMemberId, + resource_hub_resource_id AS resourceHubResourceId, + cloud_tombstoned_at AS cloudTombstonedAt, + sync_state AS syncState, + version, + created_at AS createdAt, + updated_at AS updatedAt + FROM workspace_projects + WHERE workspace_id = ? AND project_id = ?`, + ) + .get(workspaceId, projectId) as DbRow | undefined; +} + +export function listWorkspaceProjects(db: SqliteDb, workspaceId: string) { + return db + .prepare( + `SELECT p.id, + p.name, + p.skill_id AS skillId, + p.design_system_id AS designSystemId, + p.pending_prompt AS pendingPrompt, + p.metadata_json AS metadataJson, + p.applied_plugin_snapshot_id AS appliedPluginSnapshotId, + p.custom_instructions AS customInstructions, + p.created_at AS createdAt, + p.updated_at AS updatedAt, + wp.project_id AS workspaceProjectId, + wp.workspace_id AS workspaceId, + wp.visibility AS workspaceVisibility, + wp.resource_state AS resourceState, + wp.created_by_workspace_member_id AS createdByWorkspaceMemberId, + wp.updated_by_workspace_member_id AS updatedByWorkspaceMemberId, + wp.resource_hub_resource_id AS resourceHubResourceId, + wp.cloud_tombstoned_at AS cloudTombstonedAt, + wp.sync_state AS syncState, + wp.version AS workspaceVersion, + wp.created_at AS workspaceCreatedAt, + wp.updated_at AS workspaceUpdatedAt + FROM workspace_projects wp + JOIN projects p ON p.id = wp.project_id + WHERE wp.workspace_id = ? + ORDER BY MAX(p.updated_at, wp.updated_at) DESC`, + ) + .all(workspaceId) as DbRow[]; +} + +/** + * Every project's workspace, as one map. The bulk form of + * {@link getWorkspaceProjectByProjectId}, for list endpoints that would + * otherwise issue one lookup per project on a hot path. + */ +export function listWorkspaceProjectBindings(db: SqliteDb): Map<string, string> { + const rows = db + .prepare(`SELECT project_id AS projectId, workspace_id AS workspaceId FROM workspace_projects`) + .all() as Array<{ projectId: string; workspaceId: string }>; + return new Map(rows.map((row) => [row.projectId, row.workspaceId])); +} + +export function listTeamWorkspaceProjectShares(db: SqliteDb) { + return db + .prepare( + `SELECT project_id AS projectId, + workspace_id AS workspaceId, + visibility, + created_by_workspace_member_id AS createdByWorkspaceMemberId, + updated_by_workspace_member_id AS updatedByWorkspaceMemberId, + sync_state AS syncState + FROM workspace_projects + WHERE visibility = 'team' + AND resource_state != 'deleted'`, + ) + .all() as DbRow[]; +} + +/** + * The workspace a project belongs to, looked up by project alone. + * + * A project has exactly one workspace (see collab/workspace-project-home.ts), so + * this — not `getWorkspaceProject(db, workspaceId, projectId)` — is the question + * to ask before binding a project anywhere. Asking the two-key form and getting + * nothing back means "not in THIS workspace", which an older build mistook for + * "not bound anywhere" and answered by writing another row. + */ +export function getWorkspaceProjectByProjectId(db: SqliteDb, projectId: string) { + return db + .prepare( + `SELECT project_id AS projectId, + workspace_id AS workspaceId, + visibility, + resource_state AS resourceState, + created_by_workspace_member_id AS createdByWorkspaceMemberId, + updated_by_workspace_member_id AS updatedByWorkspaceMemberId, + resource_hub_resource_id AS resourceHubResourceId, + cloud_tombstoned_at AS cloudTombstonedAt, + sync_state AS syncState, + version, + created_at AS createdAt, + updated_at AS updatedAt + FROM workspace_projects + WHERE project_id = ?`, + ) + .get(projectId) as DbRow | undefined; +} + +/** + * The `updatedAt` a writer passes when its write is SYNC, not a local person's + * change: keep the row's existing answer instead of stamping "now". + * + * A project's `updated_at` answers exactly one question for the UI — when did a + * person last change this project's conversations, files, or name? The project + * card renders it as one relative time and the list sorts by it, folding the + * project row and its `workspace_projects` binding together with + * `MAX(p.updated_at, wp.updated_at)` (see `listWorkspaceProjects` below and + * `normalizeWorkspaceProjectRow` in routes/project/index.ts). So BOTH rows have + * to answer it, and only a local action may answer it with `Date.now()`. + * + * Sync writes rows without anything having changed: materializing a teammate's + * pulled content, clearing a revocation or placeholder flag once that pull + * lands, advancing `sync_state` after a background upload, reconciling a + * binding against the team catalog. A writer on one of those paths must either + * carry the ORIGIN's timestamp when it has one (as `materializePulledTeamMirror` + * does) or pass this marker. Letting them fall through to "now" is what made a + * member's card read 「刚刚更新」 hours after a background pull they never asked + * for — the reported bug. + */ +export const SYNC_KEEPS_UPDATED_AT = '__od.sync-keeps-updated-at__' as const; + +/** + * Resolve a patch's `updatedAt` for a row that already exists: an explicit + * number wins, {@link SYNC_KEEPS_UPDATED_AT} keeps `existing`, and anything + * else (a patch that simply omits it) stamps now. + */ +function nextUpdatedAt(patched: unknown, existing: unknown): number { + if (typeof patched === 'number') return patched; + if (patched === SYNC_KEEPS_UPDATED_AT && typeof existing === 'number') { + return existing; + } + return Date.now(); +} + +/** + * Bind a project to a workspace, or return the binding it already has. + * + * Deliberately keyed on the PROJECT, not on `(workspace, project)`: a project + * already bound elsewhere is returned as-is rather than bound a second time. + * That is what makes the caller's "ensure" idempotent across workspaces instead + * of one back-fill per workspace visited — and it is also what the narrowed + * primary key now enforces, so an accidental second insert throws instead of + * silently duplicating. + * + * A fresh binding's `updated_at` falls back to the PROJECT's own `updated_at`, + * not to now: the project list reports `MAX(p.updated_at, wp.updated_at)` as one + * "last changed" time, so a binding written while syncing an old project must + * not claim the project just changed (see {@link SYNC_KEEPS_UPDATED_AT}). A + * caller that genuinely means "now" — a brand-new project — gets the same answer + * either way, because its project row was stamped now a moment ago. + */ +export function ensureWorkspaceProject(db: SqliteDb, input: DbRow) { + const now = Date.now(); + const existing = getWorkspaceProjectByProjectId(db, input.projectId); + if (existing) return existing; + const boundProjectUpdatedAt = getProject(db, input.projectId)?.updatedAt; + const insertedUpdatedAt = typeof input.updatedAt === 'number' + ? input.updatedAt + : typeof boundProjectUpdatedAt === 'number' + ? boundProjectUpdatedAt + : now; + db.prepare( + `INSERT INTO workspace_projects + (project_id, workspace_id, visibility, resource_state, + created_by_workspace_member_id, updated_by_workspace_member_id, + resource_hub_resource_id, cloud_tombstoned_at, + sync_state, version, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + input.projectId, + input.workspaceId, + input.visibility ?? 'personal', + input.resourceState ?? 'active', + input.createdByWorkspaceMemberId ?? null, + input.updatedByWorkspaceMemberId ?? input.createdByWorkspaceMemberId ?? null, + input.resourceHubResourceId ?? null, + input.cloudTombstonedAt ?? null, + input.syncState ?? 'local_only', + input.version ?? 1, + input.createdAt ?? now, + insertedUpdatedAt, + ); + return getWorkspaceProject(db, input.workspaceId, input.projectId); +} + +export function updateWorkspaceProject(db: SqliteDb, workspaceId: string, projectId: string, patch: DbRow) { + const existing = getWorkspaceProject(db, workspaceId, projectId); + if (!existing) return null; + const next: DbRow = { + ...existing, + ...patch, + resourceHubResourceId: patch.resourceHubResourceId === undefined + ? existing.resourceHubResourceId + : patch.resourceHubResourceId, + cloudTombstonedAt: patch.cloudTombstonedAt === undefined + ? existing.cloudTombstonedAt + : patch.cloudTombstonedAt, + updatedAt: nextUpdatedAt(patch.updatedAt, existing.updatedAt), + }; + db.prepare( + `UPDATE workspace_projects + SET workspace_id = ?, + visibility = ?, + resource_state = ?, + created_by_workspace_member_id = ?, + updated_by_workspace_member_id = ?, + resource_hub_resource_id = ?, + cloud_tombstoned_at = ?, + sync_state = ?, + version = ?, + updated_at = ? + WHERE workspace_id = ? AND project_id = ?`, + ).run( + workspaceId, + next.visibility, + next.resourceState, + next.createdByWorkspaceMemberId ?? null, + next.updatedByWorkspaceMemberId ?? null, + next.resourceHubResourceId ?? null, + next.cloudTombstonedAt ?? null, + next.syncState ?? null, + next.version ?? 1, + next.updatedAt, + workspaceId, + projectId, + ); + return getWorkspaceProject(db, workspaceId, projectId); +} + +/** + * Update a project's `workspace_projects` row by project alone, reassigning + * `workspace_id` to whatever the caller passes — the one case where the row's + * CURRENT workspace is allowed to differ from the workspace this event is + * asserting. + * + * `updateWorkspaceProject` requires the caller to already know the row's + * current `workspace_id` (its lookup and its `WHERE` both key on it), which is + * right for callers acting on a row they just read. It is wrong for a remote + * team-share notification: the local row can predate the share (a personal + * draft the user made before ever joining the team it just got shared into), + * so it sits under an unrelated, stale `workspace_id`. Asking + * `updateWorkspaceProject(db, newWorkspaceId, ...)` in that case finds nothing + * — same shape of mistake `getWorkspaceProjectByProjectId`'s own doc comment + * warns about — and the row is silently never migrated: visibility and + * sync_state stay frozen at whatever they were, so the project never starts + * pulling the sharer's updates. + */ +export function rebindWorkspaceProject(db: SqliteDb, projectId: string, patch: DbRow) { + const existing = getWorkspaceProjectByProjectId(db, projectId); + if (!existing) return null; + const workspaceId = typeof patch.workspaceId === 'string' ? patch.workspaceId : existing.workspaceId; + const next: DbRow = { + ...existing, + ...patch, + workspaceId, + resourceHubResourceId: patch.resourceHubResourceId === undefined + ? existing.resourceHubResourceId + : patch.resourceHubResourceId, + cloudTombstonedAt: patch.cloudTombstonedAt === undefined + ? existing.cloudTombstonedAt + : patch.cloudTombstonedAt, + updatedAt: nextUpdatedAt(patch.updatedAt, existing.updatedAt), + }; + db.prepare( + `UPDATE workspace_projects + SET workspace_id = ?, + visibility = ?, + resource_state = ?, + created_by_workspace_member_id = ?, + updated_by_workspace_member_id = ?, + resource_hub_resource_id = ?, + cloud_tombstoned_at = ?, + sync_state = ?, + version = ?, + updated_at = ? + WHERE project_id = ?`, + ).run( + workspaceId, + next.visibility, + next.resourceState, + next.createdByWorkspaceMemberId ?? null, + next.updatedByWorkspaceMemberId ?? null, + next.resourceHubResourceId ?? null, + next.cloudTombstonedAt ?? null, + next.syncState ?? null, + next.version ?? 1, + next.updatedAt, + projectId, + ); + return getWorkspaceProjectByProjectId(db, projectId); +} + +export function deleteWorkspaceProject(db: SqliteDb, workspaceId: string, projectId: string): void { + db.prepare( + `DELETE FROM workspace_projects + WHERE workspace_id = ? AND project_id = ?`, + ).run(workspaceId, projectId); +} + +/** + * The workspace a project's TEAM projection lives in — the project's pinned + * scope for hub-facing calls (presence, comments). A project shared to (or + * pulled from) a team has exactly one team-visibility row; personal drafts + * have none and resolve to null so callers fall back to the local selection. + */ +export function findTeamWorkspaceIdForProject(db: SqliteDb, projectId: string): string | null { + const row = db.prepare( + `SELECT workspace_id AS workspaceId + FROM workspace_projects + WHERE project_id = ? AND visibility = 'team' + LIMIT 1`, + ).get(projectId) as { workspaceId?: string } | undefined; + const workspaceId = typeof row?.workspaceId === 'string' ? row.workspaceId.trim() : ''; + return workspaceId || null; +} + +export function countWorkspaceProjectRefs(db: SqliteDb, projectId: string): number { + const row = db.prepare( + `SELECT COUNT(*) AS count + FROM workspace_projects + WHERE project_id = ?`, + ).get(projectId) as { count?: number } | undefined; + return Number(row?.count ?? 0); +} + +const WORKSPACE_RESOURCE_SELECT_COLUMNS = ` + resource_type AS resourceType, + resource_id AS resourceId, + workspace_id AS workspaceId, + visibility, + resource_state AS resourceState, + created_by_workspace_member_id AS createdByWorkspaceMemberId, + updated_by_workspace_member_id AS updatedByWorkspaceMemberId, + resource_hub_resource_id AS resourceHubResourceId, + cloud_tombstoned_at AS cloudTombstonedAt, + sync_state AS syncState, + version, + created_at AS createdAt, + updated_at AS updatedAt`; + +/** + * The generic counterpart of {@link getWorkspaceProject}, parameterized by + * `resourceType` ('plugin' | 'skill' | 'design_system' — 'project' itself + * stays on the dedicated `workspace_projects` table above). Returns null when + * the resource is unbound OR bound to a DIFFERENT workspace than the one + * asked about — same "wrong workspace reads as absent" contract as + * `getWorkspaceProject`. + */ +export function getWorkspaceResource( + db: SqliteDb, + resourceType: string, + workspaceId: string, + resourceId: string, +) { + return db + .prepare( + `SELECT ${WORKSPACE_RESOURCE_SELECT_COLUMNS} + FROM workspace_resources + WHERE resource_type = ? AND workspace_id = ? AND resource_id = ?`, + ) + .get(resourceType, workspaceId, resourceId) as DbRow | undefined; +} + +/** + * The workspace a resource belongs to, looked up by resource alone (mirrors + * {@link getWorkspaceProjectByProjectId}). Because `(resource_type, + * resource_id)` is the table's primary key, a resource can only ever have + * ONE binding row — this is the question to ask before binding a resource + * anywhere, not the two-key form above. + */ +export function getWorkspaceResourceByResourceId( + db: SqliteDb, + resourceType: string, + resourceId: string, +) { + return db + .prepare( + `SELECT ${WORKSPACE_RESOURCE_SELECT_COLUMNS} + FROM workspace_resources + WHERE resource_type = ? AND resource_id = ?`, + ) + .get(resourceType, resourceId) as DbRow | undefined; +} + +export function listWorkspaceResources(db: SqliteDb, resourceType: string, workspaceId: string) { + return db + .prepare( + `SELECT ${WORKSPACE_RESOURCE_SELECT_COLUMNS} + FROM workspace_resources + WHERE resource_type = ? AND workspace_id = ? + ORDER BY updated_at DESC`, + ) + .all(resourceType, workspaceId) as DbRow[]; +} + +/** Workspace ids that still own a live Team resource binding. + * + * Background reconciliation uses this persisted witness after restarts. It + * deliberately returns ids only; callers must resolve each id against the + * current authoritative Workspace directory before touching the resource hub. + */ +export function listTeamWorkspaceResourceWorkspaceIds(db: SqliteDb): string[] { + const rows = db + .prepare( + `SELECT DISTINCT workspace_id AS workspaceId + FROM workspace_resources + WHERE visibility = 'team' + AND resource_state != 'deleted' + ORDER BY workspace_id`, + ) + .all() as Array<{ workspaceId: string }>; + return rows.map((row) => row.workspaceId); +} + +/** + * Bind a resource to a workspace, or return the binding it already has. + * + * Deliberately keyed on `(resourceType, resourceId)`, not on `(workspace, + * resource)` — see {@link ensureWorkspaceProject}'s doc comment for why: a + * resource already bound elsewhere is returned as-is rather than bound a + * second time, which is what makes this idempotent across workspaces and + * what the `(resource_type, resource_id)` primary key enforces physically. + */ +export function ensureWorkspaceResource( + db: SqliteDb, + resourceType: string, + workspaceId: string, + resourceId: string, + input: DbRow = {}, +) { + const now = Date.now(); + const existing = getWorkspaceResourceByResourceId(db, resourceType, resourceId); + if (existing) return existing; + db.prepare( + `INSERT INTO workspace_resources + (resource_type, resource_id, workspace_id, visibility, resource_state, + created_by_workspace_member_id, updated_by_workspace_member_id, + resource_hub_resource_id, cloud_tombstoned_at, + sync_state, version, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + resourceType, + resourceId, + workspaceId, + input.visibility ?? 'personal', + input.resourceState ?? null, + input.createdByWorkspaceMemberId ?? null, + input.updatedByWorkspaceMemberId ?? input.createdByWorkspaceMemberId ?? null, + input.resourceHubResourceId ?? null, + input.cloudTombstonedAt ?? null, + input.syncState ?? null, + input.version ?? 1, + input.createdAt ?? now, + input.updatedAt ?? now, + ); + return getWorkspaceResource(db, resourceType, workspaceId, resourceId); +} + +export function updateWorkspaceResource( + db: SqliteDb, + resourceType: string, + workspaceId: string, + resourceId: string, + patch: DbRow, +) { + const existing = getWorkspaceResource(db, resourceType, workspaceId, resourceId); + if (!existing) return null; + const next: DbRow = { + ...existing, + ...patch, + resourceHubResourceId: patch.resourceHubResourceId === undefined + ? existing.resourceHubResourceId + : patch.resourceHubResourceId, + cloudTombstonedAt: patch.cloudTombstonedAt === undefined + ? existing.cloudTombstonedAt + : patch.cloudTombstonedAt, + updatedAt: typeof patch.updatedAt === 'number' ? patch.updatedAt : Date.now(), + }; + db.prepare( + `UPDATE workspace_resources + SET workspace_id = ?, + visibility = ?, + resource_state = ?, + created_by_workspace_member_id = ?, + updated_by_workspace_member_id = ?, + resource_hub_resource_id = ?, + cloud_tombstoned_at = ?, + sync_state = ?, + version = ?, + updated_at = ? + WHERE resource_type = ? AND workspace_id = ? AND resource_id = ?`, + ).run( + workspaceId, + next.visibility, + next.resourceState ?? null, + next.createdByWorkspaceMemberId ?? null, + next.updatedByWorkspaceMemberId ?? null, + next.resourceHubResourceId ?? null, + next.cloudTombstonedAt ?? null, + next.syncState ?? null, + next.version ?? 1, + next.updatedAt, + resourceType, + workspaceId, + resourceId, + ); + return getWorkspaceResource(db, resourceType, workspaceId, resourceId); +} + +export function deleteWorkspaceResource( + db: SqliteDb, + resourceType: string, + workspaceId: string, + resourceId: string, +): void { + db.prepare( + `DELETE FROM workspace_resources + WHERE resource_type = ? AND workspace_id = ? AND resource_id = ?`, + ).run(resourceType, workspaceId, resourceId); +} + +/** + * Delete a resource's binding row regardless of which workspace it is + * currently bound to. Callers that delete the resource's underlying record + * (e.g. plugin uninstall) MUST call this — there is no ON DELETE CASCADE for + * this table (see the table's doc comment in `migrate()`), so skipping this + * leaves an orphan `workspace_resources` row pointing at nothing. + */ +export function deleteWorkspaceResourceByResourceId( + db: SqliteDb, + resourceType: string, + resourceId: string, +): void { + db.prepare( + `DELETE FROM workspace_resources + WHERE resource_type = ? AND resource_id = ?`, + ).run(resourceType, resourceId); +} + export function listLatestProjectRunStatuses(db: SqliteDb) { const rows = db .prepare( @@ -856,7 +1793,7 @@ export function updateProject(db: SqliteDb, id: string, patch: DbRow) { const merged = { ...existing, ...patch, - updatedAt: typeof patch.updatedAt === 'number' ? patch.updatedAt : Date.now(), + updatedAt: nextUpdatedAt(patch.updatedAt, existing.updatedAt), }; db.prepare( `UPDATE projects @@ -1075,6 +2012,30 @@ export function listConversations(db: SqliteDb, projectId: string) { .all(projectId)).map(normalizeConversation); } +/** + * Return the conversation that was inserted first for a project. + * + * Project creation seeds this row before any side conversations exist. + * `created_at` normally identifies it, while `rowid` preserves insertion + * order when two conversations are created within the same millisecond. + * Keep this separate from `listConversations`, whose updated-at ordering is a + * user-facing recency contract. + */ +export function getFirstProjectConversation(db: SqliteDb, projectId: string) { + const result = db + .prepare( + `SELECT id + FROM conversations + WHERE project_id = ? + ORDER BY created_at ASC, rowid ASC + LIMIT 1`, + ) + .get(projectId) as { id?: unknown } | undefined; + return typeof result?.id === 'string' + ? getConversation(db, result.id) + : null; +} + export function getConversation(db: SqliteDb, id: string) { const r = db .prepare( @@ -1776,6 +2737,9 @@ export function listPreviewComments(db: SqliteDb, projectId: string, conversatio pod_members_json AS podMembersJson, style_json AS styleJson, attachments_json AS attachmentsJson, slide_index AS slideIndex, + anchor_state AS anchorState, anchored_version AS anchoredVersion, + author_member_id AS authorMemberId, last_good_position_json AS lastGoodPositionJson, + pin_seq AS pinSeq, sort_key AS sortKey, note, status, created_at AS createdAt, updated_at AS updatedAt FROM preview_comments WHERE project_id = ? AND conversation_id = ? @@ -1785,7 +2749,25 @@ export function listPreviewComments(db: SqliteDb, projectId: string, conversatio .map(normalizePreviewComment); } -export function upsertPreviewComment(db: SqliteDb, projectId: string, conversationId: string, input: DbRow) { +export interface UpsertPreviewCommentOptions { + /** + * True when this project currently syncs comments to the collab cloud (see + * `shouldSyncProjectComments`), so a genuinely NEW comment's `pin_seq` + * starts unconfirmed (0) instead of final (1). Ignored on the edit branch — + * `pin_seq`/`pin_seq_confirmed`/`sort_key` are assigned exactly once, at + * creation, and never revisited by an edit. Only meaningful together with a + * later `confirmPreviewCommentPinSeq` call once the cloud push resolves. + */ + pinPendingCloudConfirm?: boolean; +} + +export function upsertPreviewComment( + db: SqliteDb, + projectId: string, + conversationId: string, + input: DbRow, + options: UpsertPreviewCommentOptions = {}, +) { const target = input?.target ?? {}; const note = typeof input?.note === 'string' ? input.note.trim() : ''; const attachmentsProvided = Object.prototype.hasOwnProperty.call(input ?? {}, 'attachments'); @@ -1809,27 +2791,74 @@ export function upsertPreviewComment(db: SqliteDb, projectId: string, conversati : 0; const slideIndex = Number.isFinite(target.slideIndex) ? Math.max(0, Math.round(target.slideIndex)) : null; const slideKey = slideIndex ?? -1; + // Team collaboration creation metadata. anchor_state / last_good_position stay null at + // creation — the drift ladder resolves and writes them back (updatePreviewCommentAnchor). + const anchoredVersion = Number.isFinite(target.anchoredVersion) + ? Math.max(0, Math.round(target.anchoredVersion)) + : null; + const authorMemberId = + typeof input?.authorMemberId === 'string' && input.authorMemberId.trim() + ? input.authorMemberId.trim() + : null; + const requestedId = + typeof input?.id === 'string' && input.id.trim() + ? input.id.trim() + : null; const now = Date.now(); - const existing = db - .prepare( - `SELECT id, created_at AS createdAt, attachments_json AS attachmentsJson - FROM preview_comments - WHERE project_id = ? AND conversation_id = ? AND file_path = ? AND element_id = ? AND slide_key = ?`, - ) - .get(projectId, conversationId, filePath, elementId, slideKey) as DbRow | undefined; - const id = existing?.id ?? randomCommentId(); + const existing = requestedId + ? db + .prepare( + `SELECT id, created_at AS createdAt, attachments_json AS attachmentsJson + FROM preview_comments + WHERE id = ? AND project_id = ? AND conversation_id = ?`, + ) + .get(requestedId, projectId, conversationId) as DbRow | undefined + : undefined; + const id = existing?.id ?? requestedId ?? randomCommentId(); const createdAt = existing?.createdAt ?? now; const existingAttachments = normalizePreviewCommentAttachments(parseJsonOrUndef(existing?.attachmentsJson)); const attachments = attachmentsProvided ? incomingAttachments : existingAttachments; // A comment must carry either a note or at least one image attachment. if (!note && attachments.length === 0) throw new Error('comment note required'); + // pin_seq / pin_seq_confirmed / sort_key are assigned exactly once, on the + // INSERT branch, and are absent from the ON CONFLICT SET clause below so an + // edit (existing !== undefined) never rewrites them — see + // recvq5BVsolIxi / UpsertPreviewCommentOptions above. Computed against THIS + // db file only: safe as the initial guess even when a sibling device + // concurrently computes the same number for its own new comment, because a + // team-shared project's pin_seq_confirmed=0 row gets reconciled to the + // collab-cloud's globally-serialized seq by confirmPreviewCommentPinSeq + // once its push resolves (never by recomputing locally again). + let pinSeq: number | null = null; + let sortKey: number | null = null; + let pinSeqConfirmed = 1; + if (!existing) { + const pinScope = db + .prepare( + `SELECT COALESCE(MAX(pin_seq), 0) AS maxPinSeq + FROM preview_comments + WHERE project_id = ? AND file_path = ?`, + ) + .get(projectId, filePath) as DbRow; + pinSeq = Number(pinScope?.maxPinSeq ?? 0) + 1; + const sortScope = db + .prepare( + `SELECT COALESCE(MAX(sort_key), 0) AS maxSortKey + FROM preview_comments + WHERE project_id = ? AND file_path = ?`, + ) + .get(projectId, filePath) as DbRow; + sortKey = Number(sortScope?.maxSortKey ?? 0) + 1; + pinSeqConfirmed = options.pinPendingCloudConfirm ? 0 : 1; + } db.prepare( `INSERT INTO preview_comments (id, project_id, conversation_id, file_path, element_id, selector, label, text, position_json, html_hint, selection_kind, member_count, pod_members_json, - style_json, attachments_json, slide_index, slide_key, note, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(project_id, conversation_id, file_path, element_id, slide_key) DO UPDATE SET + style_json, attachments_json, slide_index, slide_key, note, status, created_at, updated_at, + anchored_version, author_member_id, pin_seq, pin_seq_confirmed, sort_key) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET selector = excluded.selector, label = excluded.label, text = excluded.text, @@ -1843,7 +2872,11 @@ export function upsertPreviewComment(db: SqliteDb, projectId: string, conversati slide_index = excluded.slide_index, note = excluded.note, status = 'open', - updated_at = excluded.updated_at`, + anchored_version = excluded.anchored_version, + author_member_id = excluded.author_member_id, + updated_at = excluded.updated_at + WHERE preview_comments.project_id = excluded.project_id + AND preview_comments.conversation_id = excluded.conversation_id`, ).run( id, projectId, @@ -1866,10 +2899,63 @@ export function upsertPreviewComment(db: SqliteDb, projectId: string, conversati 'open', createdAt, now, + anchoredVersion, + authorMemberId, + pinSeq, + pinSeqConfirmed, + sortKey, ); return getPreviewComment(db, projectId, conversationId, id); } +/** + * Reconcile a comment's provisional `pin_seq` to the collab-cloud's + * confirmed, globally-serialized push `seq` — the step that closes the + * cross-device race: two daemons that each computed the same local + * `MAX(pin_seq)+1` for a comment created in the same ~5s poll window + * converge to distinct numbers once their own push resolves, because the + * guard below only ever applies ONCE per comment (idempotent — a later edit's + * push resolving after the create's is a harmless no-op here). Returns false + * when the row was already confirmed (nothing to do) or does not exist. + */ +export function confirmPreviewCommentPinSeq( + db: SqliteDb, + projectId: string, + id: string, + seq: number, +): boolean { + if (!Number.isFinite(seq)) return false; + const result = db + .prepare( + `UPDATE preview_comments + SET pin_seq = ?, pin_seq_confirmed = 1 + WHERE id = ? AND project_id = ? AND pin_seq_confirmed = 0`, + ) + .run(Math.round(seq), id, projectId); + return result.changes > 0; +} + +/** + * Persist the dragged comment's new sidebar position (Phase 2 of + * recvq5BVsolIxi). The client computes `sortKey` itself (a midpoint between + * the dragged item's new neighbors) — this is a single-row write, never a + * table-wide renumber, and never touches `pin_seq`. + */ +export function reorderPreviewComment( + db: SqliteDb, + projectId: string, + conversationId: string, + id: string, + sortKey: number, +) { + db.prepare( + `UPDATE preview_comments + SET sort_key = ? + WHERE id = ? AND project_id = ? AND conversation_id = ?`, + ).run(sortKey, id, projectId, conversationId); + return getPreviewComment(db, projectId, conversationId, id); +} + export function updatePreviewCommentStatus(db: SqliteDb, projectId: string, conversationId: string, id: string, status: string) { if (!PREVIEW_COMMENT_STATUSES.has(status)) throw new Error('invalid comment status'); const now = Date.now(); @@ -1881,6 +2967,41 @@ export function updatePreviewCommentStatus(db: SqliteDb, projectId: string, conv return getPreviewComment(db, projectId, conversationId, id); } +/** + * Team collaboration drift-ladder write-back: persist how a comment resolved this render. + * `lastGoodPosition`/`anchoredVersion` are COALESCEd so a `lost` resolve (which omits + * them) keeps the last known-good values instead of wiping them. Does not bump + * `updated_at` — anchor resolution is a derived read, not a content edit. + */ +export function updatePreviewCommentAnchor( + db: SqliteDb, + projectId: string, + conversationId: string, + id: string, + input: DbRow, +) { + const anchorState = typeof input?.anchorState === 'string' ? input.anchorState : null; + const lastGoodPosition = input?.lastGoodPosition ? normalizePosition(input.lastGoodPosition) : null; + const anchoredVersion = Number.isFinite(input?.anchoredVersion) + ? Math.max(0, Math.round(input.anchoredVersion)) + : null; + db.prepare( + `UPDATE preview_comments + SET anchor_state = ?, + last_good_position_json = COALESCE(?, last_good_position_json), + anchored_version = COALESCE(?, anchored_version) + WHERE id = ? AND project_id = ? AND conversation_id = ?`, + ).run( + anchorState, + lastGoodPosition ? JSON.stringify(lastGoodPosition) : null, + anchoredVersion, + id, + projectId, + conversationId, + ); + return getPreviewComment(db, projectId, conversationId, id); +} + export function deletePreviewComment(db: SqliteDb, projectId: string, conversationId: string, id: string) { const result = db .prepare( @@ -1891,7 +3012,236 @@ export function deletePreviewComment(db: SqliteDb, projectId: string, conversati return result.changes > 0; } -function getPreviewComment(db: SqliteDb, projectId: string, conversationId: string, id: string) { +/** + * Pick a local conversation to re-home cross-daemon synced comments onto. + * Conversation ids do not cross daemons and preview_comments carries a + * conversation FK, so a comment pulled from the collab cloud must land under one + * of THIS daemon's conversations for the project. Returns the most-recently + * updated conversation id, or null when the project has none yet. + */ +export function getLatestConversationIdForProject( + db: SqliteDb, + projectId: string, +): string | null { + const row = db + .prepare( + `SELECT id FROM conversations + WHERE project_id = ? + ORDER BY updated_at DESC, rowid DESC + LIMIT 1`, + ) + .get(projectId) as DbRow | undefined; + return row && typeof row.id === 'string' ? row.id : null; +} + +/** + * Ensure a pulled Team mirror has one LOCAL conversation row that preview + * comments can use as their foreign-key anchor. + * + * Conversation ids and chat transcripts are daemon-local; Team project + * materialization deliberately does not copy the owner's private conversations + * or messages. A member mirror can therefore have zero conversations even + * after every shared file is present. Preview comments still need a local + * conversation FK, so the materializer creates one empty thread exactly once. + * If this daemon already has any conversation for the project, that existing + * local thread remains the anchor. + */ +export function ensureProjectCommentAnchorConversation( + db: SqliteDb, + projectId: string, + now = Date.now(), +): { conversationId: string; created: boolean } | null { + const existing = getLatestConversationIdForProject(db, projectId); + if (existing) return { conversationId: existing, created: false }; + if (!getProject(db, projectId)) return null; + + const conversationId = `comment-anchor-${randomUUID()}`; + insertConversation(db, { + id: conversationId, + projectId, + title: null, + sessionMode: 'design', + createdAt: now, + updatedAt: now, + }); + return { conversationId, created: true }; +} + +/** + * Delete a synced comment by its global id (the author daemon's own id). Used to + * apply an inbound tombstone. Scoped by project so a stray id can't reach across + * projects. Returns true when a row was removed. + */ +export function deleteSyncedPreviewComment( + db: SqliteDb, + projectId: string, + id: string, +): boolean { + const result = db + .prepare(`DELETE FROM preview_comments WHERE id = ? AND project_id = ?`) + .run(id, projectId); + return result.changes > 0; +} + +/** + * Merge one collab-cloud comment into local `preview_comments`. The cloud + * comment's id is used verbatim as the local id (it is the author daemon's own + * id — a global dedup key), so a comment's whole lifecycle keys off that id: + * + * - Tombstone (`deleted: true`): delete the local row by id. Delete wins + * unconditionally (it does not compare `updatedAt`). + * - Create/edit: UPSERT by id. A brand-new id inserts; an existing id updates + * IN PLACE only when the incoming `updatedAt` is strictly newer + * (last-writer-wins), so a re-pull of an unchanged comment is a no-op and a + * stale edit never overwrites a fresher local one. Comments are keyed by id, + * so multiple notes on the same element coexist, including from the same + * member. + * + * `conversationId` is a LOCAL conversation (see getLatestConversationIdForProject); + * the cloud comment's own conversationId is not a valid FK here. It is only used + * when inserting a new row — an in-place update keeps the row's existing + * conversation. Returns true when local state changed (insert, update, or delete). + */ +export function mergeSyncedPreviewComment( + db: SqliteDb, + projectId: string, + conversationId: string, + comment: CollabCloudComment, +): boolean { + if (comment.deleted) { + return deleteSyncedPreviewComment(db, projectId, comment.id); + } + const now = Date.now(); + const slideIndex = Number.isFinite(comment.slideIndex) + ? Math.max(0, Math.round(comment.slideIndex as number)) + : null; + const slideKey = slideIndex ?? -1; + const selectionKind = comment.selectionKind === 'pod' ? 'pod' : 'element'; + const podMembers = selectionKind === 'pod' && Array.isArray(comment.podMembers) + ? comment.podMembers + : null; + const memberCount = selectionKind === 'pod' + ? (podMembers?.length ?? (Number.isFinite(comment.memberCount) ? comment.memberCount : 0)) + : null; + const status = PREVIEW_COMMENT_STATUSES.has(comment.status) ? comment.status : 'open'; + const attachments = Array.isArray(comment.attachments) && comment.attachments.length > 0 + ? comment.attachments + : null; + const anchorState = typeof comment.anchorState === 'string' ? comment.anchorState : null; + const anchoredVersion = Number.isFinite(comment.anchoredVersion) + ? Math.max(0, Math.round(comment.anchoredVersion as number)) + : null; + const updatedAt = Number.isFinite(comment.updatedAt) ? (comment.updatedAt as number) : now; + const existing = db + .prepare(`SELECT updated_at AS updatedAt FROM preview_comments WHERE id = ? AND project_id = ?`) + .get(comment.id, projectId) as DbRow | undefined; + if (existing) { + // Last-writer-wins: only apply a strictly-newer edit. Keeps the existing + // row's conversation/created_at/author identity; refreshes mutable content, + // status, and drift-ladder anchor state. + if (updatedAt <= Number(existing.updatedAt ?? 0)) return false; + db.prepare( + `UPDATE preview_comments SET + selector = ?, label = ?, text = ?, position_json = ?, html_hint = ?, + selection_kind = ?, member_count = ?, pod_members_json = ?, style_json = ?, + attachments_json = ?, slide_index = ?, slide_key = ?, note = ?, status = ?, + anchor_state = ?, anchored_version = ?, last_good_position_json = ?, updated_at = ? + WHERE id = ? AND project_id = ?`, + ).run( + comment.selector, + comment.label, + typeof comment.text === 'string' ? comment.text : '', + JSON.stringify(comment.position ?? { x: 0, y: 0, width: 0, height: 0 }), + typeof comment.htmlHint === 'string' ? comment.htmlHint : '', + selectionKind, + memberCount, + podMembers ? JSON.stringify(podMembers) : null, + comment.style ? JSON.stringify(comment.style) : null, + attachments ? JSON.stringify(attachments) : null, + slideIndex, + slideKey, + typeof comment.note === 'string' ? comment.note : '', + status, + anchorState, + anchoredVersion, + comment.lastGoodPosition ? JSON.stringify(comment.lastGoodPosition) : null, + updatedAt, + comment.id, + projectId, + ); + return true; + } + // New comment. INSERT OR IGNORE guards against a rare id collision without + // throwing. + // + // pin_seq is taken straight from the wire's `seq` — the collab-cloud's own + // globally-serialized push sequence for this project (see + // CollabCloudComment.seq) — rather than recomputed as a local MAX+1. That + // is what makes a comment PULLED from a peer land on the exact same number + // the peer's own device converged to via confirmPreviewCommentPinSeq: both + // sides end up keyed off the one authoritative cloud value, never off a + // second independent local count. Already confirmed (pin_seq_confirmed=1) + // since the cloud is the source of truth here, not a local guess awaiting + // reconciliation. Falls back to a local MAX+1 only for a comment that + // somehow carries no real seq (e.g. an older relay build) so the row still + // gets a usable number instead of a permanent NULL. + const createdAt = Number.isFinite(comment.createdAt) ? comment.createdAt : now; + const hasWireSeq = Number.isFinite(comment.seq) && comment.seq > 0; + let pinSeq = hasWireSeq ? Math.round(comment.seq) : null; + if (!hasWireSeq) { + const pinScope = db + .prepare( + `SELECT COALESCE(MAX(pin_seq), 0) AS maxPinSeq + FROM preview_comments + WHERE project_id = ? AND file_path = ?`, + ) + .get(projectId, comment.filePath) as DbRow; + pinSeq = Number(pinScope?.maxPinSeq ?? 0) + 1; + } + const result = db + .prepare( + `INSERT OR IGNORE INTO preview_comments + (id, project_id, conversation_id, file_path, element_id, selector, label, + text, position_json, html_hint, selection_kind, member_count, pod_members_json, + style_json, attachments_json, slide_index, slide_key, note, status, created_at, updated_at, + anchor_state, anchored_version, author_member_id, last_good_position_json, + pin_seq, pin_seq_confirmed, sort_key) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + comment.id, + projectId, + conversationId, + comment.filePath, + comment.elementId, + comment.selector, + comment.label, + typeof comment.text === 'string' ? comment.text : '', + JSON.stringify(comment.position ?? { x: 0, y: 0, width: 0, height: 0 }), + typeof comment.htmlHint === 'string' ? comment.htmlHint : '', + selectionKind, + memberCount, + podMembers ? JSON.stringify(podMembers) : null, + comment.style ? JSON.stringify(comment.style) : null, + attachments ? JSON.stringify(attachments) : null, + slideIndex, + slideKey, + typeof comment.note === 'string' ? comment.note : '', + status, + createdAt, + updatedAt, + anchorState, + anchoredVersion, + typeof comment.memberId === 'string' ? comment.memberId : null, + comment.lastGoodPosition ? JSON.stringify(comment.lastGoodPosition) : null, + pinSeq, + 1, + createdAt, + ); + return result.changes > 0; +} + +export function getPreviewComment(db: SqliteDb, projectId: string, conversationId: string, id: string) { const row = db .prepare( `SELECT id, project_id AS projectId, conversation_id AS conversationId, @@ -1901,6 +3251,9 @@ function getPreviewComment(db: SqliteDb, projectId: string, conversationId: stri pod_members_json AS podMembersJson, style_json AS styleJson, attachments_json AS attachmentsJson, slide_index AS slideIndex, + anchor_state AS anchorState, anchored_version AS anchoredVersion, + author_member_id AS authorMemberId, last_good_position_json AS lastGoodPositionJson, + pin_seq AS pinSeq, sort_key AS sortKey, note, status, created_at AS createdAt, updated_at AS updatedAt FROM preview_comments WHERE id = ? AND project_id = ? AND conversation_id = ?`, @@ -1938,6 +3291,12 @@ function normalizePreviewComment(row: DbRow) { status: row.status, createdAt: row.createdAt, updatedAt: row.updatedAt, + anchorState: typeof row.anchorState === 'string' ? row.anchorState : undefined, + anchoredVersion: Number.isFinite(row.anchoredVersion) ? row.anchoredVersion : undefined, + authorMemberId: typeof row.authorMemberId === 'string' ? row.authorMemberId : undefined, + lastGoodPosition: parseJsonOrUndef(row.lastGoodPositionJson), + pinSeq: Number.isFinite(row.pinSeq) ? row.pinSeq : undefined, + sortKey: Number.isFinite(row.sortKey) ? row.sortKey : undefined, }; } diff --git a/apps/daemon/src/design-systems/generation-jobs.ts b/apps/daemon/src/design-systems/generation-jobs.ts index 0d130daa5fb..b2aed06fb12 100644 --- a/apps/daemon/src/design-systems/generation-jobs.ts +++ b/apps/daemon/src/design-systems/generation-jobs.ts @@ -124,7 +124,10 @@ export function createDesignSystemGenerationJobStore(options: StoreOptions) { const delayMs = options.delayMs ?? 280; const idFactory = options.idFactory ?? randomUUID; - function start(input: UserDesignSystemInput): DesignSystemGenerationJob { + function start( + input: UserDesignSystemInput, + createDesignSystemForJob: StoreOptions['createDesignSystem'] = createDesignSystem, + ): DesignSystemGenerationJob { const now = new Date().toISOString(); const job: MutableJob = { id: idFactory(), @@ -137,7 +140,7 @@ export function createDesignSystemGenerationJobStore(options: StoreOptions) { message: 'Queued', }; jobs.set(job.id, job); - void run(job, input); + void run(job, input, createDesignSystemForJob); return snapshot(job); } @@ -182,7 +185,11 @@ export function createDesignSystemGenerationJobStore(options: StoreOptions) { return job ? snapshot(job) : null; } - async function run(job: MutableJob, input: UserDesignSystemInput): Promise<void> { + async function run( + job: MutableJob, + input: UserDesignSystemInput, + createDesignSystemForJob: NonNullable<StoreOptions['createDesignSystem']>, + ): Promise<void> { try { markJob(job, 'running', 'Starting generation'); let created: DesignSystemSummary | null = null; @@ -194,7 +201,7 @@ export function createDesignSystemGenerationJobStore(options: StoreOptions) { setStepMessage(job, 'explore-resources', sourceSummary(input, sourceContext)); }); await runStep(job, 'create-draft', async () => { - created = await createDesignSystem(options.root, enrichedInput); + created = await createDesignSystemForJob(options.root, enrichedInput); job.designSystemId = created.id; setStepMessage(job, 'create-draft', `Created ${created.title}`); }); diff --git a/apps/daemon/src/design-systems/index.ts b/apps/daemon/src/design-systems/index.ts index 0e21498b55e..fef99b25c15 100644 --- a/apps/daemon/src/design-systems/index.ts +++ b/apps/daemon/src/design-systems/index.ts @@ -13,6 +13,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import JSZip from 'jszip'; +import type Database from 'better-sqlite3'; import { type ComponentsManifest, @@ -23,6 +24,9 @@ import { import { parseFrontmatter } from './frontmatter.js'; import type { FrontmatterObject, FrontmatterValue } from './frontmatter.js'; import { extractSwiftColors } from './swift-colors.js'; +import { ensureWorkspaceResource, getWorkspaceResourceByResourceId } from '../db.js'; + +type SqliteDb = Database.Database; export type DesignSystemSurface = 'web' | 'image' | 'video' | 'audio'; export type DesignSystemSource = 'built-in' | 'installed' | 'user'; @@ -45,6 +49,14 @@ export type DesignSystemSummary = { updatedAt?: string; provenance?: DesignSystemProvenance; projectId?: string; + teamSynced?: boolean; + /** + * The workspace this user design system belongs to, when one claimed it. + * + * Absent means UNCLAIMED, not "belongs to no workspace" — see + * `DesignSystemListOptions.workspaceId`. + */ + workspaceId?: string; }; export type DesignSystemFileKind = @@ -199,6 +211,9 @@ type UserDesignSystemMetadata = { updatedAt?: string; provenance?: DesignSystemProvenance; projectId?: string; + teamSynced?: boolean; + /** Workspace that claimed this system; absent on anything written before #145. */ + workspaceId?: string; }; type AtomicTextFileWrite = { @@ -248,6 +263,15 @@ export type UserDesignSystemInput = { body?: string; sourceNotes?: string; provenance?: DesignSystemProvenance; + /** + * Workspace to claim the new system for (#145). Set by the daemon from the + * active workspace selection at creation time; omitted leaves the system + * unclaimed and therefore visible from every workspace. + * + * Only `createUserDesignSystem` reads it — an update must never re-home an + * existing system just because the caller happened to be elsewhere. + */ + workspaceId?: string; }; export type UserDesignSystemRevisionInput = { @@ -264,6 +288,27 @@ export type DesignSystemListOptions = { source?: DesignSystemSource; isEditable?: boolean; defaultStatus?: DesignSystemStatus; + /** + * Restrict the listing to design systems visible from this workspace (#145). + * + * User design systems all live in ONE flat directory under the daemon data + * root — there is no per-workspace store — so without this filter a system + * authored in workspace A also showed up in a brand-new workspace B. + * + * The rule is deliberately one-way: a system CLAIMED by another workspace is + * hidden, and an UNCLAIMED one (no `workspaceId` in its metadata) stays + * visible everywhere. Unclaimed is what every system written before this + * filter existed looks like, and hiding those would make design systems + * vanish from an upgrading user's library — a worse bug than the leak. New + * systems are stamped on write, so the reported flow (author in A, create a + * fresh B) is isolated from here on. + * + * Omitted / empty means "no workspace scope" and lists everything, which is + * what every non-catalog caller wants: resolving a design system BY ID (a + * project's `design_system_id`, validation, install/import lookups) must keep + * working regardless of which workspace happens to be active. + */ + workspaceId?: string | null; }; export async function listDesignSystems( @@ -287,6 +332,7 @@ export async function listDesignSystems( if (!stats.isFile()) continue; const raw = await readFile(designPath, 'utf8'); const metadata = await readUserMetadata(root, entry.name); + if (!designSystemVisibleFromWorkspace(metadata.workspaceId, options.workspaceId)) continue; const { data: frontmatter, body } = parseFrontmatter(raw); const titleMatch = /^#\s+(.+?)\s*$/m.exec(body); const markdownTitle = @@ -330,6 +376,8 @@ export async function listDesignSystems( ...(metadata.updatedAt ? { updatedAt: metadata.updatedAt } : {}), ...(metadata.provenance ? { provenance: metadata.provenance } : {}), ...(metadata.projectId ? { projectId: metadata.projectId } : {}), + ...(metadata.teamSynced ? { teamSynced: true } : {}), + ...(metadata.workspaceId ? { workspaceId: metadata.workspaceId } : {}), }); } catch { // Skip. @@ -338,6 +386,39 @@ export async function listDesignSystems( return out; } +/** + * Whether a design system claimed by `owner` should be listed while `scope` is + * the active workspace. + * + * `scope === undefined` (the `workspaceId` option key OMITTED, not merely + * empty) means the caller asked for the truly unscoped catalog — id + * resolution, install/import lookups, and (critically) `createUserDesignSystem`/ + * `updateUserDesignSystem`/`linkUserDesignSystemProject` re-reading the system + * they just wrote by id — which must never hide anything, or writing a system + * claimed by a workspace would make `listDesignSystems(...).find(...)` fail to + * find what was just written (a real regression this fix must not introduce). + * + * `scope` present but empty (`null`/`''`) is a DIFFERENT case: a caller that + * DID ask to be scoped — `GET /api/design-systems` with no verified vela + * session — but has no workspace identity to offer. Spec 04 §10: that must + * hide a CLAIMED system, not show it, or "no scope" quietly becomes "trust + * everything". No `owner` still means VISIBLE either way — the system + * predates workspace stamping, and an upgrading user must not watch their + * library empty out. Only a positive disagreement (claimed elsewhere, scoped + * caller with a different or no identity) hides it. + */ +function designSystemVisibleFromWorkspace( + owner: string | undefined, + scope: string | null | undefined, +): boolean { + if (scope === undefined) return true; + const scopeId = scope?.trim(); + const ownerId = owner?.trim(); + if (!scopeId) return !ownerId; + if (!ownerId) return true; + return ownerId === scopeId; +} + function stringField(data: FrontmatterObject, key: string): string { const v: FrontmatterValue | undefined = data[key]; return typeof v === 'string' ? v.trim() : ''; @@ -1183,6 +1264,9 @@ export async function createUserDesignSystem( createdAt: now, updatedAt: now, ...(provenance ? { provenance } : {}), + // Claim the system for the workspace it was authored in, so switching to + // another workspace no longer shows it (#145). + ...(input.workspaceId?.trim() ? { workspaceId: input.workspaceId.trim() } : {}), }); if (artifactMode !== 'agent-managed') { await writeGeneratedDesignSystemFiles(root, dirId, { @@ -1459,6 +1543,72 @@ export async function deleteUserDesignSystem(root: string, id: string): Promise< } } +/** + * Whether `id` was materialized locally from a teammate's team share, rather + * than authored by the current caller. Mirrors the `teamSynced` flag + * `markTeamSynced` (server.ts `syncSharedTeamDesignSystem`) writes once a + * shared design system is pulled onto disk — false/absent for anything the + * caller authored themselves, including a system the caller has *shared* to + * the team (the sharer's own copy never gets this flag). Routes that mutate + * a `user:` design system (edit / publish toggle / delete) must treat a + * `true` result as "not necessarily mine" and check the caller's team-share + * management permission before proceeding (see `canManageSharedResource` in + * `collab/team-resource-share.ts`) — recvqb6mfyqXLD. + */ +export async function isTeamSyncedUserDesignSystem(root: string, id: string): Promise<boolean> { + const dirId = stripPrefixAndValidateId(id, 'user:'); + if (!dirId) return false; + const meta = await readUserMetadata(root, dirId); + return meta.teamSynced === true; +} + +/** + * One-time startup backfill (spec 9.2): design systems predate the generic + * `workspace_resources` envelope table entirely — `createWorkspaceOwnedDesignSystem` + * and `markTeamSynced` (server.ts) only started double-writing into it today, + * so every system claimed BEFORE that shipped has a `workspaceId` in its + * `metadata.json` but no corresponding row in the table. Left alone, that + * system stays permanently invisible to anything that reads the generic table + * (mirrors what `collapseWorkspaceProjectHomes` heals for project, applied to + * a filesystem-backed resource instead of a DB-only one). + * + * Idempotent by construction: a directory whose id already has a binding row + * is skipped, so re-running this on every daemon start costs one readdir plus + * a lookup per system and never writes a duplicate. + * + * `visibility` mirrors the claim `markTeamSynced` writes going forward — + * `teamSynced: true` backfills as `'team'`, everything else as `'personal'`. + * metadata.json itself is never touched; this only adds the second envelope + * copy `workspace_resources` needs. + */ +export async function backfillDesignSystemWorkspaceResources( + db: SqliteDb, + root: string, +): Promise<number> { + let entries = []; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch { + return 0; + } + let backfilled = 0; + for (const entry of entries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + const dirId = entry.name; + const metadata = await readUserMetadata(root, dirId); + const workspaceId = metadata.workspaceId; + if (!workspaceId) continue; + const id = `user:${dirId}`; + if (getWorkspaceResourceByResourceId(db, 'design_system', id)) continue; + ensureWorkspaceResource(db, 'design_system', workspaceId, id, { + visibility: metadata.teamSynced === true ? 'team' : 'personal', + resourceState: 'active', + }); + backfilled += 1; + } + return backfilled; +} + export async function listUserDesignSystemFiles( root: string, id: string, @@ -2111,6 +2261,97 @@ async function writeGeneratedDesignSystemFiles( ); } +// A real asset file synced in from a workspace project's editing-time +// mirror — arbitrary bytes the agent already produced there (e.g. a +// regenerated logo.svg), not generator output. +export type DesignSystemAssetSourceFile = { + /** POSIX-relative path under the design-system root, e.g. "assets/logo.svg". */ + path: string; + content: Buffer; +}; + +export type DesignSystemAssetSyncResult = { + /** POSIX-relative paths that were actually written to the canonical dir. */ + synced: string[]; +}; + +/** + * Copies real asset bytes into a user design system's canonical `assets/` + * directory — the fix for the logo/asset desync (spec 04 §9.3, + * recvqb1t4FrckM): canonical is the only directory `team-resource-share` + * packages and downloads read from, but agent-produced assets only ever + * landed in the workspace-project editing mirror, so a regenerated logo + * never reached what got shared or downloaded. + * + * Every write here is caller-supplied bytes, never generator output, so it + * must survive the next `writeGeneratedDesignSystemFiles` call rather than + * being silently regenerated back to a placeholder. Two things make it + * stick, both applied here: + * 1. Any `.od-generated.json` fingerprint entry for an overwritten path is + * dropped. `filterGeneratedWritesPreservingUserEdits` treats a path with + * no recorded fingerprint exactly like a hand-edited file — preserved, + * never refreshed. + * 2. `artifactMode` flips to `'agent-managed'` the first time any file + * actually syncs, so `createUserDesignSystem`/`updateUserDesignSystem` + * skip `writeGeneratedDesignSystemFiles` entirely on every future write + * (the "fingerprint protection was spinning with nothing to protect" + * root cause the investigation identified). + * + * Only paths under `assets/` are accepted; anything else is silently + * skipped — this function syncs real assets, not arbitrary canonical files. + */ +export async function syncUserDesignSystemAssetsFromFiles( + root: string, + id: string, + files: DesignSystemAssetSourceFile[], +): Promise<DesignSystemAssetSyncResult> { + const dirId = stripPrefixAndValidateId(id, 'user:'); + if (!dirId) return { synced: [] }; + const dir = path.join(root, dirId); + try { + const stats = await stat(path.join(dir, 'DESIGN.md')); + if (!stats.isFile()) return { synced: [] }; + } catch { + return { synced: [] }; + } + + const manifest = await readGeneratedManifest(dir); + let manifestChanged = false; + const synced: string[] = []; + for (const file of files) { + const sanitized = sanitizeRelativeFilePath(file.path); + if (!sanitized || !(sanitized === 'assets' || sanitized.startsWith('assets/'))) continue; + const targetPath = path.join(dir, ...sanitized.split('/')); + await mkdir(path.dirname(targetPath), { recursive: true }); + await writeFile(targetPath, file.content); + const key = generatedManifestKey(dir, targetPath); + if (key in manifest) { + delete manifest[key]; + manifestChanged = true; + } + synced.push(sanitized); + } + if (synced.length === 0) return { synced }; + + if (manifestChanged) { + await writeFile( + path.join(dir, GENERATED_MANIFEST_FILENAME), + serializeGeneratedManifest(manifest), + 'utf8', + ); + } + + const existingMeta = await readUserMetadata(root, dirId); + if (existingMeta.artifactMode !== 'agent-managed') { + await writeUserMetadata(root, dirId, { + ...existingMeta, + artifactMode: 'agent-managed', + updatedAt: new Date().toISOString(), + }); + } + return { synced }; +} + function generatedDesignSystemFileWrites( dir: string, input: { @@ -2563,7 +2804,7 @@ window.Composer = Composer; `; } -function stripPrefixAndValidateId(id: string, prefix = ''): string | null { +export function stripPrefixAndValidateId(id: string, prefix = ''): string | null { if (typeof id !== 'string') return null; if (prefix && !id.startsWith(prefix)) return null; const dirId = prefix ? id.slice(prefix.length) : id; @@ -2589,12 +2830,28 @@ async function readUserMetadata(root: string, id: string): Promise<UserDesignSys ...(typeof parsed.updatedAt === 'string' ? { updatedAt: parsed.updatedAt } : {}), ...(provenance ? { provenance } : {}), ...(projectId ? { projectId } : {}), + ...(parsed.teamSynced === true ? { teamSynced: true } : {}), + ...(cleanWorkspaceIdForMetadata(parsed.workspaceId) + ? { workspaceId: cleanWorkspaceIdForMetadata(parsed.workspaceId)! } + : {}), }; } catch { return {}; } } +/** + * Accept a workspace id only in the opaque-token shape B issues. A malformed + * value is dropped rather than trusted, which lands the system in the UNCLAIMED + * bucket — visible everywhere — instead of silently claimed by garbage. + */ +function cleanWorkspaceIdForMetadata(raw: unknown): string | null { + if (typeof raw !== 'string') return null; + const value = raw.trim(); + if (!value) return null; + return /^[A-Za-z0-9._:-]{1,160}$/.test(value) ? value : null; +} + function cleanProjectIdForMetadata(raw: unknown): string | null { if (typeof raw !== 'string') return null; const value = raw.trim(); diff --git a/apps/daemon/src/design-systems/rename-args.ts b/apps/daemon/src/design-systems/rename-args.ts index 63525d64acd..7dd0290fb56 100644 --- a/apps/daemon/src/design-systems/rename-args.ts +++ b/apps/daemon/src/design-systems/rename-args.ts @@ -12,7 +12,14 @@ export interface DesignSystemRenameArgs { title: string; } -const STRING_FLAGS_WITH_VALUE = new Set(['daemon-url', 'query', 'tag', 'title']); +const STRING_FLAGS_WITH_VALUE = new Set([ + 'daemon-url', + 'query', + 'tag', + 'title', + 'workspace', + 'workspace-member', +]); // A separate flag value must be a real token, not the next flag. Without this // guard, `--title --json` would read "--json" as the title and rename the diff --git a/apps/daemon/src/design-systems/server-services.ts b/apps/daemon/src/design-systems/server-services.ts index 4ff12ac8d63..f810e36511d 100644 --- a/apps/daemon/src/design-systems/server-services.ts +++ b/apps/daemon/src/design-systems/server-services.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import type Database from 'better-sqlite3'; +import { teamResourceWorkspaceRoot } from '../collab/team-resource-materialization.js'; type JsonRecord = Record<string, unknown>; type SkillEntry = { id: string } & JsonRecord; @@ -50,15 +51,28 @@ type DesignSystemListOptions = { source?: string; isEditable?: boolean; defaultStatus?: string; + workspaceId?: string | null; }; +export type DesignSystemAssetSyncOutcome = + | { ok: true; synced: string[] } + | { ok: false; reason: 'not-found' | 'no-workspace-project' }; + export function createDesignSystemServerServices({ + getDb, roots, paths, skills, designSystems, projects, + bindProjectToWorkspace, }: { + // Only consulted by `listAllSkills` below for its optional workspace scope + // filter — every other service in this factory stays filesystem-only. A + // getter (not the db value itself) because this factory runs BEFORE + // server.ts opens its database connection; the closure defers the read + // until a request actually needs it, long after `openDatabase()` has run. + getDb?: () => Database.Database; roots: { SKILL_ROOTS: string[]; DESIGN_TEMPLATE_ROOTS: string[]; @@ -70,7 +84,10 @@ export function createDesignSystemServerServices({ USER_DESIGN_SYSTEMS_DIR: string; }; skills: { - listSkills: (roots: string[]) => Promise<SkillEntry[]>; + listSkills: ( + roots: string[], + options?: { db?: Database.Database; workspaceId?: string | null }, + ) => Promise<SkillEntry[]>; findSkillById: (skills: SkillEntry[], id: string) => SkillEntry | undefined; }; designSystems: { @@ -81,6 +98,14 @@ export function createDesignSystemServerServices({ listUserDesignSystemFiles: (root: string, id: string) => Promise<Array<{ kind?: string; path: string }> | null | undefined>; readUserDesignSystemFile: (root: string, id: string, filePath: string) => Promise<{ path: string; content: string } | null | undefined>; linkUserDesignSystemProject: (root: string, id: string, projectId: string) => Promise<unknown>; + // Physically copies real asset bytes (sourced from a workspace project's + // editing mirror) into the canonical assets/ dir and un-fingerprints them + // so the generator never overwrites them again (spec 04 §9.3). + syncUserDesignSystemAssetsFromFiles: ( + root: string, + id: string, + files: Array<{ path: string; content: Buffer }>, + ) => Promise<{ synced: string[] }>; LEGACY_DESIGN_SYSTEM_ARTIFACTS: Array<{ replacementPaths: string[]; legacyPath: string; @@ -97,20 +122,99 @@ export function createDesignSystemServerServices({ resolveProjectDir: (projectsDir: string, projectId: string, metadata?: JsonRecord) => string; isSafeId: (id: string) => boolean; }; + /** + * Give the `ds-*` project that backs a design system's editing workspace a + * `workspace_projects` home, the same as any other created project. + * + * It is a real, run-hosting project — the chat/run prompt-composition path + * stands it up on demand (`server.ts`) and the system prompt has a dedicated + * `editingOwnDraftDesignSystem` branch for chatting inside it — so an unbound + * one is denied its first turn by `enforceWorkspaceResourceMutation` exactly + * like any other orphan. This factory has no Express request, so the daemon's + * ambient workspace is the only thing that can answer; the injected closure + * keeps that resolution in `server.ts` where the provider lives. Absent in + * tests that do not exercise the seam. + */ + bindProjectToWorkspace?: ( + projectId: string, + createdAt: number, + designSystem: DesignSystemSummary, + ) => void; }) { - async function listAllSkills() { - return skills.listSkills(roots.SKILL_ROOTS); + /** + * The functional-skills catalog. `workspaceId` narrows it to the + * user-imported skills that workspace may see (mirrors + * `listAllDesignSystems` below). Request and project-bound consumers pass + * their exact persisted scope; only true legacy/internal callers omit it. + * + * Checking `options.workspaceId === undefined` (not just falsy) matters: + * `GET /api/skills` always passes the key, with `null` whenever the request + * carries no `x-od-workspace-id` header (headerValue never returns + * `undefined`) — that request DID ask to be scoped, just with no identity, + * and must still reach `listSkills`'s workspace filter so a claimed skill is + * hidden from it (spec 04 §10), not silently fall through to the unscoped + * branch the way a plain `options.workspaceId ? … : …` truthiness check + * would. + */ + async function listAllSkills(options: { workspaceId?: string | null } = {}) { + const db = getDb?.(); + if (!db || options.workspaceId === undefined) { + return skills.listSkills(roots.SKILL_ROOTS); + } + const personalAndBuiltIn = await skills.listSkills(roots.SKILL_ROOTS, { + db, + workspaceId: options.workspaceId, + }); + const workspaceId = options.workspaceId?.trim(); + if (!workspaceId || !roots.SKILL_ROOTS[0]) return personalAndBuiltIn; + const team = await skills.listSkills([ + teamResourceWorkspaceRoot(roots.SKILL_ROOTS[0], workspaceId), + ]); + const teamIds = new Set(team.map((entry) => entry.id)); + return [ + ...team.map((entry) => ({ ...entry, teamSynced: true })), + ...personalAndBuiltIn.filter((entry) => !teamIds.has(entry.id)), + ]; } async function listAllDesignTemplates() { return skills.listSkills(roots.DESIGN_TEMPLATE_ROOTS); } - async function listAllSkillLikeEntries() { - return skills.listSkills(roots.ALL_SKILL_LIKE_ROOTS); + async function listAllSkillLikeEntries( + options: { workspaceId?: string | null } = {}, + ) { + if (options.workspaceId === undefined) { + return skills.listSkills(roots.ALL_SKILL_LIKE_ROOTS); + } + const [functional, templates] = await Promise.all([ + listAllSkills(options), + listAllDesignTemplates(), + ]); + const functionalIds = new Set(functional.map((entry) => entry.id)); + return [ + ...functional, + ...templates.filter((entry) => !functionalIds.has(entry.id)), + ]; } - async function listAllDesignSystems() { + /** + * The design-system catalog. + * + * `workspaceId` narrows the USER half to the systems that workspace may see + * (#145); the built-in half is shipped with the app and stays global. Project + * validation and request-bound lookups pass the project's exact persisted + * Workspace, so shell navigation cannot retarget same-id resolution. + * + * Forwarding the key whenever it is DEFINED (not just truthy) matters: + * `GET /api/design-systems` always passes `workspaceId`, with `null` + * whenever there is no verified vela session — that request DID ask to be + * scoped, just with no identity, and must still reach + * `designSystemVisibleFromWorkspace`'s filter so a claimed system is hidden + * from it (spec 04 §10) instead of silently landing in the unscoped branch a + * plain `options.workspaceId ? … : …` truthiness check would take. + */ + async function listAllDesignSystems(options: { workspaceId?: string | null } = {}) { const builtIn = (await designSystems.listDesignSystems(paths.DESIGN_SYSTEMS_DIR)).map((s) => ({ ...s, source: 'built-in', @@ -124,10 +228,33 @@ export function createDesignSystemServerServices({ source: 'user', isEditable: true, defaultStatus: 'draft', + ...(options.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}), }); } catch { // User directory may not exist yet or be unreadable. } + const workspaceId = options.workspaceId?.trim(); + if (workspaceId) { + try { + const team = await designSystems.listDesignSystems( + teamResourceWorkspaceRoot(paths.USER_DESIGN_SYSTEMS_DIR, workspaceId), + { + idPrefix: 'user:', + source: 'user', + isEditable: false, + defaultStatus: 'published', + workspaceId, + }, + ); + const teamIds = new Set(team.map((system) => system.id)); + installed = [ + ...team.map((system) => ({ ...system, teamSynced: true })), + ...installed.filter((system) => !teamIds.has(system.id)), + ]; + } catch { + // A workspace with no pulled Team systems has no scoped directory. + } + } const seen = new Set(builtIn.map((s) => s.id)); return [ ...installed @@ -138,7 +265,19 @@ export function createDesignSystemServerServices({ ]; } - async function readAvailableDesignSystem(id: string) { + async function readAvailableDesignSystem( + id: string, + options: { workspaceId?: string | null } = {}, + ) { + const workspaceId = options.workspaceId?.trim(); + if (workspaceId && typeof id === 'string' && id.startsWith('user:')) { + const scoped = await designSystems.readDesignSystem( + teamResourceWorkspaceRoot(paths.USER_DESIGN_SYSTEMS_DIR, workspaceId), + id, + { idPrefix: 'user:' }, + ); + if (scoped != null) return scoped; + } if (typeof id === 'string' && id.startsWith('user:')) { return designSystems.readDesignSystem(paths.USER_DESIGN_SYSTEMS_DIR, id, { idPrefix: 'user:' }); } @@ -148,7 +287,19 @@ export function createDesignSystemServerServices({ ); } - async function readAvailableDesignSystemPackageInfo(id: string) { + async function readAvailableDesignSystemPackageInfo( + id: string, + options: { workspaceId?: string | null } = {}, + ) { + const workspaceId = options.workspaceId?.trim(); + if (workspaceId && typeof id === 'string' && id.startsWith('user:')) { + const scoped = await designSystems.readDesignSystemPackageInfo( + teamResourceWorkspaceRoot(paths.USER_DESIGN_SYSTEMS_DIR, workspaceId), + id, + { idPrefix: 'user:' }, + ); + if (scoped != null) return scoped; + } if (typeof id === 'string' && id.startsWith('user:')) { return designSystems.readDesignSystemPackageInfo(paths.USER_DESIGN_SYSTEMS_DIR, id, { idPrefix: 'user:' }); } @@ -158,7 +309,21 @@ export function createDesignSystemServerServices({ ); } - async function readAvailableDesignSystemStaticFile(id: string, filePath: string) { + async function readAvailableDesignSystemStaticFile( + id: string, + filePath: string, + options: { workspaceId?: string | null } = {}, + ) { + const workspaceId = options.workspaceId?.trim(); + if (workspaceId && typeof id === 'string' && id.startsWith('user:')) { + const scoped = await designSystems.readDesignSystemStaticFile( + teamResourceWorkspaceRoot(paths.USER_DESIGN_SYSTEMS_DIR, workspaceId), + id, + filePath, + { idPrefix: 'user:' }, + ); + if (scoped != null) return scoped; + } if (typeof id === 'string' && id.startsWith('user:')) { return designSystems.readDesignSystemStaticFile(paths.USER_DESIGN_SYSTEMS_DIR, id, filePath, { idPrefix: 'user:' }); } @@ -172,7 +337,10 @@ export function createDesignSystemServerServices({ return summary?.status !== 'draft'; } - async function validateProjectDesignSystemId(id: unknown) { + async function validateProjectDesignSystemId( + id: unknown, + options: { workspaceId?: string | null } = {}, + ) { if (id === undefined || id === null || id === '') return { ok: true, id: null }; if (typeof id !== 'string') { return { @@ -181,7 +349,7 @@ export function createDesignSystemServerServices({ message: 'designSystemId must be a string or null', }; } - const systems = await listAllDesignSystems(); + const systems = await listAllDesignSystems(options); const summary = systems.find((system) => system.id === id); if (!summary) { return { @@ -200,7 +368,10 @@ export function createDesignSystemServerServices({ return { ok: true, id }; } - async function validateProjectSkillId(id: unknown) { + async function validateProjectSkillId( + id: unknown, + options: { workspaceId?: string | null } = {}, + ) { if (id === undefined || id === null || id === '') { return { ok: true, id: null }; } @@ -211,7 +382,7 @@ export function createDesignSystemServerServices({ message: 'skillId must be a string or null', }; } - const allSkills = await listAllSkillLikeEntries(); + const allSkills = await listAllSkillLikeEntries(options); const resolved = skills.findSkillById(allSkills, id); if (!resolved) { return { @@ -223,10 +394,16 @@ export function createDesignSystemServerServices({ return { ok: true, id: resolved.id }; } - function userDesignSystemWorkspaceProjectId(id: string) { + function userDesignSystemDirectoryId(id: string) { if (typeof id !== 'string' || !id.startsWith('user:')) return null; const dirId = id.slice('user:'.length); if (!/^[A-Za-z0-9._-]{1,120}$/.test(dirId)) return null; + return dirId; + } + + function userDesignSystemWorkspaceProjectId(id: string) { + const dirId = userDesignSystemDirectoryId(id); + if (!dirId) return null; return `ds-${dirId}`.slice(0, 128); } @@ -271,6 +448,7 @@ export function createDesignSystemServerServices({ updatedAt: now, }); if (!project) return null; + if (!existing) bindProjectToWorkspace?.(projectId, now, summary); const files = await designSystems.listUserDesignSystemFiles(paths.USER_DESIGN_SYSTEMS_DIR, id); if (!files) return null; @@ -368,6 +546,85 @@ export function createDesignSystemServerServices({ } } + /** + * Copies the real `assets/` files out of a user design system's workspace + * project (the editing-time mirror an agent actually writes to) into the + * canonical design-system directory, so `team-resource-share`'s zip and + * `/api/design-systems/:id/archive` stop shipping a stale/placeholder + * `assets/logo.svg` (spec 04 §9.3, recvqb1t4FrckM). Locates the source + * project the same way `ensureUserDesignSystemWorkspaceProject` does + * (`projectBackedDesignSystemProjectId`), just copying in the opposite + * direction — from the project mirror back to canonical. + */ + async function syncUserDesignSystemAssetsFromWorkspace( + dbHandle: Database.Database, + id: string, + ): Promise<DesignSystemAssetSyncOutcome> { + const systems = await listAllDesignSystems(); + const summary = systems.find((s) => s.id === id && s.source === 'user'); + if (!summary) return { ok: false, reason: 'not-found' }; + const projectId = projectBackedDesignSystemProjectId(id, summary); + if (!projectId) return { ok: false, reason: 'no-workspace-project' }; + const project = projects.getProject(dbHandle, projectId); + if (!project) return { ok: false, reason: 'no-workspace-project' }; + + const projectFiles = await projects.listFiles( + paths.PROJECTS_DIR, + project.id, + project.metadata ? { metadata: project.metadata } : {}, + ); + const assetPaths = projectFiles + .map((file) => (file && typeof file === 'object' ? (file as { path?: unknown }).path : undefined)) + .filter( + (candidate): candidate is string => + typeof candidate === 'string' && (candidate === 'assets' || candidate.startsWith('assets/')), + ); + + const files: Array<{ path: string; content: Buffer }> = []; + for (const assetPath of assetPaths) { + try { + const detail = await projects.readProjectFile( + paths.PROJECTS_DIR, + project.id, + assetPath, + project.metadata, + ); + files.push({ path: assetPath, content: detail.buffer }); + } catch { + // A file that vanished or was mid-rename during the scan shouldn't + // fail the whole sync — skip it and continue with the rest. + } + } + + const result = await designSystems.syncUserDesignSystemAssetsFromFiles( + paths.USER_DESIGN_SYSTEMS_DIR, + id, + files, + ); + return { ok: true, synced: result.synced }; + } + + /** + * Resolves the directory that a team-share publish may archive. Unlike the + * read-only canonical path resolver, this first snapshots the workspace + * project's latest assets back into canonical. A missing source fails + * closed so a repeat "Sync to team" can never publish stale bytes. + */ + async function resolveUserDesignSystemShareDirectory( + dbHandle: Database.Database, + id: string, + ): Promise<string> { + const outcome = await syncUserDesignSystemAssetsFromWorkspace(dbHandle, id); + if (!outcome.ok) { + throw new Error(`design_system_share_asset_sync_failed:${outcome.reason}`); + } + const dirId = userDesignSystemDirectoryId(id); + if (!dirId) { + throw new Error('design_system_share_asset_sync_failed:not-found'); + } + return path.join(paths.USER_DESIGN_SYSTEMS_DIR, dirId); + } + return { ensureUserDesignSystemWorkspaceProject, isProjectUsableDesignSystem, @@ -379,6 +636,8 @@ export function createDesignSystemServerServices({ readAvailableDesignSystemPackageInfo, readAvailableDesignSystemStaticFile, readDesignSystemWorkspaceTextFile, + resolveUserDesignSystemShareDirectory, + syncUserDesignSystemAssetsFromWorkspace, validateProjectDesignSystemId, validateProjectSkillId, }; diff --git a/apps/daemon/src/design-systems/workspace-owned-create.ts b/apps/daemon/src/design-systems/workspace-owned-create.ts new file mode 100644 index 00000000000..eec2e31407a --- /dev/null +++ b/apps/daemon/src/design-systems/workspace-owned-create.ts @@ -0,0 +1,73 @@ +import type { WorkspaceResourceContext } from '../collab/workspace-resource-mutation.js'; +import { + createUserDesignSystem, + deleteUserDesignSystem, + type DesignSystemSummary, + type UserDesignSystemInput, +} from './index.js'; + +type WorkspaceResourceEnvelopeInput = { + visibility: 'personal'; + resourceState: 'active'; + createdByWorkspaceMemberId: string; + updatedByWorkspaceMemberId: string; +}; + +export interface CreateWorkspaceOwnedDesignSystemDeps { + ensureWorkspaceResource: ( + resourceType: 'design_system', + workspaceId: string, + resourceId: string, + input: WorkspaceResourceEnvelopeInput, + ) => unknown; + createUserDesignSystem?: ( + root: string, + input: UserDesignSystemInput, + ) => Promise<DesignSystemSummary>; + deleteUserDesignSystem?: (root: string, id: string) => Promise<boolean>; +} + +/** + * Persist one user design system and its Workspace ownership envelope. + * + * `context` is already directory-verified by the caller. A null context is + * the deliberate headerless/local compatibility lane: the design system is + * created without a Workspace claim or envelope. For a scoped create, the + * filesystem and SQLite writes form one logical unit. If the envelope write + * fails, remove only the directory allocated by this call before surfacing + * the original error, so a later catalog scan cannot expose a half-owned + * design system. + */ +export async function createWorkspaceOwnedDesignSystem( + root: string, + input: UserDesignSystemInput, + context: WorkspaceResourceContext | null, + deps: CreateWorkspaceOwnedDesignSystemDeps, +): Promise<DesignSystemSummary> { + const create = deps.createUserDesignSystem ?? createUserDesignSystem; + const remove = deps.deleteUserDesignSystem ?? deleteUserDesignSystem; + const created = await create(root, { + ...input, + ...(context ? { workspaceId: context.workspaceId } : {}), + }); + + if (!context) return created; + + try { + deps.ensureWorkspaceResource( + 'design_system', + context.workspaceId, + created.id, + { + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: context.workspaceMemberId, + updatedByWorkspaceMemberId: context.workspaceMemberId, + }, + ); + return created; + } catch (error) { + await remove(root, created.id).catch(() => false); + throw error; + } +} diff --git a/apps/daemon/src/github-install-source.ts b/apps/daemon/src/github-install-source.ts new file mode 100644 index 00000000000..c3e0be68f74 --- /dev/null +++ b/apps/daemon/src/github-install-source.ts @@ -0,0 +1,66 @@ +const GITHUB_HOSTS = new Set(['github.com', 'www.github.com']); +const GITHUB_SEGMENT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +export type GithubRepositoryUrlResolution = + | { kind: 'other' } + | { kind: 'invalid'; error: string } + | { + kind: 'repository'; + owner: string; + repo: string; + source: string; + }; + +const ROOT_URL_HELP = + 'GitHub repository URLs must use https://github.com/<owner>/<repo> ' + + '(repository root only; issues, tree, and blob URLs are not supported)'; + +/** + * Translate the browser URL users naturally paste into the canonical + * `github:owner/repo` source understood by both install backends. + * + * Deliberately narrow: accepting only a repository root keeps refs/subpaths + * unambiguous and prevents a GitHub HTML page (issues/tree/blob) from falling + * through to the generic HTTPS tarball downloader. + */ +export function resolveGithubRepositoryUrl(rawSource: string): GithubRepositoryUrlResolution { + const source = rawSource.trim(); + let url: URL; + try { + url = new URL(source); + } catch { + return { kind: 'other' }; + } + if (!GITHUB_HOSTS.has(url.hostname.toLowerCase())) return { kind: 'other' }; + if ( + url.protocol !== 'https:' + || url.port + || url.username + || url.password + || url.search + || url.hash + ) { + return { kind: 'invalid', error: ROOT_URL_HELP }; + } + + const match = /^\/([^/]+)\/([^/]+)\/?$/.exec(url.pathname); + if (!match) return { kind: 'invalid', error: ROOT_URL_HELP }; + const owner = match[1]!; + const repo = match[2]!.replace(/\.git$/i, ''); + if ( + !GITHUB_SEGMENT_RE.test(owner) + || !GITHUB_SEGMENT_RE.test(repo) + || owner === '.' + || owner === '..' + || repo === '.' + || repo === '..' + ) { + return { kind: 'invalid', error: ROOT_URL_HELP }; + } + return { + kind: 'repository', + owner, + repo, + source: `github:${owner}/${repo}`, + }; +} diff --git a/apps/daemon/src/handoff-cli.ts b/apps/daemon/src/handoff-cli.ts index 782dcb92eda..041d0a0eade 100644 --- a/apps/daemon/src/handoff-cli.ts +++ b/apps/daemon/src/handoff-cli.ts @@ -36,7 +36,9 @@ function isHandoffResponse(value: unknown): value is HandoffResponse { const USAGE = `Usage: od project handoff <projectId> --conversation <id> --api-key <key> --model <model> - [--base-url <url>] [--max-tokens <n>] [--daemon-url <url>] [--json] + [--base-url <url>] [--max-tokens <n>] + [--workspace <id> --workspace-member <id>] + [--daemon-url <url>] [--json] Synthesizes a "resume conversation" handoff prompt from one conversation's transcript via the local daemon. Prints the prompt to stdout; --json emits @@ -67,6 +69,8 @@ interface ParsedHandoffOptions { baseUrl?: string; maxTokens?: number; daemonUrl?: string; + workspaceId?: string; + workspaceMemberId?: string; json: boolean; help: boolean; } @@ -108,6 +112,14 @@ function parseOptions(args: string[]): ParsedHandoffOptions | { error: string } const value = args[++index]; if (!value) return { error: '--daemon-url requires a URL' }; options.daemonUrl = value; + } else if (arg === '--workspace') { + const value = args[++index]; + if (!value) return { error: '--workspace requires a value' }; + options.workspaceId = value; + } else if (arg === '--workspace-member') { + const value = args[++index]; + if (!value) return { error: '--workspace-member requires a value' }; + options.workspaceMemberId = value; } else if (arg.startsWith('-')) { return { error: `unknown option: ${arg}` }; } else if (options.projectId === undefined) { @@ -130,6 +142,9 @@ export async function runProjectHandoff(args: string[]): Promise<HandoffCliResul if (!options.conversationId) return fail('handoff requires --conversation <id>'); if (!options.apiKey) return fail('handoff requires --api-key <key>'); if (!options.model) return fail('handoff requires --model <model>'); + if (Boolean(options.workspaceId) !== Boolean(options.workspaceMemberId)) { + return fail('pass --workspace <id> and --workspace-member <id> together'); + } try { const daemonUrl = ( @@ -146,7 +161,15 @@ export async function runProjectHandoff(args: string[]): Promise<HandoffCliResul `${daemonUrl}/api/projects/${encodeURIComponent(options.projectId)}/handoff`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { + 'content-type': 'application/json', + ...(options.workspaceId && options.workspaceMemberId + ? { + 'x-od-workspace-id': options.workspaceId, + 'x-od-workspace-member-id': options.workspaceMemberId, + } + : {}), + }, body: JSON.stringify(body), }, ); diff --git a/apps/daemon/src/http/tool-request-auth.ts b/apps/daemon/src/http/tool-request-auth.ts index 0952c5a3518..d97894e7fa0 100644 --- a/apps/daemon/src/http/tool-request-auth.ts +++ b/apps/daemon/src/http/tool-request-auth.ts @@ -10,13 +10,23 @@ export function bearerTokenFromRequest(req: Request): string | undefined { } export function createToolRequestAuth(registry: ToolTokenRegistry): { - authorizeToolRequest(req: Request, res: Response, operation: string): ToolTokenGrant | null; + authorizeToolRequest( + req: Request, + res: Response, + operation: string, + options?: { endpoint?: string }, + ): ToolTokenGrant | null; optionalToolGrantFromRequest(req: Request, options?: Parameters<ToolTokenRegistry['validate']>[1]): ToolTokenGrant | null; requestProjectOverride(projectId: string | null | undefined, tokenProjectId: string | null | undefined): boolean; requestRunOverride(runId: string | null | undefined, tokenRunId: string | null | undefined): boolean; } { - function authorizeToolRequest(req: Request, res: Response, operation: string): ToolTokenGrant | null { - const endpoint = req.path; + function authorizeToolRequest( + req: Request, + res: Response, + operation: string, + options: { endpoint?: string } = {}, + ): ToolTokenGrant | null { + const endpoint = options.endpoint ?? req.path; const validation = registry.validate(bearerTokenFromRequest(req), { endpoint, operation }); if (!validation.ok) { const status = validation.code === 'TOOL_ENDPOINT_DENIED' || validation.code === 'TOOL_OPERATION_DENIED' ? 403 : 401; diff --git a/apps/daemon/src/import-export-routes.ts b/apps/daemon/src/import-export-routes.ts index 8f3671f7663..db3f16ebb5a 100644 --- a/apps/daemon/src/import-export-routes.ts +++ b/apps/daemon/src/import-export-routes.ts @@ -5,6 +5,7 @@ import os from 'node:os'; import { readFile, rm } from 'node:fs/promises'; import { isBlocked as isBlockedSystemDir } from './linked-dirs.js'; import type { RouteDeps } from './server-context.js'; +import type { AuthorizeProjectRequest } from './collab/project-request-authority.js'; import { InlineAssetsLimitError, MAX_INLINE_OWNER_BYTES, @@ -23,8 +24,18 @@ import { readProjectFileVersion } from './project-file-versions.js'; import { authorizeReasoningEgress, sendReasoningEgressDenial } from './reasoning-egress.js'; import { sandboxImportedProjectRootUnavailableReason } from './sandbox-mode.js'; import { parseOrchestratorWorkspace } from './workspace-contract.js'; - -export interface RegisterImportRoutesDeps extends RouteDeps<'db' | 'http' | 'uploads' | 'node' | 'ids' | 'paths' | 'imports' | 'auth' | 'projectStore' | 'conversations' | 'projectFiles' | 'validation'> {} +import { + authorizeCreatedProjectWorkspace, + bindCreatedProjectToWorkspace, + sendCreatedProjectWorkspaceError, +} from './collab/created-project-workspace.js'; +import type { WorkspaceDirectoryFetchResult } from './collab/vela-workspace-context.js'; +import type { BoundWorkspaceResourceMutationGate } from './collab/workspace-resource-mutation.js'; + +export interface RegisterImportRoutesDeps extends RouteDeps<'db' | 'http' | 'uploads' | 'node' | 'ids' | 'paths' | 'imports' | 'auth' | 'projectStore' | 'conversations' | 'projectFiles' | 'validation'> { + fetchProjectCreationWorkspaceDirectory?: () => Promise<WorkspaceDirectoryFetchResult>; + enforceWorkspaceProjectMutation?: BoundWorkspaceResourceMutationGate; +} export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps) { const { db } = ctx; @@ -63,17 +74,36 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps pruneExpiredImportNonces, verifyDesktopImportToken, } = ctx.auth; - const { getProject, insertProject, updateProject } = ctx.projectStore; + const { + getProject, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + insertProject, + updateProject, + ensureWorkspaceProject, + } = ctx.projectStore; const { insertConversation } = ctx.conversations; const { setTabs } = ctx.projectFiles; - const { validateProjectDesignSystemId } = ctx.validation; + const { + validateProjectDesignSystemId, + validateProjectSkillId, + } = ctx.validation; app.post( '/api/import/claude-design', importUpload.single('file'), async (req, res) => { + let importedProjectDir: string | null = null; try { if (!req.file) return res.status(400).json({ error: 'zip file required' }); + const createWorkspace = await authorizeCreatedProjectWorkspace( + req, + ctx.fetchProjectCreationWorkspaceDirectory, + ); + if (!createWorkspace.ok) { + fs.promises.unlink(req.file.path).catch(() => {}); + return sendCreatedProjectWorkspaceError(res, createWorkspace); + } const originalName = req.file.originalname || 'Claude Design export.zip'; if (!/\.zip$/i.test(originalName)) { @@ -84,36 +114,46 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps const now = Date.now(); const baseName = originalName.replace(/\.zip$/i, '').trim() || 'Claude Design import'; + importedProjectDir = projectDir(PROJECTS_DIR, id); const imported = await importClaudeDesignZip( req.file.path, - projectDir(PROJECTS_DIR, id), + importedProjectDir, ); fs.promises.unlink(req.file.path).catch(() => {}); - const project = insertProject(db, { - id, - name: baseName, - skillId: null, - designSystemId: null, - pendingPrompt: `Imported from Claude Design ZIP: ${originalName}. Continue editing ${imported.entryFile}.`, - metadata: { - kind: 'prototype', - importedFrom: 'claude-design', - entryFile: imported.entryFile, - sourceFileName: originalName, - }, - createdAt: now, - updatedAt: now, - }); const cid = randomId(); - insertConversation(db, { - id: cid, - projectId: id, - title: 'Imported Claude Design project', - createdAt: now, - updatedAt: now, - }); - setTabs(db, id, [imported.entryFile], imported.entryFile); + const project = db.transaction(() => { + const createdProject = insertProject(db, { + id, + name: baseName, + skillId: null, + designSystemId: null, + pendingPrompt: `Imported from Claude Design ZIP: ${originalName}. Continue editing ${imported.entryFile}.`, + metadata: { + kind: 'prototype', + importedFrom: 'claude-design', + entryFile: imported.entryFile, + sourceFileName: originalName, + }, + createdAt: now, + updatedAt: now, + }); + insertConversation(db, { + id: cid, + projectId: id, + title: 'Imported Claude Design project', + createdAt: now, + updatedAt: now, + }); + setTabs(db, id, [imported.entryFile], imported.entryFile); + bindCreatedProjectToWorkspace( + (input) => ensureWorkspaceProject(db, input), + createWorkspace.context, + id, + now, + ); + return createdProject; + })(); res.json({ project, conversationId: cid, @@ -122,6 +162,9 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps }); } catch (err: any) { if (req.file?.path) fs.promises.unlink(req.file.path).catch(() => {}); + if (importedProjectDir) { + await fs.promises.rm(importedProjectDir, { recursive: true, force: true }).catch(() => {}); + } res.status(400).json({ error: String(err) }); } }, @@ -142,6 +185,21 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps if (!existing) { return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); } + if ( + ctx.enforceWorkspaceProjectMutation + && !(await ctx.enforceWorkspaceProjectMutation( + req, + res, + sendApiError, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + db, + projectId, + 'writeFiles', + )) + ) { + return; + } const { baseDir, orchestratorWorkspace } = req.body || {}; if (typeof baseDir !== 'string' || !baseDir.trim()) { return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir required'); @@ -268,6 +326,13 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps app.post('/api/import/folder', async (req, res) => { try { + const createWorkspace = await authorizeCreatedProjectWorkspace( + req, + ctx.fetchProjectCreationWorkspaceDirectory, + ); + if (!createWorkspace.ok) { + return sendCreatedProjectWorkspaceError(res, createWorkspace); + } const { baseDir, name, skillId, designSystemId, orchestratorWorkspace } = req.body || {}; if (typeof baseDir !== 'string' || !baseDir.trim()) { return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir required'); @@ -380,7 +445,10 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps ? name.trim() : path.basename(normalizedPath); const entryFile = await detectEntryFile(normalizedPath); - const designSystemValidation = await validateProjectDesignSystemId(designSystemId); + const designSystemValidation = await validateProjectDesignSystemId( + designSystemId, + { workspaceId: createWorkspace.context?.workspaceId ?? null }, + ); if (!designSystemValidation.ok) { return sendApiError( res, @@ -389,38 +457,58 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps designSystemValidation.message, ); } - const project = insertProject(db, { - id, - name: projectName, - skillId: skillId ?? null, - designSystemId: designSystemValidation.id, - pendingPrompt: null, - metadata: { - kind: 'prototype', - baseDir: normalizedPath, - importedFrom: 'folder', - entryFile, - ...(normalizedOrchestratorWorkspace - ? { orchestratorWorkspace: normalizedOrchestratorWorkspace } - : {}), - ...(trustedPickerImport ? { fromTrustedPicker: true as const } : {}), - }, - createdAt: now, - updatedAt: now, - }); - + const skillValidation = await validateProjectSkillId( + skillId, + { workspaceId: createWorkspace.context?.workspaceId ?? null }, + ); + if (!skillValidation.ok) { + return sendApiError( + res, + 400, + skillValidation.code, + skillValidation.message, + ); + } const cid = randomId(); - insertConversation(db, { - id: cid, - projectId: id, - title: `Imported from ${projectName}`, - createdAt: now, - updatedAt: now, - }); - // Folder imports should land on Design Files so users can choose from - // the imported folder's artifacts. Persist an empty saved tab state so - // ProjectView does not auto-open the detected primary file on hydration. - setTabs(db, id, [], null); + const project = db.transaction(() => { + const createdProject = insertProject(db, { + id, + name: projectName, + skillId: skillValidation.id, + designSystemId: designSystemValidation.id, + pendingPrompt: null, + metadata: { + kind: 'prototype', + baseDir: normalizedPath, + importedFrom: 'folder', + entryFile, + ...(normalizedOrchestratorWorkspace + ? { orchestratorWorkspace: normalizedOrchestratorWorkspace } + : {}), + ...(trustedPickerImport ? { fromTrustedPicker: true as const } : {}), + }, + createdAt: now, + updatedAt: now, + }); + insertConversation(db, { + id: cid, + projectId: id, + title: `Imported from ${projectName}`, + createdAt: now, + updatedAt: now, + }); + // Folder imports should land on Design Files so users can choose from + // the imported folder's artifacts. Persist an empty saved tab state so + // ProjectView does not auto-open the detected primary file on hydration. + setTabs(db, id, [], null); + bindCreatedProjectToWorkspace( + (input) => ensureWorkspaceProject(db, input), + createWorkspace.context, + id, + now, + ); + return createdProject; + })(); /** @type {import('@open-design/contracts').ImportFolderResponse} */ const body = { project, conversationId: cid, entryFile }; res.json(body); @@ -431,7 +519,9 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps } -export interface RegisterProjectExportRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'node' | 'ids' | 'projectStore' | 'exports' | 'projectFiles' | 'validation'> {} +export interface RegisterProjectExportRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'node' | 'ids' | 'projectStore' | 'exports' | 'projectFiles' | 'validation'> { + authorizeProjectRequest: AuthorizeProjectRequest; +} export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectExportRoutesDeps) { const { db } = ctx; @@ -453,6 +543,20 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx daemonUrlRef, sanitizeArchiveFilename, } = ctx.exports; + async function authorizeExportRead( + req: any, + res: any, + options: { allowNavigationQuery?: boolean } = {}, + ): Promise<boolean> { + return ctx.authorizeProjectRequest( + req, + res, + req.params.id, + options.allowNavigationQuery + ? { mode: 'read', allowNavigationQuery: true } + : { mode: 'read' }, + ); + } function isNoSlideDeckRenderError(rendered: { ok: boolean; error?: string }): boolean { return !rendered.ok && typeof rendered.error === 'string' && /no slide surfaces found/i.test(rendered.error); @@ -858,6 +962,7 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx app.get('/api/projects/:id/archive', async (req, res) => { try { const root = typeof req.query?.root === 'string' ? req.query.root : ''; + if (!await authorizeExportRead(req, res, { allowNavigationQuery: true })) return; const project = getProject(db, req.params.id); const { buffer, baseName } = await buildProjectArchive( PROJECTS_DIR, @@ -900,6 +1005,7 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx sendApiError(res, 400, 'BAD_REQUEST', 'files must be a non-empty array'); return; } + if (!await authorizeExportRead(req, res)) return; const project = getProject(db, req.params.id); const { buffer } = await buildBatchArchive( PROJECTS_DIR, @@ -938,6 +1044,7 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx if (!project) { return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); } + if (!await ctx.authorizeProjectRequest(req, res, project.id, { mode: 'read' })) return; const files = await listFiles(PROJECTS_DIR, req.params.id, { metadata: project.metadata, }); @@ -968,6 +1075,10 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx return sendApiError(res, 400, 'BAD_REQUEST', 'fileName required'); } const project = getProject(db, req.params.id); + if (!project) { + return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + } + if (!await ctx.authorizeProjectRequest(req, res, project.id, { mode: 'read' })) return; const metadata = project?.metadata ?? null; const versionId = normalizeExportVersionId(req.body?.versionId); const sourceHtml = await readExportVersionSource(req.params.id, fileName, versionId, metadata); @@ -998,6 +1109,7 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx // PNG and assemble a one-image-per-slide .pptx. Replaces the old "send a prompt // to the agent and hope it runs python-pptx" path with a deterministic export. app.post('/api/projects/:id/export/pptx', async (req, res) => { + if (!await authorizeExportRead(req, res)) return; await handleScreenshotExport(res, 'pptx', req.params.id, req.body); }); @@ -1005,6 +1117,7 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx // The print-ready vector PDF stays on POST /export/pdf; this is the "exactly // what you see" counterpart that shares the slide renderer with PPTX. app.post('/api/projects/:id/export/pdf-image', async (req, res) => { + if (!await authorizeExportRead(req, res)) return; await handleScreenshotExport(res, 'pdf', req.params.id, req.body); }); @@ -1013,6 +1126,7 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx // the whole document at natural size. Viewport-independent — unlike the // host-compositor snapshot, the size never depends on the preview pane. app.post('/api/projects/:id/export/image', async (req, res) => { + if (!await authorizeExportRead(req, res)) return; await handleScreenshotExport(res, 'image', req.params.id, req.body); }); @@ -1033,6 +1147,7 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx if (!isExportFormat(format)) { return sendApiError(res, 400, 'BAD_REQUEST', 'invalid export format'); } + if (!await authorizeExportRead(req, res)) return; await handleScreenshotExport(res, format, req.params.id, { fileName, // pptx is deck-only (handleScreenshotExport forces it); pdf/image honor the @@ -1090,6 +1205,7 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx ); } + if (!await authorizeExportRead(req, res, { allowNavigationQuery: true })) return; const project = getProject(db, req.params.id); const splatParam = (req.params as { splat?: string | string[] }).splat; const relPath = Array.isArray(splatParam) ? splatParam.join('/') : String(splatParam ?? ''); @@ -1478,7 +1594,9 @@ function roleForExportManifestFile( return 'other'; } -export interface RegisterFinalizeRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'projectStore' | 'validation' | 'finalize'> {} +export interface RegisterFinalizeRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'projectStore' | 'validation' | 'finalize'> { + authorizeProjectRequest: AuthorizeProjectRequest; +} export function registerFinalizeRoutes(app: Express, ctx: RegisterFinalizeRoutesDeps) { const { db } = ctx; @@ -1564,6 +1682,12 @@ export function registerFinalizeRoutes(app: Express, ctx: RegisterFinalizeRoutes if (!project) { return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); } + if (!await ctx.authorizeProjectRequest( + req, + res, + project.id, + { mode: 'write', capability: 'writeFiles' }, + )) return; const finalizeAbort = new AbortController(); const abortFromRequest = (): void => { diff --git a/apps/daemon/src/integrations/collab-cloud.ts b/apps/daemon/src/integrations/collab-cloud.ts new file mode 100644 index 00000000000..6852be7cf0d --- /dev/null +++ b/apps/daemon/src/integrations/collab-cloud.ts @@ -0,0 +1,196 @@ +// Client for the collab cloud (C-lane §D4): the cross-daemon comment relay + +// member directory. Mirrors the resource-hub integration shape — a factory with +// injectable fetch/config/timeout, env-scoped config (this file, not +// app-config.ts, owns OD_COLLAB_CLOUD_*), and a from-env constructor. +// +// DEGRADE: unlike the resource-hub client, this factory returns `null` when +// OD_COLLAB_CLOUD_URL is unset, so every caller is a plain `client?.method()` +// no-op off-team / unconfigured. Auth is a single bearer token (§D4.4); the real +// hub verifies B's signed token, this stub presents a shared local token. + +import type { + CollabCloudComment, + CollabCloudMemberDirectoryEntry, + CollabMemberRole, +} from '@open-design/contracts'; + +const DEFAULT_FETCH_TIMEOUT_MS = 8_000; + +type FetchLike = typeof fetch; + +export interface CollabCloudConfig { + baseUrl: string; + token: string | null; +} + +/** Read the collab-cloud config from env, or null when no URL is configured + * (the single "is collab cloud on?" gate — everything degrades to no-op). */ +export function readCollabCloudConfig( + env: NodeJS.ProcessEnv = process.env, +): CollabCloudConfig | null { + const baseUrl = env.OD_COLLAB_CLOUD_URL?.trim(); + if (!baseUrl) return null; + return { baseUrl, token: env.OD_COLLAB_CLOUD_TOKEN?.trim() || null }; +} + +export function hasExplicitCollabCloudConfig( + env: NodeJS.ProcessEnv = process.env, +): boolean { + return Boolean(env.OD_COLLAB_CLOUD_URL?.trim()); +} + +export class CollabCloudError extends Error { + constructor( + readonly status: number, + readonly code: string, + message?: string, + ) { + super(message ?? `collab cloud error ${status} (${code})`); + this.name = 'CollabCloudError'; + } +} + +export interface CollabCloudMemberRegistration { + displayName: string; + role: CollabMemberRole; +} + +export interface CollabCloudPullResult { + comments: CollabCloudComment[]; + latestSeq: number; +} + +interface CollabCloudClientOptions { + config?: CollabCloudConfig; + fetch?: FetchLike; + timeoutMs?: number; +} + +export function createCollabCloudClient(options: CollabCloudClientOptions = {}) { + const config = options.config ?? readCollabCloudConfig(); + if (!config) { + throw new Error('collab cloud is not configured (OD_COLLAB_CLOUD_URL is unset)'); + } + const fetchImpl = options.fetch ?? fetch; + const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS; + + function authHeaders(extra?: Record<string, string>): Record<string, string> { + const headers: Record<string, string> = { 'content-type': 'application/json', ...extra }; + if (config!.token) headers.authorization = `Bearer ${config!.token}`; + return headers; + } + + async function request<T>( + method: string, + path: string, + body?: unknown, + extraHeaders?: Record<string, string>, + ): Promise<{ status: number; payload: T; etag: string | null }> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(new URL(path, config!.baseUrl), { + method, + headers: authHeaders(extraHeaders), + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + signal: controller.signal, + }); + const etag = response.headers.get('etag'); + if (response.status === 304) { + return { status: 304, payload: {} as T, etag }; + } + const text = await response.text(); + const payload = text ? JSON.parse(text) : {}; + if (!response.ok) { + const code = typeof payload?.error === 'string' ? payload.error : 'unknown'; + throw new CollabCloudError(response.status, code, payload?.message); + } + return { status: response.status, payload: payload as T, etag }; + } finally { + clearTimeout(timeout); + } + } + + return { + isConfigured(): boolean { + return true; + }, + + /** Register (idempotently upsert) a member's directory entry. */ + async registerMember( + teamId: string, + memberId: string, + input: CollabCloudMemberRegistration, + ): Promise<CollabCloudMemberDirectoryEntry> { + const { payload } = await request<{ member: CollabCloudMemberDirectoryEntry }>( + 'PUT', + `/teams/${encodeURIComponent(teamId)}/members/${encodeURIComponent(memberId)}`, + input, + ); + return payload.member; + }, + + /** List the team's member directory (memberId → {displayName, role}). */ + async listMembers(teamId: string): Promise<CollabCloudMemberDirectoryEntry[]> { + const { payload } = await request<{ members: CollabCloudMemberDirectoryEntry[] }>( + 'GET', + `/teams/${encodeURIComponent(teamId)}/members`, + ); + return payload.members ?? []; + }, + + /** Append a comment to a project's stream; returns the assigned seq. */ + async pushComment( + teamId: string, + projectId: string, + comment: CollabCloudComment, + ): Promise<{ seq: number }> { + const { payload } = await request<{ seq: number }>( + 'POST', + `/teams/${encodeURIComponent(teamId)}/projects/${encodeURIComponent(projectId)}/comments`, + { comment }, + ); + return { seq: payload.seq }; + }, + + /** + * Pull comments with `seq > sinceSeq`. `etag` (from a prior pull) enables a + * 304 short-circuit: on 304 the result echoes back `sinceSeq` as `latestSeq` + * with no comments, and `notModified` is true. + */ + async pullComments( + teamId: string, + projectId: string, + sinceSeq: number, + etag?: string | null, + ): Promise<CollabCloudPullResult & { notModified: boolean; etag: string | null }> { + const query = `?sinceSeq=${encodeURIComponent(String(sinceSeq))}`; + const { status, payload, etag: nextEtag } = await request<CollabCloudPullResult>( + 'GET', + `/teams/${encodeURIComponent(teamId)}/projects/${encodeURIComponent(projectId)}/comments${query}`, + undefined, + etag ? { 'if-none-match': etag } : undefined, + ); + if (status === 304) { + return { comments: [], latestSeq: sinceSeq, notModified: true, etag: nextEtag }; + } + return { + comments: payload.comments ?? [], + latestSeq: typeof payload.latestSeq === 'number' ? payload.latestSeq : sinceSeq, + notModified: false, + etag: nextEtag, + }; + }, + }; +} + +export type CollabCloudClient = ReturnType<typeof createCollabCloudClient>; + +/** Build the client from env, or null when the collab cloud is not configured. */ +export function createCollabCloudClientFromEnv( + env: NodeJS.ProcessEnv = process.env, +): CollabCloudClient | null { + const config = readCollabCloudConfig(env); + if (!config) return null; + return createCollabCloudClient({ config }); +} diff --git a/apps/daemon/src/integrations/vela-billing.ts b/apps/daemon/src/integrations/vela-billing.ts new file mode 100644 index 00000000000..f7437d59040 --- /dev/null +++ b/apps/daemon/src/integrations/vela-billing.ts @@ -0,0 +1,518 @@ +import type { + WorkspaceBillingCatalog, + WorkspaceBillingRevisionClock, + WorkspaceBillingSnapshot, + WorkspaceBillingState, + WorkspaceBillingSummary, + WorkspaceTeamBillingPlanId, + WorkspaceWalletBalance, +} from '@open-design/contracts'; +import { runVelaCommand } from './vela-command.js'; + +// A-lane billing 收口. Instead of the daemon holding billing credentials, it +// shells out to `vela billing summary --format json`, which authenticates with +// the same vela login session AMR + the resource CLI use — one identity, and +// the billing truth lives in the vela backend. This is the read-side twin of +// the resource CLI adapter (see vela-cli-resource-adapter.ts): the client shows +// real credits + plan tier instead of a placeholder, and it degrades to null +// (the client keeps its context-derived tier hint) when the CLI / session is +// unavailable. The child process is injectable so the mapping is unit-tested +// without a live CLI. + +/** Run `vela billing <args>` and resolve its stdout. */ +export type RunVelaBilling = (args: string[]) => Promise<string>; + +export interface FetchVelaBillingOptions { + /** Injectable child-process runner; defaults to spawning the vela binary. */ + run?: RunVelaBilling; +} + +export class VelaWorkspaceBillingSnapshotUnsupportedError extends Error { + readonly code = 'billing_workspace_snapshot_unsupported'; + + constructor() { + super('workspace billing snapshot unsupported'); + this.name = 'VelaWorkspaceBillingSnapshotUnsupportedError'; + } +} + +export interface VelaWorkspaceBillingProjection { + snapshot: WorkspaceBillingSnapshot | null; + workspaceBalance: WorkspaceWalletBalance | null; +} + +/** Fetch Vela's account-scoped billing summary through the CLI 收口. */ +export async function fetchVelaBillingSummary( + options: FetchVelaBillingOptions = {}, +): Promise<WorkspaceBillingSummary | null> { + const run = options.run ?? defaultRunVelaBilling; + let stdout: string; + try { + stdout = await run(['summary', '--format', 'json']); + } catch { + return null; + } + return parseBillingSummary(stdout); +} + +/** + * Fetch one explicit workspace wallet. The requested id travels as a CLI + * argument, never as ambient active-workspace state, and the parser requires + * Vela to return the same identity with billing-scope v2. + */ +export async function fetchVelaWorkspaceBalance( + workspaceId: string, + options: FetchVelaBillingOptions = {}, +): Promise<WorkspaceWalletBalance | null> { + const requestedWorkspaceId = workspaceId.trim(); + if (!requestedWorkspaceId) return null; + const run = options.run ?? defaultRunVelaBilling; + let stdout: string; + try { + stdout = await run([ + 'workspace-balance', + '--workspace-id', + requestedWorkspaceId, + '--format', + 'json', + ]); + } catch { + return null; + } + return parseWorkspaceWalletBalance(stdout, requestedWorkspaceId); +} + +/** + * Prefer Vela's atomic plan+wallet snapshot. An old CLI/server may report the + * typed unsupported sentinel. Older Cobra builds instead reject the first new + * command flag before resolving the unknown subcommand; both compatibility + * cases fall back to the legacy wallet command. Auth/network/parse failures + * stay unavailable instead of being misclassified as an old server. + */ +export async function fetchVelaWorkspaceBillingProjection( + workspaceId: string, + options: FetchVelaBillingOptions = {}, +): Promise<VelaWorkspaceBillingProjection> { + const requestedWorkspaceId = workspaceId.trim(); + if (!requestedWorkspaceId) { + return { snapshot: null, workspaceBalance: null }; + } + const run = options.run ?? defaultRunVelaBilling; + try { + const stdout = await run([ + 'workspace-snapshot', + '--workspace-id', + requestedWorkspaceId, + '--format', + 'json', + ]); + const snapshot = parseWorkspaceBillingSnapshot(stdout, requestedWorkspaceId); + if (!snapshot) { + throw new Error(`workspace billing snapshot is invalid for ${requestedWorkspaceId}`); + } + return { + snapshot, + workspaceBalance: { + workspaceId: snapshot.workspaceId, + workspaceMemberId: snapshot.workspaceMemberId, + balanceUsd: snapshot.wallet.balanceUsd, + billingScopeVersion: 2, + expiresAt: snapshot.wallet.expiresAt, + updatedAt: snapshot.wallet.updatedAt, + }, + }; + } catch (error) { + if ( + !(error instanceof VelaWorkspaceBillingSnapshotUnsupportedError) && + !isWorkspaceBillingSnapshotUnsupported(error, '') + ) { + throw error; + } + const legacyStdout = await run([ + 'workspace-balance', + '--workspace-id', + requestedWorkspaceId, + '--format', + 'json', + ]); + const workspaceBalance = parseWorkspaceWalletBalance( + legacyStdout, + requestedWorkspaceId, + ); + if (!workspaceBalance) { + throw new Error(`workspace balance response is invalid for ${requestedWorkspaceId}`); + } + return { + snapshot: null, + workspaceBalance, + }; + } +} + +export interface BillingCheckoutOptions { + /** Team workspace id whose subscription is being purchased. */ + workspaceId?: string; + /** Vela team subscription plan id. */ + planId?: WorkspaceTeamBillingPlanId; + /** Seats to purchase for the team subscription (>= 1). */ + seats?: number; + /** Where Stripe returns the user after success / cancel. */ + successUrl?: string; + cancelUrl?: string; + /** Injectable child-process runner; defaults to spawning the vela binary. */ + run?: RunVelaBilling; +} + +/** + * Start a team-subscription checkout via the CLI 收口 and return the Stripe + * checkout URL to open, or null when the CLI / session / backend route is + * unavailable. Mirrors A's `POST …/billing/team-subscription/checkout-sessions` + * behind `vela billing checkout`. Never throws — a null return means "no URL", + * so the caller shows an error toast instead of crashing. + */ +export async function fetchBillingCheckoutUrl( + options: BillingCheckoutOptions = {}, +): Promise<string | null> { + const workspaceId = options.workspaceId?.trim(); + if (!workspaceId) return null; + const planId = options.planId ?? 'team_plus'; + const seats = options.seats && options.seats > 0 ? Math.floor(options.seats) : 1; + const args = [ + 'checkout', + '--workspace-id', + workspaceId, + '--plan-id', + planId, + '--seats', + String(seats), + '--format', + 'json', + ]; + if (options.successUrl) args.push('--success-url', options.successUrl); + if (options.cancelUrl) args.push('--cancel-url', options.cancelUrl); + const run = options.run ?? defaultRunVelaBilling; + let stdout: string; + try { + stdout = await run(args); + } catch { + return null; + } + const trimmed = stdout.trim(); + if (!trimmed) return null; + try { + const raw = JSON.parse(trimmed) as Record<string, unknown>; + return typeof raw.checkoutUrl === 'string' && raw.checkoutUrl ? raw.checkoutUrl : null; + } catch { + return null; + } +} + +export async function fetchVelaBillingCatalog( + workspaceId: string, + options: FetchVelaBillingOptions = {}, +): Promise<WorkspaceBillingCatalog | null> { + const trimmedWorkspaceId = workspaceId.trim(); + if (!trimmedWorkspaceId) return null; + const run = options.run ?? defaultRunVelaBilling; + let stdout: string; + try { + stdout = await run([ + 'team-catalog', + '--workspace-id', + trimmedWorkspaceId, + '--format', + 'json', + ]); + } catch { + return null; + } + return parseBillingCatalog(stdout); +} + +/** Map `vela billing summary` without inventing a workspace identity. */ +export function parseBillingSummary(stdout: string): WorkspaceBillingSummary | null { + const trimmed = stdout.trim(); + if (!trimmed) return null; + let raw: Record<string, unknown>; + try { + raw = JSON.parse(trimmed) as Record<string, unknown>; + } catch { + return null; + } + const balances = (raw.balances ?? {}) as Record<string, unknown>; + return { + workspaceId: null, + membershipTier: str(raw.membershipTier), + totalAvailableCredits: credits(balances.totalAvailableCredits), + subscriptionCredits: credits(balances.subscriptionCredits), + rechargeCredits: credits(balances.rechargeCredits), + balanceUsd: str(raw.balanceUsd) || '0', + subscriptionStatus: str(raw.subscriptionStatus), + availableActions: Array.isArray(raw.availableActions) + ? raw.availableActions.filter((a): a is string => typeof a === 'string') + : [], + workspaceBalance: null, + }; +} + +/** Parse only a backend-proven balance for the exact requested workspace. */ +export function parseWorkspaceWalletBalance( + stdout: string, + requestedWorkspaceId: string, +): WorkspaceWalletBalance | null { + const requested = requestedWorkspaceId.trim(); + const trimmed = stdout.trim(); + if (!requested || !trimmed) return null; + let raw: Record<string, unknown>; + try { + raw = JSON.parse(trimmed) as Record<string, unknown>; + } catch { + return null; + } + const workspaceId = str(raw.workspaceId).trim(); + const workspaceMemberId = str(raw.workspaceMemberId).trim(); + const balanceUsd = str(raw.balanceUsd).trim(); + if ( + raw.billingScopeVersion !== 2 || + workspaceId !== requested || + !workspaceMemberId || + !balanceUsd + ) { + return null; + } + return { + workspaceId, + workspaceMemberId, + balanceUsd, + billingScopeVersion: 2, + expiresAt: nullableString(raw.expiresAt), + updatedAt: nullableString(raw.updatedAt), + }; +} + +/** Parse only a backend-proven snapshot for the exact requested workspace. */ +export function parseWorkspaceBillingSnapshot( + stdout: string, + requestedWorkspaceId: string, +): WorkspaceBillingSnapshot | null { + const requested = requestedWorkspaceId.trim(); + const trimmed = stdout.trim(); + if (!requested || !trimmed) return null; + let raw: Record<string, unknown>; + try { + raw = JSON.parse(trimmed) as Record<string, unknown>; + } catch { + return null; + } + const workspaceId = str(raw.workspaceId).trim(); + const workspaceMemberId = str(raw.workspaceMemberId).trim(); + const billing = objectRecord(raw.billing); + const wallet = objectRecord(raw.wallet); + const revisions = objectRecord(raw.revisions); + const revisionClocks = objectRecord(raw.revisionClocks); + const billingState = nullableBillingState(billing.billingState); + const balanceUsd = str(wallet.balanceUsd).trim(); + const billingRevision = str(revisions.billing).trim(); + const walletRevision = str(revisions.wallet).trim(); + const billingRevisionClock = parseWorkspaceBillingRevisionClock( + revisionClocks.billing, + ); + const walletRevisionClock = parseWorkspaceBillingRevisionClock( + revisionClocks.wallet, + ); + if ( + raw.schemaVersion !== 1 || + raw.billingScopeVersion !== 2 || + !isObjectRecord(raw.billing) || + !isObjectRecord(raw.wallet) || + !isObjectRecord(raw.revisions) || + workspaceId !== requested || + !workspaceMemberId || + !balanceUsd || + !billingRevision || + !walletRevision || + !isNullableBillingState(billing.billingState) || + !isNullableNonEmptyString(billing.planId) || + !isNullableNonEmptyString(wallet.expiresAt) || + !isNullableNonEmptyString(wallet.updatedAt) + ) { + return null; + } + return { + schemaVersion: 1, + workspaceId, + workspaceMemberId, + billingScopeVersion: 2, + billing: { + billingState, + planId: nullableString(billing.planId), + }, + wallet: { + balanceUsd, + expiresAt: nullableString(wallet.expiresAt), + updatedAt: nullableString(wallet.updatedAt), + }, + revisions: { + billing: billingRevision, + wallet: walletRevision, + }, + ...(billingRevisionClock && walletRevisionClock + ? { + revisionClocks: { + billing: billingRevisionClock, + wallet: walletRevisionClock, + }, + } + : {}), + }; +} + +function parseWorkspaceBillingRevisionClock( + value: unknown, +): WorkspaceBillingRevisionClock | null { + if (!isObjectRecord(value)) return null; + const epoch = str(value.epoch).trim(); + const counter = str(value.counter).trim(); + if (!epoch || !/^(?:0|[1-9]\d*)$/.test(counter)) return null; + return { epoch, counter }; +} + +/** B sends credit buckets as decimal strings; a missing/garbage bucket is 0. */ +function credits(value: unknown): number { + const parsed = Number(value ?? 0); + return Number.isFinite(parsed) ? parsed : 0; +} + +export function parseBillingCatalog(stdout: string): WorkspaceBillingCatalog | null { + const trimmed = stdout.trim(); + if (!trimmed) return null; + let raw: Record<string, unknown>; + try { + raw = JSON.parse(trimmed) as Record<string, unknown>; + } catch { + return null; + } + const workspaceId = str(raw.workspaceId); + const billingInterval = raw.billingInterval === 'monthly' ? 'monthly' : null; + if (!workspaceId || !billingInterval || !Array.isArray(raw.plans)) return null; + const plans = raw.plans + .map((plan): WorkspaceBillingCatalog['plans'][number] | null => { + if (!plan || typeof plan !== 'object') return null; + const record = plan as Record<string, unknown>; + const planId = parseTeamPlanId(record.planId); + const seatUnitAmountCents = Number(record.seatUnitAmountCents); + const minSeats = Number(record.minSeats); + const currency = record.currency === 'usd' ? 'usd' : null; + const status = + record.status === 'active' || record.status === 'disabled' + ? record.status + : null; + if ( + !planId || + !currency || + !status || + !Number.isFinite(seatUnitAmountCents) || + seatUnitAmountCents <= 0 || + !Number.isFinite(minSeats) || + minSeats <= 0 + ) { + return null; + } + return { + planId, + seatUnitAmountCents, + currency, + minSeats, + status, + }; + }) + .filter((plan): plan is WorkspaceBillingCatalog['plans'][number] => plan !== null); + return { workspaceId, billingInterval, plans }; +} + +function str(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function nullableString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value : null; +} + +function objectRecord(value: unknown): Record<string, unknown> { + return isObjectRecord(value) + ? value as Record<string, unknown> + : {}; +} + +function isObjectRecord(value: unknown): value is Record<string, unknown> { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +const WORKSPACE_BILLING_STATES: ReadonlySet<WorkspaceBillingState> = new Set([ + 'free', + 'active', + 'past_due', + 'canceled', + 'inactive', + 'locked', +]); + +function nullableBillingState(value: unknown): WorkspaceBillingState | null { + return typeof value === 'string' && + WORKSPACE_BILLING_STATES.has(value as WorkspaceBillingState) + ? value as WorkspaceBillingState + : null; +} + +function isNullableBillingState(value: unknown): boolean { + return value == null || + (typeof value === 'string' && + WORKSPACE_BILLING_STATES.has(value as WorkspaceBillingState)); +} + +function isNullableNonEmptyString(value: unknown): boolean { + return value == null || (typeof value === 'string' && value.trim().length > 0); +} + +function parseTeamPlanId(value: unknown): WorkspaceTeamBillingPlanId | null { + return value === 'team_plus' || value === 'team_pro' || value === 'team_max' + ? value + : null; +} + +const defaultRunVelaBilling: RunVelaBilling = async (args) => { + let stderr = ''; + try { + return await runVelaCommand(['billing', ...args], { + configuredEnv: { VELA_INVOCATION_SOURCE: 'open-design' }, + maxBuffer: 4 * 1024 * 1024, + onStderr: (value) => { + stderr = value; + }, + }); + } catch (error) { + if ( + args[0] === 'workspace-snapshot' && + isWorkspaceBillingSnapshotUnsupported(error, stderr) + ) { + throw new VelaWorkspaceBillingSnapshotUnsupportedError(); + } + throw error; + } +}; + +function isWorkspaceBillingSnapshotUnsupported(error: unknown, stderr: string): boolean { + const detail = [ + stderr, + error instanceof Error ? error.message : String(error), + ].join('\n').toLowerCase(); + return ( + detail.includes('billing_workspace_snapshot_unsupported') || + detail.includes('workspace billing snapshot unsupported') || + detail.includes('unknown flag: --workspace-id') || + ( + detail.includes('unknown command') && + detail.includes('workspace-snapshot') + ) + ); +} diff --git a/apps/daemon/src/integrations/vela-command.ts b/apps/daemon/src/integrations/vela-command.ts new file mode 100644 index 00000000000..b2eaf278193 --- /dev/null +++ b/apps/daemon/src/integrations/vela-command.ts @@ -0,0 +1,297 @@ +import { execFile } from 'node:child_process'; + +import { + collectProcessTreePids, + createCommandInvocation, + listProcessSnapshots, + stopProcesses, +} from '@open-design/platform'; + +import { + agentCliEnvForAgent, + readAppConfigSync, +} from '../app-config.js'; +import { spawnEnvForAgent } from '../runtimes/env.js'; +import { + applyAgentLaunchEnv, + resolveAgentLaunch, +} from '../runtimes/launch.js'; +import { getAgentDef } from '../runtimes/registry.js'; + +export interface VelaCommandOptions { + env?: NodeJS.ProcessEnv; + configuredEnv?: Record<string, string>; + maxBuffer?: number; + /** + * Terminate the child process tree when it exceeds this wall-clock budget. + * Rejection happens only after termination is confirmed, so callers cannot + * release a materialization lock while the old process may still be writing. + */ + timeoutMs?: number; + /** Optional caller cancellation with the same confirmed-termination rule. */ + signal?: AbortSignal; + /** Grace for each of SIGTERM and SIGKILL confirmation. Defaults to 500ms. */ + terminationGraceMs?: number; + /** + * Observe buffered stderr after the child exits. The stdout return contract + * stays unchanged; observer failures never affect command completion. + */ + onStderr?: (stderr: string) => void; +} + +type VelaTerminationReason = 'abort' | 'timeout'; + +const DEFAULT_TERMINATION_GRACE_MS = 500; + +function positiveInteger(value: number | undefined): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : undefined; +} + +function commandTerminationError( + reason: VelaTerminationReason, + timeoutMs: number | undefined, + signal: AbortSignal | undefined, +): Error { + if (reason === 'abort') { + const error = new Error('vela command aborted', { + cause: signal?.reason, + }); + error.name = 'AbortError'; + Object.assign(error, { code: 'ABORT_ERR' }); + return error; + } + const error = new Error(`vela command timed out after ${timeoutMs}ms`); + error.name = 'TimeoutError'; + Object.assign(error, { code: 'ETIMEDOUT' }); + return error; +} + +function logCommandTermination( + phase: 'completed' | 'failed' | 'scheduled' | 'unconfirmed', + detail: { + reason: VelaTerminationReason; + timeoutMs?: number | undefined; + childPid?: number | undefined; + forcedPids?: number[] | undefined; + remainingPids?: number[] | undefined; + error?: unknown; + }, +): void { + const fields = [ + `phase=${phase}`, + `reason=${detail.reason}`, + `timeoutMs=${detail.timeoutMs ?? 'none'}`, + `childPid=${detail.childPid ?? 'unknown'}`, + `forced=${detail.forcedPids?.length ?? 0}`, + `remaining=${detail.remainingPids?.length ?? 0}`, + ]; + if (detail.error != null) { + fields.push( + `error=${detail.error instanceof Error ? detail.error.name : 'unknown'}`, + ); + } + console.warn(`[od] vela_command_termination ${fields.join(' ')}`); +} + +export function velaWorkspaceCommandOptions( + workspaceId: string | null | undefined, +): VelaCommandOptions { + const requestedWorkspaceId = workspaceId?.trim(); + return { + configuredEnv: { + VELA_INVOCATION_SOURCE: 'open-design', + ...(requestedWorkspaceId + ? { VELA_WORKSPACE_ID: requestedWorkspaceId } + : {}), + }, + }; +} + +function configuredAmrEnv( + env: NodeJS.ProcessEnv, + explicit: Record<string, string> = {}, +): Record<string, string> { + let stored: Record<string, string> = {}; + const dataDir = env.OD_DATA_DIR?.trim(); + if (dataDir) { + try { + stored = agentCliEnvForAgent(readAppConfigSync(dataDir).agentCliEnv, 'amr'); + } catch { + // An unreadable app config must not hide a valid inherited or packaged + // Vela installation; the command will use the normal resolver fallback. + } + } + const inheritedVelaBin = env.VELA_BIN?.trim(); + return { + ...(inheritedVelaBin ? { VELA_BIN: inheritedVelaBin } : {}), + // Settings-backed agent CLI configuration follows the same precedence as + // login and AMR launches: it overrides the inherited shell environment. + ...stored, + ...explicit, + }; +} + +/** + * Run the same resolved Vela binary and environment used by Open Design login + * and AMR agent launches. Resource/team/collab adapters must use this instead + * of spawning a PATH-only `vela` process, otherwise a packaged login can + * succeed while the collaboration command uses a different or missing CLI. + */ +export function runVelaCommand( + args: string[], + options: VelaCommandOptions = {}, +): Promise<string> { + const env = options.env ?? process.env; + const configuredEnv = configuredAmrEnv(env, options.configuredEnv); + const def = getAgentDef('amr'); + if (!def) { + return Promise.reject(new Error('AMR runtime definition is missing')); + } + const launch = resolveAgentLaunch(def, configuredEnv); + const bin = launch.launchPath ?? launch.selectedPath; + if (!bin) { + return Promise.reject( + new Error('vela binary not found; install vela or configure VELA_BIN'), + ); + } + const childEnv = applyAgentLaunchEnv( + spawnEnvForAgent('amr', env, configuredEnv), + launch, + ); + const invocation = createCommandInvocation({ command: bin, args, env: childEnv }); + const timeoutMs = positiveInteger(options.timeoutMs); + const terminationGraceMs = + positiveInteger(options.terminationGraceMs) ?? DEFAULT_TERMINATION_GRACE_MS; + if (options.signal?.aborted) { + return Promise.reject( + commandTerminationError('abort', timeoutMs, options.signal), + ); + } + return new Promise<string>((resolve, reject) => { + let childPid: number | undefined; + let settled = false; + let terminating = false; + let deadlineTimer: NodeJS.Timeout | undefined; + let abortListener: (() => void) | undefined; + + const clearTriggers = (): void => { + if (deadlineTimer) { + clearTimeout(deadlineTimer); + deadlineTimer = undefined; + } + if (abortListener && options.signal) { + options.signal.removeEventListener('abort', abortListener); + abortListener = undefined; + } + }; + + const settle = ( + outcome: { stdout: string } | { error: unknown }, + ): void => { + if (settled) return; + settled = true; + clearTriggers(); + if ('error' in outcome) reject(outcome.error); + else resolve(outcome.stdout); + }; + + const terminate = (reason: VelaTerminationReason): void => { + if (settled || terminating) return; + terminating = true; + clearTriggers(); + logCommandTermination('scheduled', { + reason, + timeoutMs, + childPid, + }); + if (childPid == null) { + // Releasing the caller's lock without a PID would allow another pull + // to write concurrently with a process whose termination is unknown. + logCommandTermination('unconfirmed', { + reason, + timeoutMs, + }); + return; + } + void (async () => { + const processTree = collectProcessTreePids( + await listProcessSnapshots(), + [childPid], + ); + const result = await stopProcesses(processTree, { + termGraceMs: terminationGraceMs, + killGraceMs: terminationGraceMs, + }); + if (result.remainingPids.length > 0) { + logCommandTermination('unconfirmed', { + reason, + timeoutMs, + childPid, + forcedPids: result.forcedPids, + remainingPids: result.remainingPids, + }); + return; + } + logCommandTermination('completed', { + reason, + timeoutMs, + childPid, + forcedPids: result.forcedPids, + remainingPids: result.remainingPids, + }); + settle({ + error: commandTerminationError(reason, timeoutMs, options.signal), + }); + })().catch((error: unknown) => { + // Do not settle: without confirmed termination the caller must retain + // its per-project lock rather than permit overlapping disk writes. + logCommandTermination('failed', { + reason, + timeoutMs, + childPid, + error, + }); + }); + }; + + const child = execFile( + invocation.command, + invocation.args, + { + env: childEnv, + encoding: 'utf8', + maxBuffer: options.maxBuffer ?? 16 * 1024 * 1024, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + }, + (error, stdout, stderr) => { + if (terminating) return; + if (stderr && options.onStderr) { + try { + options.onStderr(stderr); + } catch { + // Diagnostics are observational and must never change transport. + } + } + if (error) settle({ error }); + else settle({ stdout }); + }, + ); + childPid = child.pid; + if (settled) return; + + if (options.signal) { + abortListener = () => terminate('abort'); + options.signal.addEventListener('abort', abortListener, { once: true }); + if (options.signal.aborted) { + terminate('abort'); + return; + } + } + if (timeoutMs !== undefined) { + deadlineTimer = setTimeout(() => terminate('timeout'), timeoutMs); + deadlineTimer.unref(); + } + }); +} diff --git a/apps/daemon/src/integrations/vela-errors.ts b/apps/daemon/src/integrations/vela-errors.ts index b7ea393b2d0..2c601831ee2 100644 --- a/apps/daemon/src/integrations/vela-errors.ts +++ b/apps/daemon/src/integrations/vela-errors.ts @@ -19,16 +19,20 @@ export interface AmrAccountFailureSignal { stderrTail?: unknown; } -// `source=open_design` tags the wallet landing page_view so vela analytics can +// `source=open_design` tags the console landing page_view so vela analytics can // attribute the recharge visit to Open Design. +// +// The console dashboard, not a wallet page: balance and manual top-up were +// rehomed onto it (vela #1055) and the wallet route left the product's +// information architecture, so this link must not send a user there. export const DEFAULT_AMR_RECHARGE_URL = - 'https://open-design.ai/amr/wallet?source=open_design'; + 'https://open-design.ai/amr/dashboard?source=open_design'; const AMR_AUTH_REQUIRED_MESSAGE = 'AMR sign-in is required. Sign in to AMR Cloud again, then retry this run.'; const AMR_INSUFFICIENT_BALANCE_MESSAGE = - `AMR Cloud reported insufficient balance for this model. Recharge your AMR wallet at ${DEFAULT_AMR_RECHARGE_URL}, then retry this run.`; + `AMR Cloud reported insufficient balance for this model. Top up your AMR balance at ${DEFAULT_AMR_RECHARGE_URL}, then retry this run.`; const AMR_TIER_UPGRADE_REQUIRED_MESSAGE = 'Your current AMR plan does not include this model or request type. Upgrade your AMR plan, or switch to an available model and retry.'; diff --git a/apps/daemon/src/integrations/vela-profile.ts b/apps/daemon/src/integrations/vela-profile.ts index 5650aa3474b..372d8de1805 100644 --- a/apps/daemon/src/integrations/vela-profile.ts +++ b/apps/daemon/src/integrations/vela-profile.ts @@ -1,17 +1,19 @@ const AMR_PROFILE_ENV = 'OPEN_DESIGN_AMR_PROFILE'; +const VELA_PROFILE_ENV = 'VELA_PROFILE'; const DEFAULT_PROFILE = 'prod'; -const ALLOWED_PROFILES = new Set(['prod', 'test', 'local']); +const ALLOWED_PROFILES = new Set(['prod', 'test', 'feature-test', 'local']); -export type AmrProfile = 'prod' | 'test' | 'local'; +export type AmrProfile = 'prod' | 'test' | 'feature-test' | 'local'; type EnvMap = NodeJS.ProcessEnv | Record<string, string | undefined>; export function resolveAmrProfile(env: EnvMap = process.env): AmrProfile { - const raw = (env[AMR_PROFILE_ENV] || '').trim(); + const source = (env[AMR_PROFILE_ENV] || '').trim() ? AMR_PROFILE_ENV : VELA_PROFILE_ENV; + const raw = (env[AMR_PROFILE_ENV] || env[VELA_PROFILE_ENV] || '').trim(); if (!raw) return DEFAULT_PROFILE; if (ALLOWED_PROFILES.has(raw)) return raw as AmrProfile; console.warn( - `[amr] invalid ${AMR_PROFILE_ENV}="${raw}"; falling back to ${DEFAULT_PROFILE}`, + `[amr] invalid ${source}="${raw}"; expected prod, test, feature-test, or local; falling back to ${DEFAULT_PROFILE}`, ); return DEFAULT_PROFILE; } diff --git a/apps/daemon/src/integrations/vela-team-projects.ts b/apps/daemon/src/integrations/vela-team-projects.ts new file mode 100644 index 00000000000..6696093b8d2 --- /dev/null +++ b/apps/daemon/src/integrations/vela-team-projects.ts @@ -0,0 +1,71 @@ +import type { ProjectSyncState } from '@open-design/contracts'; +import type { ResourceHubPrincipal } from '../collab/resource-principal.js'; + +export type VelaTeamProjectSyncState = + | 'pending_upload' + | 'syncing' + | 'synced' + | 'failed'; + +export interface VelaTeamProjectRecord { + id: string; + workspaceId: string; + projectId: string; + resourceId: string; + ownerMemberId: string; + displayName: string | null; + syncState: VelaTeamProjectSyncState; + lastSyncedVersionId: string | null; + createdAt: string; + updatedAt: string; + access: { + canView: boolean; + canComment: boolean; + canEdit: boolean; + frozen: boolean; + }; +} + +export interface UpsertVelaTeamProjectInput { + projectId: string; + resourceId: string; + displayName?: string | null; + syncState?: VelaTeamProjectSyncState; + lastSyncedVersionId?: string | null; +} + +export interface VelaTeamProjectCatalogClient { + list(principal: ResourceHubPrincipal): Promise<VelaTeamProjectRecord[]>; + upsert( + input: UpsertVelaTeamProjectInput, + principal: ResourceHubPrincipal, + ): Promise<VelaTeamProjectRecord | null>; +} + +export function projectResourceIdFor( + projectId: string, + principal?: ResourceHubPrincipal | null, +): string { + if (!principal) return `project-${projectId}`; + const scoped = Buffer.from( + JSON.stringify([principal.teamId, principal.memberId, projectId]), + 'utf8', + ).toString('base64url'); + return `project-${scoped}`; +} + +export function projectSyncStateToVela( + state: ProjectSyncState, +): VelaTeamProjectSyncState { + if (state === 'synced') return 'synced'; + if (state === 'sync_failed') return 'failed'; + return 'pending_upload'; +} + +export function velaProjectSyncStateToProject( + state: VelaTeamProjectSyncState, +): ProjectSyncState { + if (state === 'synced') return 'synced'; + if (state === 'failed') return 'sync_failed'; + return 'pending_upload'; +} diff --git a/apps/daemon/src/integrations/vela-wallet.ts b/apps/daemon/src/integrations/vela-wallet.ts index 427830afd51..e1bec2e5655 100644 --- a/apps/daemon/src/integrations/vela-wallet.ts +++ b/apps/daemon/src/integrations/vela-wallet.ts @@ -37,6 +37,10 @@ interface VelaWalletBalanceResponse { updatedAt?: unknown; } +function isValidUsdBalance(value: unknown): value is string { + return typeof value === 'string' && /^\d+(?:\.\d+)?$/.test(value); +} + function publicUser(user: VelaUser | null): AmrWalletSnapshot['user'] { if (!user) return null; return { @@ -164,16 +168,26 @@ export function createVelaWalletSnapshotReader(options: VelaWalletReaderOptions }); } const body = (await response.json()) as VelaWalletBalanceResponse; - const balanceUsd = typeof body.balanceUsd === 'string' ? body.balanceUsd : null; - if (balanceUsd === null) { + if (!isValidUsdBalance(body.balanceUsd)) { + const cached = cache.get(key); + if (cached) { + return { + ...withCacheSource(cached.snapshot, true), + error: { + code: 'upstream', + message: 'AMR wallet balance response contained an invalid balanceUsd.', + }, + }; + } return unavailableSnapshot({ code: 'upstream', fetchedAt, - message: 'AMR wallet balance response was missing balanceUsd.', + message: 'AMR wallet balance response contained an invalid balanceUsd.', profile: input.profile, user: input.user, }); } + const balanceUsd = body.balanceUsd; const snapshot: AmrWalletSnapshot = { status: 'available', profile: input.profile, diff --git a/apps/daemon/src/integrations/vela.ts b/apps/daemon/src/integrations/vela.ts index 2e5c87e4c7b..9212b82c9a8 100644 --- a/apps/daemon/src/integrations/vela.ts +++ b/apps/daemon/src/integrations/vela.ts @@ -28,6 +28,7 @@ const AMR_ENTRY_SOURCES: ReadonlySet<TrackingAmrEntrySource> = new Set([ 'inline_model_switcher_amr_row', 'settings_amr_agent_card', 'settings_amr_authorize', + 'settings_cloud_callout', 'settings_amr_console', 'settings_amr_install', 'avatar_amr_console', @@ -86,6 +87,7 @@ const AMR_ENTRY_SOURCE_PAGE_BY_SOURCE: Record< inline_model_switcher_amr_row: 'chat_panel', settings_amr_agent_card: 'settings', settings_amr_authorize: 'settings', + settings_cloud_callout: 'settings', settings_amr_console: 'settings', settings_amr_install: 'settings', avatar_amr_console: 'chat_panel', @@ -235,12 +237,36 @@ export interface VelaLoginStatus { userCode?: string; /** True when vela warned it could not open the browser automatically. */ browserOpenFailed?: boolean; + /** + * Origin of the vela web console this runtime talks to, when it was given + * one. See {@link resolveVelaConsoleOrigin} — the client needs it to build + * wallet / plans / upgrade links for a non-public AMR environment. + */ + consoleOrigin?: string; authAttemptId?: string; authStages?: VelaLoginAuthStage[]; authRoute?: AmrAuthNetworkPath; fallbackUsed?: boolean; } +/** + * The vela web console origin this runtime was configured with, normalized + * without a trailing slash, or undefined when it was given none. + * + * Non-prod AMR environments are internal deployments, so their hostnames are + * not literals in this public repository: packaging injects the origin from a + * CI secret and the packaged runtime forwards it as `OD_VELA_WEB_URL`. Reporting + * it on the login status is how the web client learns which console to link to + * without needing a hostname table of its own. Undefined for prod and fork + * builds, where the client falls back to the public product console. + */ +export function resolveVelaConsoleOrigin( + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const origin = env.OD_VELA_WEB_URL?.trim().replace(/\/+$/, '') ?? ''; + return origin.length > 0 ? origin : undefined; +} + export interface VelaLoginAuthStage { sequence: number; stage: AmrAuthStage; @@ -352,6 +378,10 @@ export function mergeVelaEnv( } function configDir(): string { + const amrHome = process.env.AMR_HOME?.trim(); + if (amrHome === '~') return homedir(); + if (amrHome?.startsWith('~/')) return path.join(homedir(), amrHome.slice(2)); + if (amrHome) return amrHome; return path.join(homedir(), '.amr'); } diff --git a/apps/daemon/src/media/models.ts b/apps/daemon/src/media/models.ts index 7fa3bc09bcb..4df1fa84fd8 100644 --- a/apps/daemon/src/media/models.ts +++ b/apps/daemon/src/media/models.ts @@ -36,7 +36,7 @@ export const MEDIA_PROVIDERS: MediaProvider[] = [ { id: 'volcengine', label: 'Volcengine Ark (Doubao)', hint: 'Seedance 2.0 / Seedream', integrated: true, defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3' }, { id: 'grok', label: 'xAI Grok Imagine', hint: 'grok-imagine — image + video with native audio', integrated: true, defaultBaseUrl: 'https://api.x.ai/v1' }, { id: 'hyperframes', label: 'HyperFrames', hint: 'Local HTML -> MP4 renderer', integrated: true, credentialsRequired: false, settingsVisible: false }, - { id: 'nanobanana', label: 'Nano Banana', hint: 'Google official by default; custom gateway configurable', integrated: true, defaultBaseUrl: 'https://generativelanguage.googleapis.com', supportsCustomModel: true }, + { id: 'nanobanana', label: 'Nano Banana', hint: 'Uses Google’s official API by default. You can also configure a custom gateway.', integrated: true, defaultBaseUrl: 'https://generativelanguage.googleapis.com', supportsCustomModel: true }, { id: 'imagerouter', label: 'ImageRouter', hint: 'OpenAI-compatible image + video routing', integrated: true, defaultBaseUrl: 'https://api.imagerouter.io/v1/openai', docsUrl: 'https://docs.imagerouter.io/api-reference/image-generation/', supportsCustomModel: true, customModelPlaceholder: 'openai/gpt-image-2 or xAI/grok-imagine-video' }, { id: 'openrouter', label: 'OpenRouter', hint: 'Unified gateway for image + video models', integrated: true, credentialsRequired: true, settingsVisible: true, defaultBaseUrl: 'https://openrouter.ai/api/v1', docsUrl: 'https://openrouter.ai/settings/keys' }, { id: 'custom-image', label: 'Custom Image API', hint: 'OpenAI-compatible images/generations + images/edits (local or cloud)', integrated: true, docsUrl: 'https://platform.openai.com/docs/api-reference/images', supportsCustomModel: true, customModelPlaceholder: 'my-image-model' }, diff --git a/apps/daemon/src/orbit.ts b/apps/daemon/src/orbit.ts index f197b1752a8..5a061821caf 100644 --- a/apps/daemon/src/orbit.ts +++ b/apps/daemon/src/orbit.ts @@ -65,6 +65,7 @@ export type OrbitRunHandler = (request: { prompt: string; systemPrompt: string; template: OrbitTemplateSelection | null; + workspaceScope: OrbitConfigPrefs['workspaceScope']; }) => Promise<OrbitRunHandlerStart>; type OrbitOutputLocale = 'en' | 'zh-CN' | 'zh-TW'; @@ -189,6 +190,7 @@ function normalizeOrbitConfig(config: Partial<OrbitConfigPrefs> | undefined): Or : typeof config?.templateSkillId === 'string' && config.templateSkillId.trim() ? config.templateSkillId.trim() : null, + workspaceScope: config?.workspaceScope ?? null, }; } @@ -527,6 +529,7 @@ export class OrbitService { prompt, systemPrompt, template: localizedTemplate, + workspaceScope: this.config.workspaceScope ?? null, }); this.inflightProjectId = handlerStart.projectId; diff --git a/apps/daemon/src/plugins/installer.ts b/apps/daemon/src/plugins/installer.ts index f2a0828c822..4e75c01d9f7 100644 --- a/apps/daemon/src/plugins/installer.ts +++ b/apps/daemon/src/plugins/installer.ts @@ -31,6 +31,8 @@ import { type ResolveOptions, type RegistryRoots, } from './registry.js'; +import { deleteWorkspaceResourceByResourceId } from '../db.js'; +import { resolveGithubRepositoryUrl } from '../github-install-source.js'; import type { InstalledPluginRecord, MarketplaceTrust, @@ -138,15 +140,23 @@ export async function* installPlugin( db: SqliteDb, opts: InstallOptions, ): AsyncGenerator<InstallEvent, void, void> { - if (opts.source.startsWith('github:')) { - yield* installFromGithub(db, opts); + const browserGithub = resolveGithubRepositoryUrl(opts.source); + if (browserGithub.kind === 'invalid') { + yield { kind: 'error', message: browserGithub.error, warnings: [] }; return; } - if (HTTPS_SOURCE_RE.test(opts.source)) { - yield* installFromHttpsArchive(db, opts); + const normalizedOpts = browserGithub.kind === 'repository' + ? { ...opts, source: browserGithub.source } + : opts; + if (normalizedOpts.source.startsWith('github:')) { + yield* installFromGithub(db, normalizedOpts); return; } - yield* installFromLocalFolder(db, opts); + if (HTTPS_SOURCE_RE.test(normalizedOpts.source)) { + yield* installFromHttpsArchive(db, normalizedOpts); + return; + } + yield* installFromLocalFolder(db, normalizedOpts); } // `github:owner/repo[@ref][/subpath]` → codeload tarball. @@ -798,6 +808,24 @@ export async function uninstallPlugin( return { ok: false, warning: `Plugin id '${id}' is not a safe folder name` }; } const removed = deleteInstalledPlugin(db, id); + // Clean up the workspace_resources binding row too — this table has no + // FOREIGN KEY ... ON DELETE CASCADE (a resource_id can point at any of + // several tables depending on resource_type, so SQLite cannot enforce a + // polymorphic FK), so skipping this would leave an orphan binding that + // reinstalling the same plugin id would find and silently reuse (stale + // workspace/visibility). A DELETE against a row that never existed is a + // no-op, so this is safe to call unconditionally in production (every real + // daemon db goes through db.ts's full `migrate()`, which always creates + // `workspace_resources`). Guarded here only for narrow-schema test + // doubles that run `migratePlugins(db)` in isolation (e.g. + // tests/plugins-installer.test.ts) without the rest of db.ts's schema — + // mirrors the same "table may not exist yet" tolerance server.ts's + // `collectBundledScenarios` already uses for `installed_plugins`. + try { + deleteWorkspaceResourceByResourceId(db, 'plugin', id); + } catch { + // Table not present in this db — nothing to clean up. + } const folder = path.join(roots.userPluginsRoot, id); // Defence in depth: even a SAFE_BASENAME-passing id must resolve to a direct // child of the registry root. If normalization lands anywhere else, refuse. diff --git a/apps/daemon/src/plugins/lockfile.ts b/apps/daemon/src/plugins/lockfile.ts index 296818a51cb..74ed1a58540 100644 --- a/apps/daemon/src/plugins/lockfile.ts +++ b/apps/daemon/src/plugins/lockfile.ts @@ -74,14 +74,39 @@ export async function writePluginLockfile( await writeFile(filePath, JSON.stringify(sorted, null, 2) + '\n', 'utf8'); } +// Two installs finishing close together both read-modify-write this same +// file; without serializing, whichever write lands last silently discards +// the other's entry (issue #109 — concurrent plugin installs drop one +// lockfile row even though both copies land on disk). The daemon is the +// sole writer of its own data root, so an in-memory per-path promise chain +// is enough — no OS-level file lock needed. Chains are intentionally never +// evicted: the number of distinct lockfile paths in one daemon's lifetime +// is bounded by the number of projects it manages, not installs. +const pendingWritesByPath = new Map<string, Promise<unknown>>(); + +async function withLockfileQueue<T>(filePath: string, task: () => Promise<T>): Promise<T> { + const previous = pendingWritesByPath.get(filePath) ?? Promise.resolve(); + const settled = previous.catch(() => undefined).then(task); + pendingWritesByPath.set(filePath, settled); + try { + return await settled; + } finally { + if (pendingWritesByPath.get(filePath) === settled) { + pendingWritesByPath.delete(filePath); + } + } +} + export async function upsertPluginLockfileEntry( filePath: string, plugin: InstalledPluginRecord, lockedAt = Date.now(), ): Promise<PluginLockfile> { - const lockfile = await readPluginLockfile(filePath); - const entry = lockEntryFromInstalled(plugin, lockedAt); - lockfile.plugins[entry.name] = entry; - await writePluginLockfile(filePath, lockfile); - return lockfile; + return withLockfileQueue(filePath, async () => { + const lockfile = await readPluginLockfile(filePath); + const entry = lockEntryFromInstalled(plugin, lockedAt); + lockfile.plugins[entry.name] = entry; + await writePluginLockfile(filePath, lockfile); + return lockfile; + }); } diff --git a/apps/daemon/src/plugins/registry.ts b/apps/daemon/src/plugins/registry.ts index 306a481e803..fbcd19174f6 100644 --- a/apps/daemon/src/plugins/registry.ts +++ b/apps/daemon/src/plugins/registry.ts @@ -32,6 +32,7 @@ import type { TrustTier, } from '@open-design/contracts'; import { defaultTrustForRecord, resolveCapabilitiesGranted } from './trust.js'; +import { getWorkspaceResourceByResourceId } from '../db.js'; import type Database from 'better-sqlite3'; type SqliteDb = Database.Database; @@ -230,9 +231,172 @@ export function rowToInstalledPlugin(row: DbRow): InstalledPluginRecord { }; } -export function listInstalledPlugins(db: SqliteDb): InstalledPluginRecord[] { +/** + * Is this plugin visible from `scope` (the requesting workspace)? + * + * Same one-way rule design-systems already ships (`designSystemVisibleFromWorkspace` + * in design-systems/index.ts): a plugin CLAIMED by another workspace (a + * `workspace_resources` row whose `workspace_id` differs) is hidden, and an + * UNCLAIMED plugin (no binding row at all) stays visible everywhere. Every + * plugin installed before workspace isolation shipped looks unclaimed, so + * this never makes a pre-existing install vanish out from under an upgrading + * user — only a plugin installed AFTER this shipped, into a specific + * workspace, can be hidden from a different one. + * + * `scope === undefined` (as opposed to `null` or `''`) is a SEPARATE signal + * from "no identity": it means the caller — `listInstalledPlugins` called + * with no second argument at all — never asked for scoping in the first + * place (id resolution, the bundled-scenario scan), so nothing is filtered + * regardless of ownership. `null`/`''` means a caller DID ask to be scoped but + * has no workspace identity to offer (a signed-out client, a `curl` with no + * headers) — spec 04 §10: that must hide a CLAIMED plugin, not show it, or + * "no scope" silently becomes "trust everything". + */ +function pluginVisibleFromWorkspace(db: SqliteDb, pluginId: string, scope: string | null | undefined): boolean { + const binding = getWorkspaceResourceByResourceId(db, 'plugin', pluginId); + const ownerId = typeof binding?.workspaceId === 'string' ? binding.workspaceId.trim() : ''; + if (binding?.resourceState === 'deleted') return false; + if (scope === undefined) return true; + const scopeId = scope?.trim(); + if (!scopeId) return !ownerId; + if (!ownerId) return true; + return ownerId === scopeId; +} + +/** + * A materialized Team plugin is readable only while its exact Workspace + * binding is live. An unbound marker is still accepted for one compatibility + * read so pre-binding installs can be adopted by the daemon without vanishing + * during an upgrade; callers must persist that binding immediately. + */ +export function workspaceTeamPluginBindingAllowsRead( + db: SqliteDb, + workspaceId: string, + pluginId: string, +): boolean { + const binding = getWorkspaceResourceByResourceId( + db, + 'plugin', + workspaceTeamPluginBindingResourceId(workspaceId, pluginId), + ); + if (!binding) return true; + return binding.workspaceId === workspaceId + && binding.visibility === 'team' + && binding.resourceState !== 'deleted'; +} + +/** + * Snapshot the local binding generation before an asynchronous hub read. + * `resourceState` is intentionally part of the fence in addition to + * `updatedAt`: two synchronous SQLite writes can share the same millisecond, + * but an intervening tombstone must still invalidate an older positive read. + * `null` is the generation for a binding that does not exist yet. + */ +export function workspaceTeamPluginBindingActivationFence( + db: SqliteDb, + workspaceId: string, + pluginId: string, +): string | null { + const binding = getWorkspaceResourceByResourceId( + db, + 'plugin', + workspaceTeamPluginBindingResourceId(workspaceId, pluginId), + ); + if (!binding) return null; + return JSON.stringify([ + binding.workspaceId, + binding.visibility, + binding.resourceState ?? null, + binding.updatedAt, + binding.updatedByWorkspaceMemberId ?? null, + binding.resourceHubResourceId ?? null, + ]); +} + +const WORKSPACE_TEAM_PLUGIN_BINDING_PREFIX = 'team-mirror:'; + +/** + * Team materializations are separate from the Personal installed-plugin row. + * The generic binding table only allows one row per resource id, so a Team + * mirror must not claim the bare plugin id: Personal and Team plugins with the + * same manifest id are valid and coexist in `listWorkspacePlugins`. + */ +export function workspaceTeamPluginBindingResourceId( + workspaceId: string, + pluginId: string, +): string { + return `${WORKSPACE_TEAM_PLUGIN_BINDING_PREFIX}${encodeURIComponent(workspaceId)}:${encodeURIComponent(pluginId)}`; +} + +export function pluginIdFromWorkspaceTeamPluginBinding( + workspaceId: string, + bindingResourceId: string, +): string | null { + const prefix = `${WORKSPACE_TEAM_PLUGIN_BINDING_PREFIX}${encodeURIComponent(workspaceId)}:`; + if (!bindingResourceId.startsWith(prefix)) return null; + try { + return decodeURIComponent(bindingResourceId.slice(prefix.length)); + } catch { + return null; + } +} + +export async function resolveWorkspaceTeamPluginWithBindingGate<T>(input: { + bindingAllowsRead: () => boolean; + resolve: () => Promise<T | null>; +}): Promise<T | null> { + if (!input.bindingAllowsRead()) return null; + const resolved = await input.resolve(); + if (resolved == null || !input.bindingAllowsRead()) return null; + return resolved; +} + +export async function resolveAndActivateWorkspaceTeamPlugin<T>(input: { + resolve: () => Promise<T | null>; + captureActivationFence: () => string | null; + stillShared: () => Promise<boolean>; + activationFenceIsCurrent: (fence: string | null) => boolean; + activate: () => boolean; +}): Promise<T | null> { + const resolved = await input.resolve(); + if (resolved == null) return null; + if (!await activateWorkspaceTeamPluginIfStillShared(input)) return null; + return resolved; +} + +export async function activateWorkspaceTeamPluginIfStillShared(input: { + captureActivationFence: () => string | null; + stillShared: () => Promise<boolean>; + activationFenceIsCurrent: (fence: string | null) => boolean; + activate: () => boolean; +}): Promise<boolean> { + const activationFence = input.captureActivationFence(); + if (!await input.stillShared()) return false; + // The hub read above is asynchronous. A newer reconciliation may retire the + // binding while it is pending, so a positive result is authoritative only + // for the binding generation captured before that read. Both this final + // check and `activate` are synchronous, leaving no event-loop interleave in + // which a tombstone can be overwritten. + if (!input.activationFenceIsCurrent(activationFence)) return false; + return input.activate(); +} + +/** + * `workspaceId` is optional and defaults to the pre-workspace-isolation + * behavior (every live installed plugin, otherwise unfiltered) so every existing caller — + * `od plugin list`, inventory stats, the bundled-scenario scan in server.ts — + * keeps working unchanged, AS LONG AS THEY OMIT THE ARGUMENT ENTIRELY. A + * reconciled tombstone is terminal even for these unscoped internal callers. + * `GET /api/plugins` always passes a second argument (`headerValue(...)`, + * which returns `string | null`, never `undefined`), so it always gets the + * workspace-scoped view even when the caller has no header — see + * `pluginVisibleFromWorkspace`'s doc comment for why `undefined` and `null` + * must NOT collapse to the same "unfiltered" behavior here. + */ +export function listInstalledPlugins(db: SqliteDb, workspaceId?: string | null): InstalledPluginRecord[] { const rows = db.prepare(`SELECT * FROM installed_plugins ORDER BY title ASC`).all() as DbRow[]; - return rows.map(rowToInstalledPlugin); + const records = rows.map(rowToInstalledPlugin); + return records.filter((record) => pluginVisibleFromWorkspace(db, record.id, workspaceId)); } export function getInstalledPlugin(db: SqliteDb, id: string): InstalledPluginRecord | null { diff --git a/apps/daemon/src/project-design-token-suggestions.ts b/apps/daemon/src/project-design-token-suggestions.ts new file mode 100644 index 00000000000..711f9a55286 --- /dev/null +++ b/apps/daemon/src/project-design-token-suggestions.ts @@ -0,0 +1,331 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import type { + ProjectDesignTokenSuggestion, + ProjectDesignTokenSuggestionProp, + ProjectDesignTokenSuggestionQuery, + ProjectDesignTokenSuggestionsResponse, +} from '@open-design/contracts'; +import { readDesignSystem } from './design-systems/index.js'; + +type ProjectFileLike = { + name: string; + mime?: string; + size?: number; +}; + +type Candidate = { + token: string; + value: string; + sourceFile: string; + line: number; +}; + +export type BuildProjectDesignTokenSuggestionsOptions = { + projectId: string; + projectMetadata?: unknown; + project?: { designSystemId?: string | null } | null; + projectsRoot: string; + designSystemsRoot: string; + userDesignSystemsRoot: string; + listFiles: ( + projectsRoot: string, + projectId: string, + options?: { metadata?: unknown }, + ) => Promise<ProjectFileLike[]>; + resolveProjectDir: ( + projectsRoot: string, + projectId: string, + metadata?: unknown, + ) => string; + query: ProjectDesignTokenSuggestionQuery; +}; + +const DEFAULT_PROPS: ProjectDesignTokenSuggestionProp[] = [ + 'color', + 'backgroundColor', + 'borderColor', + 'fontFamily', + 'fontSize', + 'fontWeight', + 'lineHeight', + 'letterSpacing', + 'width', + 'height', + 'gap', + 'padding', + 'margin', + 'borderRadius', + 'borderWidth', +]; + +const MAX_TEXT_FILE_BYTES = 512 * 1024; +const MAX_CANDIDATES_PER_FILE = 400; +const MAX_SUGGESTIONS = 80; + +export async function buildProjectDesignTokenSuggestions( + options: BuildProjectDesignTokenSuggestionsOptions, +): Promise<ProjectDesignTokenSuggestionsResponse> { + const props = normalizeProps(options.query.props); + const values = normalizeQueryValues(options.query.values ?? {}); + const candidates = await collectProjectTokenCandidates(options); + const designSystemId = typeof options.project?.designSystemId === 'string' + ? options.project.designSystemId + : null; + if (designSystemId) { + const body = await readDesignSystem(options.designSystemsRoot, designSystemId) + ?? await readDesignSystem(options.userDesignSystemsRoot, designSystemId) + ?? await readDesignSystem(options.userDesignSystemsRoot, designSystemId, { idPrefix: 'user:' }); + if (body) { + candidates.push(...extractTokenCandidates(body, `design-system:${designSystemId}/DESIGN.md`)); + } + } + + const suggestions = rankTokenCandidates(candidates, props, values); + return { + projectId: options.projectId, + query: { + ...options.query, + props, + values, + }, + suggestions: suggestions.slice(0, MAX_SUGGESTIONS), + }; +} + +async function collectProjectTokenCandidates(options: BuildProjectDesignTokenSuggestionsOptions): Promise<Candidate[]> { + const files = await options.listFiles(options.projectsRoot, options.projectId, { + metadata: options.projectMetadata, + }); + const root = options.resolveProjectDir(options.projectsRoot, options.projectId, options.projectMetadata); + const candidates: Candidate[] = []; + for (const file of files) { + if (!isTokenSearchTextFile(file)) continue; + if (typeof file.size === 'number' && file.size > MAX_TEXT_FILE_BYTES) continue; + let content = ''; + try { + content = await readFile(path.join(root, file.name), 'utf8'); + } catch { + continue; + } + candidates.push(...extractTokenCandidates(content, file.name).slice(0, MAX_CANDIDATES_PER_FILE)); + } + return candidates; +} + +function isTokenSearchTextFile(file: ProjectFileLike): boolean { + const name = file.name.toLowerCase(); + if (/\.(css|scss|sass|less|html?|tsx?|jsx?|json|md|mdx)$/u.test(name)) return true; + const mime = file.mime ?? ''; + return /^text\//iu.test(mime) || /^application\/(?:json|javascript|typescript)\b/iu.test(mime); +} + +export function extractTokenCandidates(content: string, sourceFile: string): Candidate[] { + const out: Candidate[] = []; + const lines = content.split('\n'); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? ''; + const lineNo = index + 1; + pushCssCustomProperties(out, line, sourceFile, lineNo); + pushJsonTokens(out, line, sourceFile, lineNo); + pushNamedCssDeclarations(out, line, sourceFile, lineNo); + pushMarkdownTokens(out, line, sourceFile, lineNo); + } + return dedupeCandidates(out); +} + +function pushCssCustomProperties(out: Candidate[], line: string, sourceFile: string, lineNo: number) { + const re = /(--[A-Za-z0-9_-]+)\s*:\s*([^;}{]+)/gu; + for (const match of line.matchAll(re)) { + addCandidate(out, match[1], match[2], sourceFile, lineNo); + } +} + +function pushJsonTokens(out: Candidate[], line: string, sourceFile: string, lineNo: number) { + const re = /"([A-Za-z0-9_.-]*(?:color|font|size|space|spacing|gap|radius|border|shadow|weight|lineHeight|letterSpacing)[A-Za-z0-9_.-]*)"\s*:\s*"([^"]+)"/giu; + for (const match of line.matchAll(re)) { + addCandidate(out, match[1], match[2], sourceFile, lineNo); + } +} + +function pushNamedCssDeclarations(out: Candidate[], line: string, sourceFile: string, lineNo: number) { + const re = /\b(color|background(?:-color)?|border(?:-[a-z]+)?|font(?:-size|-weight|-family)?|line-height|letter-spacing|gap|padding(?:-[a-z]+)?|margin(?:-[a-z]+)?|border-radius)\s*:\s*([^;}{]+)/giu; + for (const match of line.matchAll(re)) { + addCandidate(out, match[1], match[2], sourceFile, lineNo); + } +} + +function pushMarkdownTokens(out: Candidate[], line: string, sourceFile: string, lineNo: number) { + const re = /`?([A-Za-z0-9_.-]*(?:color|font|size|space|spacing|gap|radius|border|weight)[A-Za-z0-9_.-]*)`?\s*(?:=|:|->|→)\s*`?((?:#[0-9a-f]{3,8})|(?:-?\d+(?:\.\d+)?(?:px|rem|em|%)?)|(?:[A-Za-z][A-Za-z0-9 ,'"-]+))`?/giu; + for (const match of line.matchAll(re)) { + addCandidate(out, match[1], match[2], sourceFile, lineNo); + } +} + +function addCandidate(out: Candidate[], token: string | undefined, value: string | undefined, sourceFile: string, line: number) { + const cleanToken = (token ?? '').trim(); + const cleanValue = normalizeTokenValue(value ?? ''); + if (!cleanToken || !cleanValue) return; + if (!looksUsefulTokenValue(cleanValue)) return; + out.push({ token: cleanToken, value: cleanValue, sourceFile, line }); +} + +function normalizeTokenValue(value: string): string { + return value + .replace(/!important\b/giu, '') + .replace(/[,;]+$/gu, '') + .trim(); +} + +function looksUsefulTokenValue(value: string): boolean { + return /#[0-9a-f]{3,8}\b/iu.test(value) + || /\b(?:rgb|rgba|hsl|hsla|oklch|color-mix)\(/iu.test(value) + || /\b-?\d+(?:\.\d+)?(?:px|rem|em|%)?\b/u.test(value) + || /\b(?:Inter|Roboto|Arial|Helvetica|Georgia|serif|sans-serif|monospace)\b/iu.test(value) + || /var\(--[A-Za-z0-9_-]+\)/u.test(value); +} + +function dedupeCandidates(candidates: Candidate[]): Candidate[] { + const seen = new Set<string>(); + const out: Candidate[] = []; + for (const candidate of candidates) { + const key = `${candidate.token}\0${candidate.value}\0${candidate.sourceFile}\0${candidate.line}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(candidate); + } + return out; +} + +function normalizeProps(props: ProjectDesignTokenSuggestionProp[] | undefined): ProjectDesignTokenSuggestionProp[] { + if (!Array.isArray(props) || props.length === 0) return DEFAULT_PROPS; + const allowed = new Set(DEFAULT_PROPS); + const next = props.filter((prop): prop is ProjectDesignTokenSuggestionProp => allowed.has(prop)); + return next.length > 0 ? Array.from(new Set(next)) : DEFAULT_PROPS; +} + +function normalizeQueryValues( + values: Partial<Record<ProjectDesignTokenSuggestionProp, string>>, +): Partial<Record<ProjectDesignTokenSuggestionProp, string>> { + const normalized: Partial<Record<ProjectDesignTokenSuggestionProp, string>> = {}; + for (const prop of DEFAULT_PROPS) { + const value = values[prop]?.trim(); + if (value) normalized[prop] = value; + } + return normalized; +} + +function rankTokenCandidates( + candidates: Candidate[], + props: ProjectDesignTokenSuggestionProp[], + values: Partial<Record<ProjectDesignTokenSuggestionProp, string>>, +): ProjectDesignTokenSuggestion[] { + const suggestions: ProjectDesignTokenSuggestion[] = []; + for (const prop of props) { + for (const candidate of candidates) { + const score = scoreCandidate(prop, values[prop] ?? '', candidate); + if (score <= 0) continue; + suggestions.push({ + prop, + token: candidate.token, + value: candidate.value, + sourceFile: candidate.sourceFile, + line: candidate.line, + matchReason: matchReason(prop, values[prop] ?? '', candidate, score), + score, + }); + } + } + return dedupeSuggestions(suggestions) + .sort((a, b) => b.score - a.score || a.sourceFile.localeCompare(b.sourceFile) || a.line - b.line); +} + +function scoreCandidate(prop: ProjectDesignTokenSuggestionProp, queryValue: string, candidate: Candidate): number { + const token = candidate.token.toLowerCase(); + const value = candidate.value.toLowerCase(); + let score = propNameScore(prop, token); + if (score === 0 && !queryValue) return 0; + const normalizedQuery = normalizeComparableValue(queryValue); + const normalizedCandidate = normalizeComparableValue(value); + if (normalizedQuery && normalizedCandidate) { + if (normalizedQuery === normalizedCandidate) score += 120; + else { + const qn = numericValue(normalizedQuery); + const cn = numericValue(normalizedCandidate); + if (qn !== null && cn !== null) { + const delta = Math.abs(qn - cn); + if (delta <= 1) score += 90; + else if (delta <= 4) score += 60; + else if (delta <= 8) score += 30; + } + } + } + if (value.includes('var(--')) score += 12; + if (/design-system:/u.test(candidate.sourceFile)) score += 10; + return score; +} + +function propNameScore(prop: ProjectDesignTokenSuggestionProp, token: string): number { + const groups: Record<ProjectDesignTokenSuggestionProp, RegExp> = { + color: /color|foreground|text|fg|ink/u, + backgroundColor: /background|surface|bg|fill|panel/u, + borderColor: /border|stroke|outline/u, + fontFamily: /font|family|typeface/u, + fontSize: /font.*size|text.*size|type.*size|size/u, + fontWeight: /weight|bold|regular|medium/u, + lineHeight: /line.*height|leading/u, + letterSpacing: /letter.*spacing|tracking/u, + width: /width|size|measure/u, + height: /height|size|measure/u, + gap: /gap|space|spacing/u, + padding: /padding|space|spacing/u, + margin: /margin|space|spacing/u, + borderRadius: /radius|rounded/u, + borderWidth: /border.*width|stroke.*width/u, + }; + return groups[prop].test(token) ? 60 : 0; +} + +function normalizeComparableValue(value: string): string { + const clean = value.trim().toLowerCase(); + const hex = clean.match(/#[0-9a-f]{3,8}\b/u)?.[0]; + if (hex) return expandShortHex(hex); + const num = clean.match(/-?\d+(?:\.\d+)?(?:px|rem|em|%)?/u)?.[0]; + if (num) return num.endsWith('px') ? num : num; + return clean.replace(/\s+/gu, ' '); +} + +function expandShortHex(value: string): string { + if (/^#[0-9a-f]{3}$/iu.test(value)) { + return `#${value[1]}${value[1]}${value[2]}${value[2]}${value[3]}${value[3]}`.toLowerCase(); + } + return value.toLowerCase(); +} + +function numericValue(value: string): number | null { + const match = value.match(/^-?\d+(?:\.\d+)?/u); + if (!match) return null; + const n = Number(match[0]); + return Number.isFinite(n) ? n : null; +} + +function matchReason(prop: ProjectDesignTokenSuggestionProp, queryValue: string, candidate: Candidate, score: number): string { + const normalizedQuery = normalizeComparableValue(queryValue); + const normalizedCandidate = normalizeComparableValue(candidate.value); + if (normalizedQuery && normalizedCandidate && normalizedQuery === normalizedCandidate) { + return `Exact ${prop} value match`; + } + if (score >= 100) return `Close ${prop} value match`; + if (propNameScore(prop, candidate.token.toLowerCase()) > 0) return `Token name matches ${prop}`; + return `Similar ${prop} value`; +} + +function dedupeSuggestions(suggestions: ProjectDesignTokenSuggestion[]): ProjectDesignTokenSuggestion[] { + const best = new Map<string, ProjectDesignTokenSuggestion>(); + for (const suggestion of suggestions) { + const key = `${suggestion.prop}\0${suggestion.token}\0${suggestion.value}\0${suggestion.sourceFile}\0${suggestion.line}`; + const current = best.get(key); + if (!current || suggestion.score > current.score) best.set(key, suggestion); + } + return Array.from(best.values()); +} diff --git a/apps/daemon/src/projects.ts b/apps/daemon/src/projects.ts index 4d377d72eaf..aaaa30b13fa 100644 --- a/apps/daemon/src/projects.ts +++ b/apps/daemon/src/projects.ts @@ -1341,6 +1341,49 @@ export async function removeProjectDir(projectsRoot, projectId) { await rm(dir, { recursive: true, force: true }); } +export async function stageProjectDirsForDelete(projectsRoot, projectIds, batchId) { + const uniqueProjectIds = Array.from(new Set(projectIds)); + const stagingRoot = path.join(projectsRoot, '.delete-staging', batchId); + const staged = []; + await mkdir(stagingRoot, { recursive: true }); + try { + for (const projectId of uniqueProjectIds) { + const source = projectDir(projectsRoot, projectId); + const target = path.join(stagingRoot, projectId); + try { + await rename(source, target); + staged.push({ projectId, source, target }); + } catch (error) { + if (error?.code === 'ENOENT') continue; + throw error; + } + } + } catch (error) { + await Promise.allSettled( + staged + .slice() + .reverse() + .map((entry) => rename(entry.target, entry.source)), + ); + await rm(stagingRoot, { recursive: true, force: true }).catch(() => {}); + throw error; + } + return { + async rollback() { + await Promise.allSettled( + staged + .slice() + .reverse() + .map((entry) => rename(entry.target, entry.source)), + ); + await rm(stagingRoot, { recursive: true, force: true }).catch(() => {}); + }, + async commit() { + await rm(stagingRoot, { recursive: true, force: true }); + }, + }; +} + function resolveSafe(dir, name) { const safePath = validateProjectPath(name); const target = path.resolve(dir, safePath); diff --git a/apps/daemon/src/prompts/system.ts b/apps/daemon/src/prompts/system.ts index 3478d6b246b..4f753bd67bc 100644 --- a/apps/daemon/src/prompts/system.ts +++ b/apps/daemon/src/prompts/system.ts @@ -260,7 +260,7 @@ export function resolveExclusiveSurface(args: { // means the agent hand-rolls deck scaffolding — so every borderline term // stays in. const DECK_INTENT_SIGNAL = - /\b(slides?|deck|keynote|presentation|pitch\s?deck|ppt(x)?|slideshow|carousel)\b|幻灯|简报|讲稿|演示|路演|汇报|宣讲|课件|讲解|演讲|提案/i; + /\b(slides?|deck|keynote|presentation|pitch\s?deck|(?:seed|pre[-\s]?seed|investor|fundraising|startup)\s+pitch|ppt(x)?|slideshow|carousel)\b|幻灯|简报|讲稿|演示|路演|汇报|宣讲|课件|讲解|演讲|提案/i; /** * Whether the outgoing user request reads as a slide-deck brief. Gates the diff --git a/apps/daemon/src/resource-cli.ts b/apps/daemon/src/resource-cli.ts new file mode 100644 index 00000000000..8eec5d424f7 --- /dev/null +++ b/apps/daemon/src/resource-cli.ts @@ -0,0 +1,19 @@ +import { runVelaCommand } from './integrations/vela-command.js'; + +/** + * `od resource` is a compatibility entry point for the login-backed Vela + * resource drive. Open Design intentionally owns no Resource Hub credentials + * or content-addressed transfer implementation. + */ +export async function runResource(args: string[]): Promise<void> { + try { + const stdout = await runVelaCommand([ + 'resource', + ...(args.length > 0 ? args : ['--help']), + ]); + if (stdout) process.stdout.write(stdout); + } catch (error) { + console.error(error instanceof Error ? error.message : 'vela resource command failed'); + process.exitCode = 1; + } +} diff --git a/apps/daemon/src/routes/chat.ts b/apps/daemon/src/routes/chat.ts index 356ec38dbb9..8cd4947aec1 100644 --- a/apps/daemon/src/routes/chat.ts +++ b/apps/daemon/src/routes/chat.ts @@ -37,6 +37,7 @@ import { resolveModelForServiceTier } from '../runtimes/models.js'; import { googleStreamGenerateContentUrl } from '../integrations/google-models.js'; import { createRoleMarkerGuard } from '../role-marker-guard.js'; import { authorizeReasoningEgress, sendReasoningEgressDenial } from '../reasoning-egress.js'; +import type { AuthorizeProjectRequest } from '../collab/project-request-authority.js'; // Allowlist for the `/feedback` route. Mirrors the // ChatMessageFeedbackReasonCode union in packages/contracts/src/api/chat.ts. @@ -56,7 +57,9 @@ const FEEDBACK_REASON_ALLOWLIST: ReadonlySet<string> = new Set([ 'other', ]); -export interface RegisterChatRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'chat' | 'agents' | 'critique' | 'validation' | 'lifecycle' | 'paths' | 'telemetry' | 'appConfig'> {} +export interface RegisterChatRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'chat' | 'agents' | 'critique' | 'validation' | 'lifecycle' | 'paths' | 'telemetry' | 'appConfig'> { + authorizeProjectRequest: AuthorizeProjectRequest; +} export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { const { db, design } = ctx; @@ -106,20 +109,40 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { app.post('/api/runs/:id/feedback', async (req, res) => { const runId = req.params.id; const body = (req.body ?? {}) as Partial<{ - projectId: string; - conversationId: string; - assistantMessageId: string; rating: 'positive' | 'negative'; reasonCodes: string[]; hasCustomReason: boolean; customReason: string; - }>; + }> & Record<string, unknown>; if (!runId) { return sendApiError(res, 400, 'INVALID_RUN_ID', 'runId missing'); } + const callerOwnedContextFields = [ + 'projectId', + 'conversationId', + 'assistantMessageId', + ].filter((field) => Object.prototype.hasOwnProperty.call(body, field)); + if (callerOwnedContextFields.length > 0) { + return sendApiError( + res, + 400, + 'INVALID_FEEDBACK_CONTEXT', + 'feedback project, conversation, and message identity are derived from the run', + ); + } if (body.rating !== 'positive' && body.rating !== 'negative') { return sendApiError(res, 400, 'INVALID_RATING', 'rating must be positive or negative'); } + const run = design.runs.get(runId); + if (!run || typeof run.projectId !== 'string' || !run.projectId) { + return sendApiError(res, 404, 'NOT_FOUND', 'run not found'); + } + if (!await ctx.authorizeProjectRequest( + req, + res, + run.projectId, + { mode: 'write', capability: 'writeFiles' }, + )) return; // Drop anything outside the contract-side reason allowlist and // deduplicate; otherwise a malformed or replayed client payload could // create unknown Langfuse categories or duplicate score ids in the @@ -141,11 +164,13 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { return; } // Build score metadata bag that lands in the Langfuse score body. - // Mirrors the PostHog event so analysts can cross-reference. + // Mirrors the PostHog event so analysts can cross-reference. Every + // identity field comes from the daemon-owned run object; request bodies + // cannot retarget a score to another project/conversation/message. const scoreMetadata: Record<string, unknown> = { - projectId: body.projectId, - conversationId: body.conversationId, - assistantMessageId: body.assistantMessageId, + projectId: run.projectId, + conversationId: run.conversationId ?? null, + assistantMessageId: run.assistantMessageId ?? null, hasCustomReason: body.hasCustomReason === true, customReason, }; @@ -399,9 +424,19 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { // POST /api/projects/:projectId/critique/:runId/interrupt // Cascades an AbortController to the in-flight orchestrator for the given run. + const critiqueInterruptHandler = + handleCritiqueInterrupt(db, critiqueRunRegistry); app.post( '/api/projects/:projectId/critique/:runId/interrupt', - handleCritiqueInterrupt(db, critiqueRunRegistry), + async (req, res) => { + if (!await ctx.authorizeProjectRequest( + req, + res, + req.params.projectId, + { mode: 'write', capability: 'writeFiles' }, + )) return; + critiqueInterruptHandler(req, res); + }, ); // GET /api/projects/:projectId/critique/:runId/artifact @@ -412,12 +447,21 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { // // Response cap is threaded from cfg.parserMaxBlockBytes so a row that // the orchestrator + writer accepted is always retrievable. + const critiqueArtifactHandler = handleCritiqueArtifact(db, { + artifactsRoot: critiqueArtifactsRoot, + responseCapBytes: critiqueResponseCapBytes, + }); app.get( '/api/projects/:projectId/critique/:runId/artifact', - handleCritiqueArtifact(db, { - artifactsRoot: critiqueArtifactsRoot, - responseCapBytes: critiqueResponseCapBytes, - }), + async (req, res) => { + if (!await ctx.authorizeProjectRequest( + req, + res, + req.params.projectId, + { mode: 'read', allowNavigationQuery: true }, + )) return; + await critiqueArtifactHandler(req, res); + }, ); // ---- API Proxy (SSE) for API-compatible endpoints ------------------------ @@ -1513,6 +1557,21 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { 'projectId is required and must be a safe identifier', ); } + // The provider completion may immediately request a media tool that writes + // into this project. Authorize the whole loop before URL resolution, + // upstream egress, credential seeding, or tool execution so a read-only + // Team member cannot spend a BYOK key or mutate the creator's files. + // + // The shared gate preserves the signed-out/local compatibility contract: + // a project with no persisted Workspace binding is accepted without + // consulting cloud authority. Only a bound project must prove the exact + // creator-capable Workspace identity. + if (!await ctx.authorizeProjectRequest( + req, + res, + projectId, + { mode: 'write', capability: 'writeFiles' }, + )) return; const effectiveBaseUrl = baseUrl || opts.defaultBaseUrl; const validated = await validateExternalApiBaseUrl(effectiveBaseUrl); diff --git a/apps/daemon/src/routes/collab-context.ts b/apps/daemon/src/routes/collab-context.ts new file mode 100644 index 00000000000..62632945534 --- /dev/null +++ b/apps/daemon/src/routes/collab-context.ts @@ -0,0 +1,847 @@ +import type { Express, Response } from 'express'; +import type { + CollabCloudMemberDirectoryEntry, + CollabCloudMembersResponse, + TeamProject, + WorkspaceBillingCatalog, + WorkspaceBillingCatalogResponse, + WorkspaceBillingCheckoutResponse, + WorkspaceBillingInterestRequest, + WorkspaceBillingInterestResponse, + WorkspaceBillingSnapshot, + WorkspaceTeamBillingPlanId, + WorkspaceBillingResponse, + WorkspaceBillingSummary, + WorkspaceWalletBalance, + WorkspaceDirectoryItem, + WorkspaceDirectoryResponse, + WorkspaceCollabContext, + WorkspaceContextResponse, + WorkspaceActiveResponse, + WorkspaceInviteCreateResponse, + WorkspaceInviteCreateResult, + WorkspaceInviteRole, + WorkspaceInvalidationSsePayload, + WorkspaceTeamProjectsResponse, +} from '@open-design/contracts'; +import { + parseWorkspaceCollabContext, + type WorkspaceContextProvider, +} from '../collab/workspace-context.js'; +import { createTeamProjectsLister } from '../collab/team-projects.js'; +import { + consumeInviteContinuation, + type InviteContinueOutcome, +} from '../collab/invite-continue.js'; +import { + createWorkspaceInvite, + type CreateInviteOutcome, + type CreateWorkspaceInviteInput, +} from '../collab/invite-create.js'; +import { + fetchBillingCheckoutUrl, + fetchVelaBillingCatalog, + fetchVelaWorkspaceBillingProjection, + fetchVelaBillingSummary, + type VelaWorkspaceBillingProjection, +} from '../integrations/vela-billing.js'; +import { + listVelaWorkspaceDirectory, + workspaceContextFromDirectoryItem, + type WorkspaceDirectoryFetchResult, +} from '../collab/vela-workspace-context.js'; +import { + createWorkspaceBillingRuntimeCoordinator, + WorkspaceBillingInterestError, + type WorkspaceBillingRuntimeCoordinator, + type WorkspaceBillingRuntimeResult, +} from '../collab/workspace-billing-runtime.js'; +import { + verifyWorkspaceRequestContext, + type VerifiedWorkspaceRequestContextResult, +} from '../collab/request-workspace-context.js'; +import { requestWithWorkspaceNavigationScope } from '../collab/workspace-resource-mutation.js'; + +export type WorkspaceEventSink = (payload: WorkspaceInvalidationSsePayload) => void; +export type WorkspaceEventSinksByWorkspace = + Map<string, Set<WorkspaceEventSink>>; + +/** + * Deliver one thin invalidation only to clients whose EventSource connection + * was freshly verified for the affected Workspace. Member identity is still + * verified at subscription time; delivery is workspace-wide because roster, + * catalog, context, and team billing changes legitimately invalidate every + * active member's view of that Workspace. + */ +export function emitWorkspaceEventToScope( + sinksByWorkspace: WorkspaceEventSinksByWorkspace, + workspaceIdInput: string, + payload: WorkspaceInvalidationSsePayload, +): boolean { + const workspaceId = workspaceIdInput.trim(); + if (!workspaceId) return false; + const sinks = sinksByWorkspace.get(workspaceId); + if (!sinks || sinks.size === 0) return false; + for (const sink of Array.from(sinks)) { + try { + sink(payload); + } catch { + sinks.delete(sink); + } + } + if (sinks.size === 0) sinksByWorkspace.delete(workspaceId); + return true; +} + +export interface RegisterCollabContextRoutesDeps { + workspaceContext: WorkspaceContextProvider; + /** Injectable for tests; defaults to consuming against B with the vela session. */ + consumeInvite?: (nonce: string) => Promise<InviteContinueOutcome>; + /** Injectable for tests; defaults to creating invites on B with the vela session. */ + createInvite?: (input: CreateWorkspaceInviteInput) => Promise<CreateInviteOutcome>; + /** Injectable for tests; defaults to the vela billing CLI 收口. */ + fetchBilling?: () => Promise<WorkspaceBillingSummary | null>; + /** Injectable for tests; returns one backend-proven v2 workspace wallet. */ + fetchWorkspaceBalance?: (workspaceId: string) => Promise<WorkspaceWalletBalance | null>; + /** Injectable for tests; returns the additive atomic plan+wallet projection. */ + fetchWorkspaceBillingProjection?: ( + workspaceId: string, + ) => Promise<VelaWorkspaceBillingProjection>; + /** Daemon-owned exact-scope billing state. Shared with upstream SSE hooks. */ + billingRuntime?: WorkspaceBillingRuntimeCoordinator; + /** Injectable for tests; defaults to the vela billing catalog CLI 收口. */ + fetchBillingCatalog?: (workspaceId: string) => Promise<WorkspaceBillingCatalog | null>; + /** Injectable for tests; defaults to the vela billing checkout CLI 收口. */ + startCheckout?: (input: { + workspaceId?: string; + planId?: WorkspaceTeamBillingPlanId; + seats?: number; + }) => Promise<string | null>; + /** Injectable for tests; defaults to the resource-hub team-project lister + * built from the same workspace context + env-configured hub client the share + * path uses. */ + listTeamProjects?: (context: WorkspaceCollabContext) => Promise<TeamProject[]>; + /** + * The team's collab-cloud member directory (memberId → {displayName, role}), + * so the web client can resolve comment authors + the shared-project owner to + * a name + role. Empty off-team / when the collab cloud is unconfigured. STUB: + * B's roster is the real source; the collab-cloud directory stands in for it. + */ + listMembers?: ( + context: WorkspaceCollabContext, + ) => Promise<CollabCloudMemberDirectoryEntry[]>; + /** + * Legacy local selection store retained for compatibility wiring. Data-plane + * routes do not read or mutate it; each tab carries its exact Workspace and + * member identity on the request. + */ + activeWorkspace?: { + get(): string | null; + set(workspaceId: string): Promise<void>; + clear(): Promise<void>; + }; + /** + * Announce that one tab selected `workspaceId`, after a fresh membership + * directory read confirms the exact Workspace/member pair. + * + * Caches are keyed by explicit scope, so this only warms the selected scope; + * it never changes or checks daemon-global active/current state. It is + * deliberately fire-and-forget: the response must not wait on warming. + */ + onWorkspaceSwitched?: (workspaceId: string) => void; + /** Injectable for tests; defaults to the Vela workspace directory API. */ + listWorkspaceDirectory?: () => Promise<WorkspaceDirectoryItem[]>; + /** + * Directory read with an authoritative-success bit. An unavailable backend + * must never be collapsed into a confirmed empty membership list. + */ + fetchWorkspaceDirectory?: () => Promise<WorkspaceDirectoryFetchResult>; + /** + * Force-refresh the membership authority after an invite continuation is + * consumed. The consume mutates B before the daemon's settled directory + * lease expires; refreshing here prevents the accepted Workspace from being + * rejected by the next exact-scope request as a stale non-membership. + */ + refreshWorkspaceDirectoryAfterMutation?: () => Promise<WorkspaceDirectoryFetchResult>; + /** + * Collab realtime hop-2 — the workspace-scoped invalidation SSE seams. When + * both are provided the daemon registers `GET /api/workspace/events`; the route + * adds its per-connection sink to `workspaceEventSinks` (fed by the + * workspace-invalidation poller) and drops it on disconnect. Omitted in tests + * that do not exercise the stream — the route then 404s cleanly. + */ + createSseResponse?: (res: unknown, opts?: unknown) => { + send: (event: string, data: unknown, id?: string | number | null) => boolean; + }; + workspaceEventSinks?: WorkspaceEventSinksByWorkspace; +} + +const ASSIGNABLE_ROLES = new Set<WorkspaceInviteRole>(['admin', 'member']); + +/** + * Normalize an invite-create request body into validated { email, role } items. + * Accepts either the canonical `{ invites: [...] }` batch shape or a single + * top-level `{ email, role }`. Rows without a non-empty email are dropped; a + * missing/unknown role defaults to 'member' (never 'owner'). + */ +function parseInviteCreateItems( + body: unknown, +): Array<{ email: string; role: WorkspaceInviteRole }> { + const raw = body as { invites?: unknown; email?: unknown; role?: unknown } | null; + const source: unknown[] = Array.isArray(raw?.invites) + ? raw!.invites + : raw && typeof raw === 'object' && typeof raw.email === 'string' + ? [raw] + : []; + const items: Array<{ email: string; role: WorkspaceInviteRole }> = []; + for (const entry of source) { + if (!entry || typeof entry !== 'object') continue; + const rec = entry as { email?: unknown; role?: unknown }; + if (typeof rec.email !== 'string') continue; + const email = rec.email.trim(); + if (!email) continue; + const role: WorkspaceInviteRole = + typeof rec.role === 'string' && ASSIGNABLE_ROLES.has(rec.role as WorkspaceInviteRole) + ? (rec.role as WorkspaceInviteRole) + : 'member'; + items.push({ email, role }); + } + return items; +} + +/** + * Workspace-context route : the daemon's single B-integration seam. The + * web client fetches an explicitly selected workspace context here to decide + * whether collab runs and who the present member is (resolveCollabSession). In + * production the provider proxies B; the dev provider is settable via PUT so a + * demo/tools-dev run can exercise the full path before B is reachable. + */ +export function registerCollabContextRoutes(app: Express, deps: RegisterCollabContextRoutesDeps): void { + const { workspaceContext } = deps; + const consumeInvite = deps.consumeInvite ?? ((nonce: string) => consumeInviteContinuation(nonce)); + const createInvite = + deps.createInvite ?? ((input: CreateWorkspaceInviteInput) => createWorkspaceInvite(input)); + const fetchBilling = deps.fetchBilling ?? (() => fetchVelaBillingSummary()); + const fetchWorkspaceBillingProjection = + deps.fetchWorkspaceBillingProjection ?? + (deps.fetchWorkspaceBalance + ? async (workspaceId: string): Promise<VelaWorkspaceBillingProjection> => ({ + snapshot: null, + workspaceBalance: await deps.fetchWorkspaceBalance!(workspaceId), + }) + : (workspaceId: string) => fetchVelaWorkspaceBillingProjection(workspaceId)); + const billingRuntime = + deps.billingRuntime ?? + createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: ({ workspaceId }) => + fetchWorkspaceBillingProjection(workspaceId), + }); + const fetchBillingCatalog = + deps.fetchBillingCatalog ?? ((workspaceId: string) => fetchVelaBillingCatalog(workspaceId)); + const startCheckout = + deps.startCheckout ?? + ((input: { workspaceId?: string; planId?: WorkspaceTeamBillingPlanId; seats?: number }) => + fetchBillingCheckoutUrl(input)); + const rawTeamProjectsLister = createTeamProjectsLister({}); + const listTeamProjects = + deps.listTeamProjects ?? + ((context: WorkspaceCollabContext) => rawTeamProjectsLister(context.workspaceId)); + const listMembers = deps.listMembers ?? (async () => []); + const listWorkspaceDirectory = + deps.listWorkspaceDirectory ?? (() => listVelaWorkspaceDirectory()); + const fetchWorkspaceDirectory = + deps.fetchWorkspaceDirectory ?? + (async (): Promise<WorkspaceDirectoryFetchResult> => ({ + ok: true, + items: await listWorkspaceDirectory(), + })); + const sendWorkspaceVerificationFailure = ( + res: Response, + verified: Exclude< + VerifiedWorkspaceRequestContextResult, + { ok: true } + >, + ) => + res.status(verified.status).json({ + error: verified.code, + message: verified.message, + ...(verified.retryable ? { retryable: true } : {}), + }); + + // Desktop invite hand-off ("桌面唤起和本地恢复"): the desktop app parses the + // opendesign:// invite deeplink and POSTs the nonce here. The daemon consumes + // the one-time continuation on B with the signed-in vela session and returns + // the resolved workspace context so the client can switch into the team + // workspace. The nonce is single-use — B enforces subject match + one consume. + app.post('/api/workspace/invite/continue', async (req, res) => { + const body = req.body as { nonce?: unknown } | null; + const nonce = body && typeof body.nonce === 'string' ? body.nonce : ''; + if (!nonce.trim()) return res.status(400).json({ error: 'missing_nonce' }); + const outcome = await consumeInvite(nonce); + if (!outcome.ok) return res.status(outcome.status).json({ error: outcome.error }); + // Consuming the one-time nonce has already committed the membership on B. + // Refresh the daemon's settled authority lease before the renderer makes + // its first exact-scope read. A refresh outage must not turn a successfully + // consumed, non-repeatable continuation into an HTTP failure. + await deps.refreshWorkspaceDirectoryAfterMutation?.().catch(() => undefined); + return res.json({ context: outcome.context, workspaceMemberId: outcome.workspaceMemberId }); + }); + + // Invite CREATE (the inviter/host flow): the team switcher's "邀请同事" dialog + // POSTs one or more { email, role } pairs here. The daemon derives the current + // workspaceId from the caller's workspace context and creates each invite on B + // with the signed-in vela session. Every outcome is typed: a missing session + // 401s, a missing workspace 409s, and B's per-invite failures (including a 404 + // when B's create endpoint is absent locally) come back as `ok: false` results + // — the endpoint never crashes on the backend being unavailable. + app.post('/api/workspace/invite', async (req, res) => { + const items = parseInviteCreateItems(req.body); + if (items.length === 0) return res.status(400).json({ error: 'missing_invites' }); + + const verified = await verifyWorkspaceRequestContext({ + req, + fetchWorkspaceDirectory, + requireTeam: true, + }); + if (!verified.ok) return sendWorkspaceVerificationFailure(res, verified); + const context = verified.context; + const workspaceId = context.workspaceId; + if (!context.permissions.canInviteMembers) { + return res.status(403).json({ error: 'forbidden' }); + } + + const results: WorkspaceInviteCreateResult[] = []; + for (const item of items) { + const outcome = await createInvite({ email: item.email, role: item.role, workspaceId }); + // The vela session is workspace-wide: if it is missing for one invite it is + // missing for all, so short-circuit to a single 401 instead of N failures. + if (!outcome.ok && outcome.error === 'no_session') { + return res.status(401).json({ error: 'no_session' }); + } + results.push( + outcome.ok + ? { email: item.email, ok: true, inviteId: outcome.inviteId } + : { email: item.email, ok: false, error: outcome.error }, + ); + } + const body: WorkspaceInviteCreateResponse = { results }; + return res.json(body); + }); + + app.get('/api/workspace/context', async (req, res) => { + const authorization = req.header('authorization') ?? undefined; + const verified = await verifyWorkspaceRequestContext({ + req, + fetchWorkspaceDirectory, + }); + if (!verified.ok) return sendWorkspaceVerificationFailure(res, verified); + const enriched = await workspaceContext.resolveExact?.({ + authorization, + workspaceId: verified.context.workspaceId, + }).catch(() => null); + const context = + enriched + && enriched.workspaceId === verified.context.workspaceId + && enriched.workspaceMemberId === verified.context.workspaceMemberId + ? enriched + : verified.context; + const body: WorkspaceContextResponse = { context }; + res.json(body); + }); + + // Collab realtime hop-2: workspace-scoped invalidation SSE. Browser-owned + // EventSource cannot send custom headers, so it carries the exact + // Workspace/member pair in the navigation query. The pair is promoted to + // the normal request-header shape, freshly directory-verified, then used + // only to select the sink partition; authority-bearing role/lifecycle bits + // always come from the verified directory context. + // + // Carries thin + // `WorkspaceInvalidationSsePayload` signals (`team-projects-changed`, + // `members-changed`, `workspace-context-changed`, `billing-changed`); the web + // re-fetches the affected resource on receipt. Modeled on the project events + // SSE (`/api/projects/:id/events`): one flat sink set, dropped on disconnect + // via `res.on('close')`. No event buffer — a disconnect gap is closed by the + // client's reconnect snapshot re-fetch, not a server-side replay. + const { createSseResponse, workspaceEventSinks } = deps; + if (createSseResponse && workspaceEventSinks) { + app.get('/api/workspace/events', async (req, res) => { + const scopedRequest = requestWithWorkspaceNavigationScope(req); + if (scopedRequest === 'conflict') { + res.status(400).json({ + error: 'WORKSPACE_CONTEXT_CONFLICT', + message: 'workspace header and navigation scope must match', + }); + return; + } + const verified = await verifyWorkspaceRequestContext({ + req: scopedRequest, + fetchWorkspaceDirectory, + }); + if (!verified.ok) { + sendWorkspaceVerificationFailure(res, verified); + return; + } + const workspaceId = verified.context.workspaceId; + const sse = createSseResponse(res); + const sink: WorkspaceEventSink = (payload) => { + const type = + payload && typeof payload === 'object' && 'type' in payload + ? String((payload as { type: unknown }).type) + : 'message'; + sse.send(type, payload); + }; + let workspaceSinks = workspaceEventSinks.get(workspaceId); + if (!workspaceSinks) { + workspaceSinks = new Set(); + workspaceEventSinks.set(workspaceId, workspaceSinks); + } + workspaceSinks.add(sink); + // Handshake so the client treats the stream as live and resets its + // reconnect backoff immediately (mirrors the project stream's `ready`). + sse.send('ready', { at: Date.now() }); + const cleanup = () => { + workspaceSinks?.delete(sink); + if (workspaceSinks?.size === 0) { + workspaceEventSinks.delete(workspaceId); + } + }; + res.on('close', cleanup); + res.on('finish', cleanup); + }); + } + + app.get('/api/workspace/directory', async (req, res) => { + const directory = await fetchWorkspaceDirectory().catch( + (): WorkspaceDirectoryFetchResult => ({ ok: false, items: [] }), + ); + if (!directory.ok) { + return res.status(503).json({ + error: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + message: 'workspace membership authority is temporarily unavailable', + retryable: true, + }); + } + const items = directory.items; + const claimed = await verifyWorkspaceRequestContext({ + req, + fetchWorkspaceDirectory: async () => directory, + }); + const activeWorkspaceId = claimed.ok ? claimed.context.workspaceId : null; + const body: WorkspaceDirectoryResponse = { items, activeWorkspaceId }; + res.json(body); + }); + + app.put('/api/workspace/active', async (req, res) => { + const raw = req.body as { workspaceId?: unknown; workspaceMemberId?: unknown } | null; + const workspaceId = typeof raw?.workspaceId === 'string' ? raw.workspaceId.trim() : ''; + const workspaceMemberId = + typeof raw?.workspaceMemberId === 'string' ? raw.workspaceMemberId.trim() : ''; + if (!workspaceId) return res.status(400).json({ error: 'missing_workspace_id' }); + if (!workspaceMemberId) { + return res.status(400).json({ error: 'missing_workspace_member_id' }); + } + + const directoryResult = await fetchWorkspaceDirectory().catch( + (): WorkspaceDirectoryFetchResult => ({ ok: false, items: [] }), + ); + if (!directoryResult.ok) { + return res.status(503).json({ + error: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + message: 'workspace membership authority is temporarily unavailable', + retryable: true, + }); + } + const directory = directoryResult.items; + // A directory row only authorizes a switch while it is a LIVE membership. + // Matching on the id alone would let a listed-but-removed membership (or a + // deleted workspace) through, and this entry is also what gets synthesized + // into the response below — so an unfiltered match could describe a + // workspace the caller no longer holds. Same predicate the provider's own + // `resolvePinnedWorkspace` uses. + const selected = directory.find( + (item) => + item.workspaceId === workspaceId && + item.workspaceMemberId === workspaceMemberId && + item.memberStatus === 'active' && + item.lifecycleState !== 'deleted', + ); + if (!selected) { + return res.status(404).json({ error: 'workspace_not_visible' }); + } + + // Choosing a workspace is tab-local. The membership directory above is the + // authorization; neither this compatibility endpoint nor any data-plane + // route writes a daemon-global active Workspace. + // + // This used to PUT B's account-level active workspace first and fail the + // user's click (502) when that write did not take. That row is keyed by app + // user, so it can only ever name ONE workspace for an account whose clients + // are in different ones: every switch yanked the other clients' server-side + // scope. Workspace now travels per request, which is what makes N tabs and + // clients of one account independent. + const authorization = req.header('authorization') ?? undefined; + const context = typeof workspaceContext.resolveExact === 'function' + ? await workspaceContext.resolveExact!({ + authorization, + workspaceId, + }).catch(() => null) + : null; + if ( + context + && ( + context.workspaceId !== workspaceId + || context.workspaceMemberId !== workspaceMemberId + ) + ) { + return res.status(404).json({ error: 'workspace_no_longer_available' }); + } + const resolved = context ?? workspaceContextFromDirectoryItem(selected); + // Warm this exact workspace's cold caches before responding, but never + // await them — a slow upstream must not delay the tab-local selection. + deps.onWorkspaceSwitched?.(workspaceId); + const body: WorkspaceActiveResponse = { activeWorkspaceId: workspaceId, context: resolved }; + res.json(body); + }); + + // Team-wide shared-project discovery: the web "全部项目" view fetches every + // project any member shared to the team here (read from the resource hub), so a + // member whose own /api/projects list is empty still sees the owner's shared + // projects to pull + open. Empty off-team / hub-unconfigured; a transient hub + // error also degrades to [] so a hub outage never blanks the view with a 500. + app.get('/api/workspace/projects/team', async (req, res) => { + const verified = await verifyWorkspaceRequestContext({ + req, + fetchWorkspaceDirectory, + requireTeam: true, + }); + if (!verified.ok) { + return res.status(verified.status).json({ + error: verified.code, + message: verified.message, + ...(verified.retryable ? { retryable: true } : {}), + }); + } + let projects: TeamProject[] = []; + try { + projects = await listTeamProjects(verified.context); + } catch { + projects = []; + } + const body: WorkspaceTeamProjectsResponse = { projects }; + res.json(body); + }); + + // Member directory: the web client resolves comment authors (authorMemberId → + // "琼羽 · Owner") and the shared-project owner name from this. Read from the + // collab-cloud directory; empty off-team / hub-unconfigured, and a directory + // outage degrades to [] rather than a 500. STUB: stands in for B's roster. + app.get('/api/workspace/members', async (req, res) => { + const verified = await verifyWorkspaceRequestContext({ + req, + fetchWorkspaceDirectory, + requireTeam: true, + }); + if (!verified.ok) return sendWorkspaceVerificationFailure(res, verified); + let members: CollabCloudMemberDirectoryEntry[] = []; + try { + members = await listMembers(verified.context); + } catch { + members = []; + } + const body: CollabCloudMembersResponse = { members }; + res.json(body); + }); + + // Billing reads are explicit at the HTTP boundary: + // - scope=account is the personal/account summary; + // - scope=workspace requires a workspaceId that resolves to an active team + // membership in the directory, then reads Vela's independently scoped v2 + // wallet response. + // + // The URL is the selection source. Authorization is an independent + // membership lookup — never daemon-global active/current state — so two + // clients can address different workspaces without switching each other. + // Account metadata and workspace money remain independently nullable. + app.put('/api/workspace/billing/interests/:clientId', async (req, res) => { + const clientId = req.params.clientId?.trim() ?? ''; + const body = (req.body ?? {}) as Partial<WorkspaceBillingInterestRequest>; + const generation = typeof body.generation === 'string' ? body.generation.trim() : ''; + if ( + !clientId || + clientId.length > 160 || + !/^(?:0|[1-9]\d*)$/.test(generation) || + !Array.isArray(body.interests) + ) { + return res.status(400).json({ error: 'invalid_billing_interest' }); + } + const interests = body.interests.map((interest) => ({ + workspaceId: + typeof interest?.workspaceId === 'string' ? interest.workspaceId.trim() : '', + workspaceMemberId: + typeof interest?.workspaceMemberId === 'string' + ? interest.workspaceMemberId.trim() + : '', + })); + if (interests.some((interest) => !interest.workspaceId || !interest.workspaceMemberId)) { + return res.status(400).json({ error: 'invalid_billing_interest' }); + } + + if (interests.length > 0) { + const directoryResult = await fetchWorkspaceDirectory().catch( + (): WorkspaceDirectoryFetchResult => ({ ok: false, items: [] }), + ); + if (!directoryResult.ok) { + return res.status(503).json({ error: 'workspace_directory_unavailable' }); + } + const unauthorized = interests.filter( + (interest) => + !directoryResult.items.some( + (item) => + item.workspaceId === interest.workspaceId && + item.workspaceMemberId === interest.workspaceMemberId && + item.workspaceType === 'team' && + item.memberStatus === 'active' && + item.lifecycleState === 'active', + ), + ); + if (unauthorized.length > 0) { + // A stale renderer may still declare an old membership epoch for a + // workspace another renderer is legitimately using. Reject this + // declaration without mutating process-wide workspace state. + return res.status(403).json({ error: 'workspace_not_authorized' }); + } + } + + try { + const lease: WorkspaceBillingInterestResponse = + billingRuntime.setClientInterests({ + clientId, + clientGeneration: generation, + interests, + }); + return res.json(lease); + } catch (error) { + if (!(error instanceof WorkspaceBillingInterestError)) throw error; + return res + .status(error.code === 'interest_capacity_exceeded' ? 429 : 409) + .json({ + error: error.code, + ...(error.acceptedGeneration + ? { acceptedGeneration: error.acceptedGeneration } + : {}), + }); + } + }); + + app.delete('/api/workspace/billing/interests/:clientId', (req, res) => { + const clientId = req.params.clientId?.trim() ?? ''; + const generation = + typeof req.query.generation === 'string' ? req.query.generation.trim() : undefined; + if (!clientId) return res.status(400).json({ error: 'invalid_billing_interest' }); + try { + const released = billingRuntime.releaseClientInterests(clientId, generation); + return res.json({ ok: true, released }); + } catch (error) { + if (!(error instanceof WorkspaceBillingInterestError)) throw error; + return res.status(400).json({ error: error.code }); + } + }); + + app.get('/api/workspace/billing', async (req, res) => { + const scope = typeof req.query.scope === 'string' ? req.query.scope.trim() : ''; + const requestedWorkspaceId = + typeof req.query.workspaceId === 'string' ? req.query.workspaceId.trim() : ''; + const freshness = + typeof req.query.freshness === 'string' ? req.query.freshness.trim() : ''; + if ( + (scope !== 'account' && scope !== 'workspace') || + (scope === 'account' && requestedWorkspaceId) || + (scope === 'workspace' && !requestedWorkspaceId) || + (freshness !== '' && freshness !== 'authoritative') || + (scope !== 'workspace' && freshness !== '') + ) { + return res.status(400).json({ error: 'invalid_billing_scope' }); + } + if (scope === 'account') { + const summary = await fetchBilling(); + const body: WorkspaceBillingResponse = { summary, workspaceBalance: null }; + return res.json(body); + } + + const clientId = req.header('x-od-workspace-runtime-client-id') ?? undefined; + const clientGeneration = + req.header('x-od-workspace-runtime-generation') ?? undefined; + const directoryResult = await fetchWorkspaceDirectory().catch( + (): WorkspaceDirectoryFetchResult => ({ ok: false, items: [] }), + ); + if (!directoryResult.ok) { + billingRuntime.markWorkspaceUnavailable( + requestedWorkspaceId, + 'workspace_directory_unavailable', + ); + return res.status(503).json({ error: 'workspace_directory_unavailable' }); + } + const directory = directoryResult.items; + const membership = directory.find( + (item) => + item.workspaceId === requestedWorkspaceId && + item.workspaceType === 'team' && + item.memberStatus === 'active' && + item.lifecycleState === 'active', + ); + if (!membership) { + billingRuntime.revokeWorkspace(requestedWorkspaceId); + return res.status(403).json({ error: 'workspace_not_authorized' }); + } + billingRuntime.retainWorkspaceMember( + requestedWorkspaceId, + membership.workspaceMemberId, + ); + billingRuntime.authorizeWorkspaceMember({ + workspaceId: requestedWorkspaceId, + workspaceMemberId: membership.workspaceMemberId, + }); + let accountSummary: WorkspaceBillingSummary | null; + let runtimeResult: WorkspaceBillingRuntimeResult; + try { + [accountSummary, runtimeResult] = await Promise.all([ + fetchBilling(), + billingRuntime.read( + { + workspaceId: requestedWorkspaceId, + workspaceMemberId: membership.workspaceMemberId, + }, + { + reason: + freshness === 'authoritative' + ? 'authoritative-action-read' + : 'explicit-billing-read', + ...(freshness === 'authoritative' ? { requireFresh: true } : {}), + ...(clientId ? { clientId } : {}), + ...(clientGeneration ? { clientGeneration } : {}), + }, + ), + ]); + } catch (error) { + if (error instanceof WorkspaceBillingInterestError) { + return res.status(409).json({ + error: error.code, + ...(error.acceptedGeneration + ? { acceptedGeneration: error.acceptedGeneration } + : {}), + }); + } + if (freshness === 'authoritative') { + const code = + typeof (error as { code?: unknown })?.code === 'string' + ? (error as { code: string }).code + : 'workspace_billing_authoritative_unavailable'; + return res.status(503).json({ error: code }); + } + throw error; + } + const projection = runtimeResult.projection; + const workspaceBalance = projection.workspaceBalance; + const authorizedWorkspaceBalance = + workspaceBalance?.workspaceId === requestedWorkspaceId && + workspaceBalance.workspaceMemberId === membership.workspaceMemberId + ? workspaceBalance + : null; + const snapshot = projection.snapshot; + const authorizedWorkspaceSnapshot: WorkspaceBillingSnapshot | null = + snapshot?.workspaceId === requestedWorkspaceId && + snapshot.workspaceMemberId === membership.workspaceMemberId + ? snapshot + : null; + const authoritativeObservedAt = + freshness === 'authoritative' && + runtimeResult.state.status === 'fresh' + ? runtimeResult.state.observedAt + : null; + if (freshness === 'authoritative' && !authoritativeObservedAt) { + return res.status(503).json({ + error: 'workspace_billing_authoritative_unavailable', + }); + } + const body: WorkspaceBillingResponse = { + summary: accountSummary, + workspaceBalance: authorizedWorkspaceBalance, + ...(authorizedWorkspaceSnapshot + ? { workspaceSnapshot: authorizedWorkspaceSnapshot } + : {}), + workspaceRuntime: runtimeResult.state, + ...(authoritativeObservedAt + ? { + authoritativeWorkspaceRead: { + workspaceId: requestedWorkspaceId, + workspaceMemberId: membership.workspaceMemberId, + observedAt: authoritativeObservedAt, + }, + } + : {}), + }; + return res.json(body); + }); + + app.get('/api/workspace/billing/catalog', async (req, res) => { + const verified = await verifyWorkspaceRequestContext({ + req, + fetchWorkspaceDirectory, + requireTeam: true, + }); + if (!verified.ok) return sendWorkspaceVerificationFailure(res, verified); + const catalog = await fetchBillingCatalog(verified.context.workspaceId); + const body: WorkspaceBillingCatalogResponse = { catalog }; + return res.json(body); + }); + + // Compatibility checkout route. The current product UI opens Vela Web for + // upgrade/payment, but keeping this endpoint avoids breaking existing tests + // and lets A's CLI checkout path be exercised directly when needed. + app.post('/api/workspace/billing/checkout', async (req, res) => { + const verified = await verifyWorkspaceRequestContext({ + req, + fetchWorkspaceDirectory, + requireTeam: true, + }); + if (!verified.ok) return sendWorkspaceVerificationFailure(res, verified); + const body = (req.body ?? {}) as { planId?: unknown; seats?: unknown }; + const planId = parseTeamBillingPlanId(body.planId); + const seats = typeof body.seats === 'number' && body.seats > 0 ? Math.floor(body.seats) : undefined; + const checkoutInput: { + workspaceId?: string; + planId?: WorkspaceTeamBillingPlanId; + seats?: number; + } = { workspaceId: verified.context.workspaceId }; + if (planId) checkoutInput.planId = planId; + if (seats !== undefined) checkoutInput.seats = seats; + const checkoutUrl = await startCheckout(checkoutInput); + const response: WorkspaceBillingCheckoutResponse = { checkoutUrl }; + res.json(response); + }); + + // Dev/demo seam: override the in-memory context. A real B-backed provider does + // not expose `set`, so this 404s in production instead of spoofing identity. + app.put('/api/workspace/context', (req, res) => { + if (!workspaceContext.set) { + return res.status(404).json({ error: 'workspace context is not settable' }); + } + const body = req.body as unknown; + // `null` explicitly clears the context (sign-out / leave team). + if (body === null || (body && typeof body === 'object' && Object.keys(body).length === 0)) { + workspaceContext.set(null); + const cleared: WorkspaceContextResponse = { context: null }; + return res.json(cleared); + } + const context = parseWorkspaceCollabContext(body); + if (!context) return res.status(400).json({ error: 'invalid workspace context' }); + workspaceContext.set(context); + const response: WorkspaceContextResponse = { context }; + res.json(response); + }); +} + +function parseTeamBillingPlanId(value: unknown): WorkspaceTeamBillingPlanId | null { + return value === 'team_plus' || value === 'team_pro' || value === 'team_max' ? value : null; +} diff --git a/apps/daemon/src/routes/collab-presence.ts b/apps/daemon/src/routes/collab-presence.ts new file mode 100644 index 00000000000..b7177c6529a --- /dev/null +++ b/apps/daemon/src/routes/collab-presence.ts @@ -0,0 +1,574 @@ +import type { Express, Request, Response } from 'express'; +import type { + CollabPresenceMember, + WorkspaceCollabContext, +} from '@open-design/contracts'; +import type { CollabRuntime } from '../collab/runtime.js'; +import type { PresenceMember } from '../collab/presence-tracker.js'; +import type { + VelaCliPresenceHeartbeatInput, + VelaCliPresenceLeaveInput, +} from '../collab/vela-cli-collab-client.js'; +import type { + VerifiedWorkspaceRequestContextResult, +} from '../collab/request-workspace-context.js'; + +type PresenceActivity = Exclude<PresenceMember['activity'], undefined>; + +export interface CollabPresenceCloudClient { + heartbeatPresence( + projectId: string, + input: VelaCliPresenceHeartbeatInput, + context?: WorkspaceCollabContext | null, + ): Promise<CollabPresenceMember[]>; + listPresence( + projectId: string, + context?: WorkspaceCollabContext | null, + ): Promise<CollabPresenceMember[]>; + leavePresence( + projectId: string, + input: VelaCliPresenceLeaveInput, + context?: WorkspaceCollabContext | null, + ): Promise<CollabPresenceMember[]>; +} + +export interface RegisterCollabPresenceRoutesDeps { + collab: Pick<CollabRuntime, 'presence'>; + cloud?: CollabPresenceCloudClient | null; + isProjectShared?: ( + projectId: string, + context?: WorkspaceCollabContext | null, + ) => Promise<boolean>; + verifyWorkspaceRequest?: ( + req: Request, + projectId: string, + ) => Promise< + | VerifiedWorkspaceRequestContextResult + | WorkspaceCollabContext + | null + >; + /** + * Bounded successful authority lease for the idempotent GET surface. + * Heartbeat and leave deliberately keep using `verifyWorkspaceRequest`. + */ + verifyWorkspaceReadRequest?: RegisterCollabPresenceRoutesDeps['verifyWorkspaceRequest']; + /** Test/operations seam for the short-lived cloud list cache. */ + presenceListCacheFreshMs?: number; + /** Virtual clock seam for deterministic TTL coverage. */ + presenceListCacheNow?: () => number; + /** + * The configured cloud route authoritatively rejects projects outside the + * requested workspace, so a separate remote team-project lookup is redundant. + */ + cloudAuthorizesProjectPresence?: (projectId: string) => boolean; +} + +export interface CollabPresenceRoutesControl { + /** + * Preserve the last authorized roster but make it due for background + * refresh. Presence-change events use this path so their own relay echo + * cannot turn the next UI read into a cold, blocking Vela request. + */ + markPresenceStale(projectId: string, workspaceId?: string): void; + /** + * Drop cached list results for one project after a share-authority change. + * `workspaceId` keeps invalidation scoped when it is known. + */ + invalidatePresence(projectId: string, workspaceId?: string): void; +} + +const DEFAULT_PRESENCE_LIST_CACHE_FRESH_MS = 1_000; +const MAX_PRESENCE_LIST_CACHE_ENTRIES = 256; + +interface PresenceListCacheEntry { + projectId: string; + workspaceId: string; + value: CollabPresenceMember[] | null; + settledAt: number; + inflight: Promise<CollabPresenceMember[]> | null; +} + +function presenceListCacheKey( + projectId: string, + context: WorkspaceCollabContext | null, +): string { + const permissions = context?.permissions; + return JSON.stringify([ + projectId, + context?.workspaceId?.trim() ?? '', + context?.workspaceMemberId?.trim() ?? '', + context?.workspaceType ?? '', + context?.role ?? '', + context?.memberStatus ?? '', + context?.lifecycleState ?? '', + permissions?.canManageMembers ?? false, + permissions?.canManageBilling ?? false, + permissions?.canInviteMembers ?? false, + permissions?.canManageAutoRecharge ?? false, + permissions?.canShareProjects ?? false, + permissions?.canWriteSyncedFiles ?? false, + permissions?.canViewWorkspaceSettings ?? false, + permissions?.canManageSharedResources ?? false, + ]); +} + +function createPresenceListCache(options: { + freshMs: number; + now: () => number; +}) { + const entries = new Map<string, PresenceListCacheEntry>(); + const store = (key: string, entry: PresenceListCacheEntry) => { + entries.delete(key); + entries.set(key, entry); + while (entries.size > MAX_PRESENCE_LIST_CACHE_ENTRIES) { + const oldestKey = entries.keys().next().value; + if (oldestKey === undefined) break; + entries.delete(oldestKey); + } + }; + + const refresh = ( + key: string, + entry: PresenceListCacheEntry, + fetcher: () => Promise<CollabPresenceMember[]>, + ): Promise<CollabPresenceMember[]> => { + const request = Promise.resolve().then(fetcher); + entry.inflight = request; + request.then( + (value) => { + if (entries.get(key) !== entry) return; + entry.value = value; + entry.settledAt = options.now(); + entry.inflight = null; + }, + () => { + if (entries.get(key) !== entry) return; + // A failed cold read is not authority or presence evidence. A failed + // background refresh also drops its old value after the one caller + // that already received it, so an outage cannot display somebody as + // online indefinitely. + entries.delete(key); + }, + ); + return request; + }; + + return { + read( + projectId: string, + context: WorkspaceCollabContext | null, + fetcher: () => Promise<CollabPresenceMember[]>, + ): Promise<CollabPresenceMember[]> { + const key = presenceListCacheKey(projectId, context); + let entry = entries.get(key); + if (!entry) { + entry = { + projectId, + workspaceId: context?.workspaceId?.trim() ?? '', + value: null, + settledAt: 0, + inflight: null, + }; + store(key, entry); + return refresh(key, entry, fetcher); + } + // Bounded LRU: a daemon that opens many historical projects must not + // retain every short-lived roster forever. + store(key, entry); + if (entry.value !== null) { + const value = entry.value; + if ( + !entry.inflight + && options.now() - entry.settledAt >= options.freshMs + ) { + // Polls never wait on a fresh Vela process once this exact viewer has + // a value. Refresh in the background and keep concurrent callers on + // the same process. + void refresh(key, entry, fetcher).catch(() => undefined); + } + return Promise.resolve(value); + } + return entry.inflight ?? refresh(key, entry, fetcher); + }, + + publish( + projectId: string, + context: WorkspaceCollabContext | null, + value: CollabPresenceMember[], + ): void { + const key = presenceListCacheKey(projectId, context); + store(key, { + projectId, + workspaceId: context?.workspaceId?.trim() ?? '', + value, + settledAt: options.now(), + inflight: null, + }); + }, + + markStale(projectId: string, workspaceId?: string): void { + const exactWorkspaceId = workspaceId?.trim(); + for (const entry of entries.values()) { + if (entry.projectId !== projectId) continue; + if (exactWorkspaceId && entry.workspaceId !== exactWorkspaceId) continue; + // Keep the last authorized roster for the immediate caller. The next + // read starts one background refresh and returns without waiting on + // the Vela process. + entry.settledAt = Number.NEGATIVE_INFINITY; + } + }, + + invalidate(projectId: string, workspaceId?: string): void { + const exactWorkspaceId = workspaceId?.trim(); + for (const [key, entry] of entries) { + if (entry.projectId !== projectId) continue; + if (exactWorkspaceId && entry.workspaceId !== exactWorkspaceId) continue; + entries.delete(key); + } + }, + }; +} + +/** + * The workspace-scoped presence surface of the collab transport, as this route + * module needs it. `VelaCliCollabClient` satisfies it structurally. + */ +export interface CollabPresenceCloudTransport { + heartbeatPresence( + projectId: string, + input: VelaCliPresenceHeartbeatInput, + workspaceId: string, + ): Promise<CollabPresenceMember[]>; + listPresence( + projectId: string, + workspaceId: string, + ): Promise<CollabPresenceMember[]>; + leavePresence( + projectId: string, + input: VelaCliPresenceLeaveInput, + workspaceId: string, + ): Promise<CollabPresenceMember[]>; +} + +/** + * Bind a collab transport to a per-project workspace scope, producing the + * `cloud` dependency below — or nothing when this run has no cloud transport. + * + * The invariant: **a `cloud` dependency exists if and only if a transport + * exists.** Every endpoint below reads a present `cloud` as "the cloud owns + * presence for this run" and stops consulting the process-local tracker + * entirely. So a relay built over an absent transport is not merely useless — + * it turns the in-process fallback into dead code and every gated presence + * request into a 502. Returning `null` is what keeps that fallback reachable + * for the runs that have no transport, which is every build that has not + * opted into the vela-cli collab transport: all stable/prod packaged builds + * and every plain `tools-dev` run. + * + * Callers must route through this rather than assembling an object literal of + * arrow functions, because a literal is unconditionally truthy no matter what + * the transport turned out to be. + */ +export function createCollabPresenceCloudClient( + transport: CollabPresenceCloudTransport | null | undefined, + workspaceScopeFor: (projectId: string) => string | undefined, +): CollabPresenceCloudClient | null { + if (!transport) return null; + const exactWorkspaceId = ( + projectId: string, + context?: WorkspaceCollabContext | null, + ): string => { + const workspaceId = + context?.workspaceId?.trim() || workspaceScopeFor(projectId)?.trim() || ''; + if (!workspaceId) { + throw new Error('explicit workspace scope is required'); + } + return workspaceId; + }; + return { + heartbeatPresence: (projectId, input, context) => + transport.heartbeatPresence( + projectId, + input, + exactWorkspaceId(projectId, context), + ), + listPresence: (projectId, context) => + transport.listPresence( + projectId, + exactWorkspaceId(projectId, context), + ), + leavePresence: (projectId, input, context) => + transport.leavePresence( + projectId, + input, + exactWorkspaceId(projectId, context), + ), + }; +} + +function readHeartbeat(body: unknown): { + member: PresenceMember; + clientId?: string; + filePath?: string | null; + activity?: PresenceMember['activity']; +} | null { + const raw = (body ?? {}) as Record<string, unknown>; + const memberId = typeof raw.memberId === 'string' ? raw.memberId.trim() : ''; + if (!memberId) return null; + const member: PresenceMember = { memberId }; + if (typeof raw.name === 'string' && raw.name.trim()) member.name = raw.name.trim(); + if (raw.role === 'owner' || raw.role === 'admin' || raw.role === 'member') member.role = raw.role; + if (typeof raw.avatarUrl === 'string' || raw.avatarUrl === null) member.avatarUrl = raw.avatarUrl; + if (typeof raw.filePath === 'string' || raw.filePath === null) member.filePath = raw.filePath; + if (raw.activity !== undefined) member.activity = raw.activity as PresenceActivity; + const clientId = typeof raw.clientId === 'string' && raw.clientId.trim() + ? raw.clientId.trim() + : memberId; + const filePath = typeof raw.filePath === 'string' || raw.filePath === null + ? raw.filePath + : undefined; + return { + member, + clientId, + ...(filePath !== undefined ? { filePath } : {}), + ...(member.activity !== undefined ? { activity: member.activity } : {}), + }; +} + +function readLeave(body: unknown): { memberId: string; clientId?: string } | null { + const raw = (body ?? {}) as Record<string, unknown>; + const memberId = typeof raw.memberId === 'string' ? raw.memberId.trim() : ''; + if (!memberId) return null; + const clientId = typeof raw.clientId === 'string' && raw.clientId.trim() + ? raw.clientId.trim() + : memberId; + return { memberId, clientId }; +} + +function cloudError(res: Response, error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return res.status(502).json({ error: 'collab_presence_unavailable', message }); +} + +type PresenceWorkspaceVerification = + | { ok: true; context: WorkspaceCollabContext | null } + | Exclude<VerifiedWorkspaceRequestContextResult, { ok: true }>; + +function normalizeWorkspaceVerification( + value: + | VerifiedWorkspaceRequestContextResult + | WorkspaceCollabContext + | null, +): PresenceWorkspaceVerification { + if (value && 'ok' in value) return value; + if (value) return { ok: true, context: value }; + // Legacy injected test adapters used null as a route-specific denial. + // Production supplies the structured verifier result above. + return { ok: true, context: null }; +} + +function sendWorkspaceVerificationFailure( + res: Response, + verification: Exclude<PresenceWorkspaceVerification, { ok: true }>, +) { + return res.status(verification.status).json({ + error: verification.code, + message: verification.message, + ...(verification.retryable ? { retryable: true } : {}), + }); +} + +/** + * Team collaboration presence (presence) capability. Members heartbeat while viewing a + * shared project; clients poll the present set (live cursors were cut, content + * is polled — the spec). The set is process-local in {@link CollabRuntime}. + */ +export function registerCollabPresenceRoutes( + app: Express, + deps: RegisterCollabPresenceRoutesDeps, +): CollabPresenceRoutesControl { + const { presence } = deps.collab; + const cloud = deps.cloud ?? null; + const presenceLists = createPresenceListCache({ + freshMs: Math.max( + 0, + deps.presenceListCacheFreshMs ?? DEFAULT_PRESENCE_LIST_CACHE_FRESH_MS, + ), + now: deps.presenceListCacheNow ?? Date.now, + }); + + async function projectIsShared( + projectId: string, + context: WorkspaceCollabContext | null, + ): Promise<boolean> { + if (!deps.isProjectShared) return true; + try { + return await deps.isProjectShared(projectId, context); + } catch (error) { + return false; + } + } + + function cloudAuthorizesProject(projectId: string): boolean { + if (!cloud || !deps.cloudAuthorizesProjectPresence) return false; + try { + return deps.cloudAuthorizesProjectPresence(projectId); + } catch { + return false; + } + } + + async function verifiedContext( + req: Request, + projectId: string, + mode: 'read' | 'write', + ): Promise<PresenceWorkspaceVerification> { + const verify = + mode === 'read' + ? deps.verifyWorkspaceReadRequest ?? deps.verifyWorkspaceRequest + : deps.verifyWorkspaceRequest; + if (!verify) { + return { ok: true, context: null }; + } + try { + return normalizeWorkspaceVerification( + await verify(req, projectId), + ); + } catch { + return { + ok: false, + status: 503, + code: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + message: 'workspace membership authority is temporarily unavailable', + retryable: true, + }; + } + } + + app.get('/api/projects/:id/presence', async (req, res) => { + const verification = await verifiedContext(req, req.params.id, 'read'); + if (cloud && !verification.ok) { + return sendWorkspaceVerificationFailure(res, verification); + } + const context = verification.ok ? verification.context : null; + if (cloud) { + try { + return res.json({ + present: await presenceLists.read( + req.params.id, + context, + async () => { + // When the upstream hub subscription cannot authoritatively + // vouch for this Workspace, resolving the project owner is part + // of the SAME expensive Vela read as the roster. Keeping this + // check outside `presenceLists` made every hot GET spawn a + // second CLI/catalog read even while the roster itself was + // cached. Explicit share/unshare and hub invalidation drop this + // entry, while the exact authority verification above still + // runs before every read and fails closed on membership loss. + if ( + !cloudAuthorizesProject(req.params.id) + && !(await projectIsShared(req.params.id, context)) + ) { + return []; + } + return cloud.listPresence(req.params.id, context); + }, + ), + }); + } catch (error) { + return cloudError(res, error); + } + } + if (!(await projectIsShared(req.params.id, context))) { + return res.json({ present: [] }); + } + res.json({ present: presence.present(req.params.id) }); + }); + + app.post('/api/projects/:id/presence/heartbeat', async (req, res) => { + const heartbeat = readHeartbeat(req.body); + if (!heartbeat) return res.status(400).json({ error: 'memberId required' }); + const verification = await verifiedContext(req, req.params.id, 'write'); + if (cloud && !verification.ok) { + return sendWorkspaceVerificationFailure(res, verification); + } + const context = verification.ok ? verification.context : null; + if ( + cloud + && deps.verifyWorkspaceRequest + && ( + !verification.ok + || !context + || heartbeat.member.memberId !== context.workspaceMemberId + ) + ) { + return res.status(403).json({ error: 'WORKSPACE_PROJECT_PRESENCE_DENIED' }); + } + if ( + !cloudAuthorizesProject(req.params.id) && + !(await projectIsShared(req.params.id, context)) + ) { + if (cloud) presenceLists.publish(req.params.id, context, []); + return res.json({ present: [] }); + } + if (cloud) { + try { + const present = await cloud.heartbeatPresence( + req.params.id, + heartbeat, + context, + ); + presenceLists.publish(req.params.id, context, present); + return res.json({ + present, + }); + } catch (error) { + return cloudError(res, error); + } + } + presence.heartbeat(req.params.id, heartbeat.member); + res.json({ present: presence.present(req.params.id) }); + }); + + app.post('/api/projects/:id/presence/leave', async (req, res) => { + const leave = readLeave(req.body); + if (!leave) return res.status(400).json({ error: 'memberId required' }); + const verification = await verifiedContext(req, req.params.id, 'write'); + if (cloud && !verification.ok) { + return sendWorkspaceVerificationFailure(res, verification); + } + const context = verification.ok ? verification.context : null; + if ( + cloud + && deps.verifyWorkspaceRequest + && (!verification.ok || !context || leave.memberId !== context.workspaceMemberId) + ) { + return res.status(403).json({ error: 'WORKSPACE_PROJECT_PRESENCE_DENIED' }); + } + if (cloud) { + try { + const present = await cloud.leavePresence( + req.params.id, + leave, + context, + ); + presenceLists.publish(req.params.id, context, present); + return res.json({ + ok: true, + present, + }); + } catch (error) { + return cloudError(res, error); + } + } + presence.leave(req.params.id, leave.memberId); + res.json({ ok: true, present: presence.present(req.params.id) }); + }); + + return { + markPresenceStale: (projectId, workspaceId) => + presenceLists.markStale(projectId, workspaceId), + invalidatePresence: (projectId, workspaceId) => + presenceLists.invalidate(projectId, workspaceId), + }; +} diff --git a/apps/daemon/src/routes/collab-sync.ts b/apps/daemon/src/routes/collab-sync.ts new file mode 100644 index 00000000000..61e4bd93f6f --- /dev/null +++ b/apps/daemon/src/routes/collab-sync.ts @@ -0,0 +1,2381 @@ +import type { Express, Request, Response } from 'express'; +import { mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + workspaceContextHasWorkspaceIdentity, + type ProjectContentTransferState, + type ProjectMetadata, + type ProjectSyncIntentEvent, + type TeamProject, + type WorkspaceCollabContext, +} from '@open-design/contracts'; +import type { + ProjectContentTransferToken, +} from '../collab/project-content-transfer-state.js'; +import type { + VerifiedWorkspaceRequestContextResult, +} from '../collab/request-workspace-context.js'; +import type { CollabRuntime } from '../collab/runtime.js'; +import { + contextToResourceHubPrincipal, + type ResourceHubPrincipal, +} from '../collab/resource-principal.js'; +import { + isAuthorizedProactivePullInvocation, + isBoundProactivePullInvocation, + isFreshProactivePullAuthorizationWitness, + type AuthorizedProactivePullInvocation, + type ProactivePullAuthorizationWitness, +} from '../collab/proactive-content-pull.js'; +import { + isAuthorizedTeamProjectPullReceiptExpired, + isAuthorizedTeamProjectPullUnavailable, + stageAuthorizedTeamProjectPull, + validateAuthorizedTeamProjectPullReceipt, + type AuthorizedTeamProjectPullReceipt, + type StageAuthorizedTeamProjectPullInput, + type StagedAuthorizedTeamProjectPull, +} from '../collab/authorized-team-project-pull.js'; +import { + promoteAuthorizedTeamProjectStage, + type PromoteAuthorizedTeamProjectStageInput, +} from '../collab/team-mirror-promotion.js'; +import { isUnmaterializedSharedPlaceholder } from '../collab/shared-project-placeholder.js'; +import { + isRetractedHubResourceError, + parseVelaResourceSnapshot, + runVelaResourceCommand, +} from '../collab/vela-cli-resource-adapter.js'; +import { readVelaControlApiContext } from '../integrations/vela.js'; +import { readProjectManifest } from '../project-locations.js'; +import { redactSecrets } from '../redact.js'; + +/** The fields register-on-pull reads out of a pulled project's manifest. */ +export interface PulledProjectManifest { + name?: string; + skillId?: string | null; + designSystemId?: string | null; + createdAt?: number; + updatedAt?: number; +} + +export interface RegisterPulledProjectInput { + id: string; + name: string; + skillId: string | null; + designSystemId: string | null; + metadata?: ProjectMetadata; + createdAt: number; + updatedAt: number; +} + +export interface TeamMirrorPullScope { + workspaceId: string; + resourceTeamId: string; + viewerMemberId: string; + ownerMemberId: string; +} + +export interface PulledProjectStore { + get?: (projectId: string) => { name?: string | null; metadata?: unknown } | null; + has(projectId: string): boolean; + register(input: RegisterPulledProjectInput): void; + update?: (input: RegisterPulledProjectInput) => void; + /** + * Atomically materialize the project row and its active team binding, then + * return only after a strict readback proves the mirror is mutation-gated. + */ + materializeTeamMirror?: ( + input: RegisterPulledProjectInput, + scope: TeamMirrorPullScope, + ) => { localRecordChanged: boolean }; + materializeAuthorizedTeamMirror?: ( + input: RegisterPulledProjectInput, + scope: TeamMirrorPullScope, + receipt: AuthorizedTeamProjectPullReceipt, + ) => { localRecordChanged: boolean }; +} + +type CollabSyncPullTimingStatus = + | 'pulled' + | 'revoked' + | 'register_failed' + | 'threw' + | 'staged' + | 'capability-unavailable' + | 'failed'; + +export interface RegisterCollabSyncRoutesDeps { + collab: Pick< + CollabRuntime, + | 'scheduler' + | 'publishedVersion' + | 'publishedHead' + | 'projectSyncState' + | 'projectOwnerMemberId' + | 'requestTeamShare' + | 'requestTeamUnshare' + | 'pullLatest' + >; + resolveSharedProjectOwner?: ( + projectId: string, + scope?: { workspaceId: string; workspaceMemberId: string }, + ) => Promise<string | null>; + /** + * Read-only owner lookup for GET /collab/status. This may use a short-lived + * explicit-scope display cache because request authority is verified first. + * Pull, publish, presence, and mutation paths deliberately keep using the + * fresh `resolveSharedProjectOwner` dependency above. + */ + resolveSharedProjectOwnerForStatus?: ( + projectId: string, + scope?: { workspaceId: string; workspaceMemberId: string }, + ) => Promise<string | null>; + resolveSharedProject?: ( + projectId: string, + scope?: TeamMirrorPullScope | null, + ) => Promise<TeamProject | null>; + /** + * Authorize the request's explicit Workspace selector against the signed-in + * account's authoritative membership directory, then return the directory- + * derived context. Client-supplied role/permission headers are never + * authority. Null is a fail-closed denial. + */ + verifyWorkspaceRequest?: ( + req: Request, + projectId?: string, + ) => Promise< + | VerifiedWorkspaceRequestContextResult + | WorkspaceCollabContext + | null + >; + /** + * Bounded successful authority lease for the read-only status surface. + * Mutations and content materialization must continue to use + * `verifyWorkspaceRequest`. + */ + verifyWorkspaceReadRequest?: ( + req: Request, + projectId?: string, + ) => Promise< + | VerifiedWorkspaceRequestContextResult + | WorkspaceCollabContext + | null + >; + /** + * Revalidate one already-captured Team pull scope against the authoritative + * membership directory. This must address `scope.workspaceId` + + * `scope.viewerMemberId` directly; it must not compare against the daemon's + * mutable active Workspace. + */ + verifyWorkspaceScope?: (scope: TeamMirrorPullScope) => Promise<boolean>; + /** Set/clear the non-destructive "team mirror revoked" flag on a local + * project so read routes stop serving a project that has left the team. */ + markTeamProjectRevoked?: (projectId: string, revoked: boolean) => void; + /** Read the same durable quarantine marker for status/direct-read denial. */ + isTeamProjectRevoked?: (projectId: string) => boolean; + /** + * Set/clear the `sharedProjectPlaceholderAt` stamp on a local project's + * metadata (see collab/shared-project-placeholder.ts). Set when + * `ensureSharedProjectPlaceholder` registers a placeholder record; cleared + * exactly once a pull has materialized real hub content locally. While the + * stamp is set, the publish paths refuse to treat the local copy as content + * authority (the recvqzaDvUU6B3 fresh-install wipe guard). + */ + markSharedProjectPlaceholder?: (projectId: string, placeholder: boolean) => void; + /** + * Delete a local project record that is still an unmaterialized + * shared-project placeholder (and its empty content directory). Called by + * the retracted-share heal below ONLY for a record the placeholder stamp + * proves contentless — implementations must re-check + * `isUnmaterializedSharedPlaceholder` before deleting so a concurrent pull + * that just materialized real content can never be destroyed. + */ + retireUnmaterializedSharedPlaceholder?: (projectId: string) => void; + /** Drop the daemon's cached team-project catalog listing so a heal that + * removed a catalog row is visible on the next list read, not one + * stale-while-revalidate TTL later. */ + invalidateTeamProjectCatalog?: () => void; + resolveOwnerDisplayName?: ( + memberId: string, + context: WorkspaceCollabContext, + ) => Promise<{ displayName: string; role: 'owner' | 'admin' | 'member' } | null>; + projectStore?: PulledProjectStore; + resolveProjectDir?: (projectId: string) => string | Promise<string>; + resolvePullDir?: (projectId: string) => string; + /** Read the durable local materialization cursor for this exact team mirror. */ + readMaterializedVersion?: ( + projectId: string, + scope: TeamMirrorPullScope, + ) => number | null; + /** Read the daemon-local inbound content-transfer lifecycle snapshot. */ + readContentTransferState?: ( + projectId: string, + scope: TeamMirrorPullScope, + ) => ProjectContentTransferState | null; + /** Begin one exact-scope transfer generation after authorization resolves. */ + beginContentTransfer?: ( + projectId: string, + scope: TeamMirrorPullScope, + version?: number, + ) => ProjectContentTransferToken; + /** Only the matching exact-scope generation token may complete a transfer. */ + finishContentTransfer?: ( + projectId: string, + scope: TeamMirrorPullScope, + token: ProjectContentTransferToken, + version?: number, + ) => void; + /** Persist the actual version after either HTTP or proactive pull lands. */ + writeMaterializedVersion?: ( + projectId: string, + scope: TeamMirrorPullScope, + version: number, + ) => void | Promise<void>; + /** + * Tell the proactive coordinator that the fallback HTTP/legacy lane + * durably landed this exact scope + version. This runs only after the + * cursor commit, so consumers may settle a queued same-head retry without + * trusting an in-memory claim that is ahead of disk. + */ + onLegacyPullMaterialized?: ( + projectId: string, + scope: TeamMirrorPullScope, + version: number, + ) => void | Promise<void>; + readManifest?: (projectDir: string) => Promise<PulledProjectManifest | null>; + authorizedTeamProjectPull?: { + journalDir: string; + getActiveWorkspaceSnapshot?: () => { + workspaceId: string | null; + generation: number; + }; + stage?: ( + input: StageAuthorizedTeamProjectPullInput, + ) => Promise<StagedAuthorizedTeamProjectPull>; + promote?: ( + input: PromoteAuthorizedTeamProjectStageInput<{ + localRecordChanged: boolean; + }>, + ) => Promise<{ localRecordChanged: boolean }>; + }; + onTeamShareStateChanged?: (input: { + projectId: string; + principal?: ResourceHubPrincipal | null; + visibility: 'personal' | 'team'; + ownerMemberId?: string | null; + updatedByMemberId?: string | null; + }) => void; + /** + * Notify any live `/api/projects/:id/events` SSE subscribers that this + * project's files changed on disk. Called after a successful + * `POST /collab/pull` materializes new content. + * + * This is NOT redundant with the project's chokidar watcher: the `vela + * resource pull` transport materializes a pulled project by replacing its + * ENTIRE directory (a fresh inode every pull, confirmed via `stat` across + * repeated pulls against a live resource-hub project) rather than updating + * files in place. A chokidar watch established before that swap keeps + * watching the OLD (now-orphaned) directory handle and silently stops + * firing — so the member's own currently-open FileViewer tab never saw + * `file-changed`, even though `/collab/status` and the file's bytes on disk + * were both already correct (recvq6CIesNvWZ). Firing this explicit signal + * right after the pull we know just landed sidesteps the swap entirely + * instead of depending on chokidar surviving it. + */ + notifyFilesChanged?: (projectId: string) => void; + /** + * Notify any live `/api/projects/:id/events` SSE subscribers that this + * project's LOCAL record (name / skill / design-system) changed as part of + * a pull. `registerPulledProject` is what replaces the "共享项目" + * placeholder record with the real project name — but that write is + * DB-only, and the only other post-pull signal (`notifyFilesChanged`) + * refreshes the file list, never the project record. Without this signal a + * member web that seeded its `projects` state from the placeholder keeps + * rendering "共享项目" in the sidebar/tab until a full page reload + * (recvqhwv6RPU1j). Wired to the existing `project-metadata-changed` thin + * event; fired only when the pull actually registered or updated the local + * record, so steady-state content pulls emit nothing. + */ + notifyProjectMetadataChanged?: (projectId: string) => void; + /** Opt-in, secret-free timing observer. It must not affect pull behavior. */ + onPullTiming?: (event: { + phase: + | 'route-started' + | 'initial-authorization-reused' + | 'authorized-stage-started' + | 'authorized-stage-done' + | 'authorized-receipt-validated' + | 'authorized-scope-revalidated' + | 'promotion-started' + | 'promotion-done' + | 'version-persisted' + | 'transport-invoke' + | 'transport-done' + | 'registration-prepared' + | 'catalog-revalidated' + | 'scope-revalidated' + | 'mirror-materialized' + | 'version-write-started' + | 'persisted' + | 'route-completed'; + projectId: string; + version?: number; + receivedAtMs?: number; + atMs: number; + status?: CollabSyncPullTimingStatus; + }) => void; +} + +/** Result of one shared-project content pull — the same flow whether it was + * reached over `POST /api/projects/:id/collab/pull` or daemon-internally + * through {@link CollabSyncRoutesHandle.pullSharedProject}. */ +export type CollabSyncPullOutcome = + | { status: 'pulled'; version: number | null } + | { status: 'revoked' } + | { status: 'register_failed' }; + +/** Daemon-internal surface `registerCollabSyncRoutes` hands back so non-HTTP + * callers (the hub push channel's proactive content pull, server.ts) can run + * the same pull flow the POST route runs. */ +export interface CollabSyncRoutesHandle { + /** + * Materialize the latest published content for a shared project, exactly as + * `POST /api/projects/:id/collab/pull` would: revocation gate, owner-routed + * hub pull, register-on-pull, and the `file-changed` / + * `project-metadata-changed` signals. The viewer principal is derived from + * the daemon's own workspace context (there is no request to read headers + * from). Concurrent pulls for the same project+scope — including a member + * web's racing POST — coalesce onto one in-flight materialization. + */ + pullSharedProject( + projectId: string, + scope: TeamMirrorPullScope, + authorizationWitness?: ProactivePullAuthorizationWitness, + expectedVersion?: number, + authorizedStageInvocation?: AuthorizedProactivePullInvocation, + ): Promise<CollabSyncPullOutcome>; +} + +const SYNC_INTENT_EVENTS: ReadonlySet<ProjectSyncIntentEvent> = new Set([ + 'project_visibility_changed', + 'project_team_share_requested', + 'project_team_unshare_requested', +]); +const PULLED_PROJECT_PLACEHOLDER_NAME = '共享项目'; +const PUBLIC_FILE_RESOURCE_KIND = 'project'; +const PUBLIC_FILE_REF = 'published'; + +interface PublicFilePublication { + url: string; + slug: string; + fileName: string; +} + +const publicFilePublications = new Map<string, PublicFilePublication>(); +const MAX_ERROR_LOG_FIELD_LENGTH = 2_048; + +function redactedErrorLogText(value: unknown): string { + const text = value instanceof Error + ? value.message || value.name + : String(value); + return redactSecrets(text).slice(0, MAX_ERROR_LOG_FIELD_LENGTH); +} + +function errorLogFields(error: unknown): { + errorName: string; + errorMessage: string; + errorCause?: string; +} { + const errorName = redactSecrets( + error instanceof Error ? error.name : typeof error, + ).slice(0, MAX_ERROR_LOG_FIELD_LENGTH); + const errorMessage = redactedErrorLogText(error); + const cause = + error && typeof error === 'object' && 'cause' in error + ? (error as { cause?: unknown }).cause + : undefined; + return { + errorName, + errorMessage, + ...(cause == null + ? {} + : { errorCause: redactedErrorLogText(cause) }), + }; +} + +function cleanPulledProjectName(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.replace(/\s+/g, ' ').trim(); + if (!trimmed || trimmed === 'index.html') return null; + return trimmed; +} + +async function readJsonObject(filePath: string): Promise<Record<string, unknown> | null> { + try { + const parsed = JSON.parse(await readFile(filePath, 'utf8')) as unknown; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record<string, unknown> + : null; + } catch { + return null; + } +} + +async function inferNameFromSkillManifest(projectDir: string): Promise<string | null> { + const skillsDir = path.join(projectDir, '.od-skills'); + let entries: string[]; + try { + entries = await readdir(skillsDir); + } catch { + return null; + } + for (const entry of entries) { + const manifest = await readJsonObject(path.join(skillsDir, entry, 'open-design.json')); + const title = cleanPulledProjectName(manifest?.title); + if (title) return title; + const name = cleanPulledProjectName(manifest?.name); + if (name) return name; + } + return null; +} + +async function inferNameFromHtmlTitle(projectDir: string): Promise<string | null> { + try { + const html = await readFile(path.join(projectDir, 'index.html'), 'utf8'); + const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i); + return cleanPulledProjectName(match?.[1]?.replace(/<[^>]*>/g, '')); + } catch { + return null; + } +} + +async function resolvePulledProjectName( + projectDir: string, + manifest: PulledProjectManifest | null, +): Promise<string> { + return cleanPulledProjectName(manifest?.name) + ?? await inferNameFromSkillManifest(projectDir) + ?? await inferNameFromHtmlTitle(projectDir) + ?? PULLED_PROJECT_PLACEHOLDER_NAME; +} + +const STATUS_ENRICHMENT_CACHE_LIMIT = 256; + +function readLruEntry<K, V>(cache: Map<K, V>, key: K): V | undefined { + if (!cache.has(key)) return undefined; + const value = cache.get(key)!; + cache.delete(key); + cache.set(key, value); + return value; +} + +function writeLruEntry<K, V>(cache: Map<K, V>, key: K, value: V): void { + cache.delete(key); + cache.set(key, value); + while (cache.size > STATUS_ENRICHMENT_CACHE_LIMIT) { + const oldest = cache.keys().next(); + if (oldest.done) return; + cache.delete(oldest.value); + } +} + +function normalizePublicFilePath(raw: string): string | null { + if (raw.includes('\\')) return null; + let decoded: string; + try { + decoded = raw + .split('/') + .map((part) => decodeURIComponent(part)) + .join('/'); + } catch { + return null; + } + if (decoded.includes('\\')) return null; + const normalized = decoded.replace(/^\/+/, '').replace(/\/+/g, '/'); + if ( + !normalized || + normalized.includes('\0') || + normalized.split('/').some((part) => part === '' || part === '.' || part === '..') + ) { + return null; + } + return normalized; +} + +async function resolvePublicSourceFile(projectDir: string, filePath: string): Promise<string> { + const [projectRoot, candidate] = await Promise.all([ + realpath(projectDir), + realpath(path.join(projectDir, filePath)), + ]); + const relative = path.relative(projectRoot, candidate); + if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) { + return candidate; + } + const error = new Error('public file path escapes project root') as NodeJS.ErrnoException; + error.code = 'EACCES'; + throw error; +} + +function publicFileResourceIdFor( + projectId: string, + filePath: string, + principal: ResourceHubPrincipal, +): string { + const scoped = Buffer.from( + JSON.stringify([principal.teamId, principal.memberId, projectId, filePath]), + 'utf8', + ).toString('base64url'); + return `project-file-${scoped}`; +} + +function publicFilePublicationKey(projectId: string, filePath: string, principal: ResourceHubPrincipal): string { + return JSON.stringify([principal.teamId, principal.memberId, projectId, filePath]); +} + +function encodePublicFileUrlPath(filePath: string): string { + return filePath.split('/').map((part) => encodeURIComponent(part)).join('/'); +} + +/** + * The 409 body for a public-file request that has no team workspace behind it. + * + * Public links are snapshots in the workspace resource hub, which only a team + * workspace can address (`workspaceContextHasTeamIdentity`). A personal or + * signed-out session — and a team session whose context read momentarily fails — + * lands here. Ship a sentence alongside the code so every surface that is not + * the web UI (the `od` CLI, embedding agents) states the reason instead of + * echoing `WORKSPACE_IDENTITY_REQUIRED` at a human. The web UI localizes the + * code itself; see `publicFilePublishFailureKey` in apps/web. + */ +function workspaceIdentityRequiredBody() { + return { + error: 'WORKSPACE_IDENTITY_REQUIRED', + message: + 'Publishing a public link needs a signed-in workspace. Sign in to Open Design Cloud, ' + + 'or use Deploy to publish this file without one.', + }; +} + +/** + * Resource-hub principal for the PUBLIC SINGLE-FILE publish routes. + * + * These routes deliberately do NOT use `contextToResourceHubPrincipal`, which + * requires `workspaceContextHasTeamIdentity` and is still exactly right for team + * project sharing (a shared project needs teammates to share WITH). + * + * A public file link needs no such thing. The hub addresses purely by workspace + * id, and B stopped refusing a personal workspace on its control-key auth path: + * `authenticateSession` now mints a principal whose `teamId` IS the workspace id + * — "a partition of one" — and `resolveAccess` only ever compares that id with + * the resource's own. So the real requirement here is A workspace, not a TEAM + * workspace: an id to publish under and a member id to own the resource with. + * + * A signed-out session still has neither, and is still refused — this widens the + * gate, it does not remove it. The web UI must gate its entry point on the SAME + * rule (`canPublishPublicFile` in apps/web/src/collab/public-file-publish.ts); + * a button that renders where this returns 409 is the bug this pair exists to + * prevent. + */ +function publicFilePrincipal(context: WorkspaceCollabContext | null): ResourceHubPrincipal | null { + if (!workspaceContextHasWorkspaceIdentity(context) || !context) return null; + // The predicate above already proved both ids are present; this is the type + // narrowing TS needs, not a second copy of the rule. + const { workspaceId, workspaceMemberId } = context; + if (!workspaceId || !workspaceMemberId) return null; + return { + memberId: workspaceMemberId, + // Personal workspaces carry no `teamId`; the workspace id is the scope. + teamId: context.teamId ?? workspaceId, + role: context.role, + lifecycleState: context.lifecycleState, + workspaceType: context.workspaceType, + }; +} + +function publicResourceHubBaseUrl(): string | null { + return readVelaControlApiContext()?.apiUrl?.trim() || process.env.OD_RESOURCE_HUB_URL?.trim() || null; +} + +function publicSnapshotFileUrl(baseUrl: string, slug: string, filePath: string): string { + const relative = `/api/v1/public/snapshots/${encodeURIComponent(slug)}/files/${encodePublicFileUrlPath(filePath)}`; + return new URL(relative, baseUrl).toString(); +} + +async function resolveSharedProjectForPublicFile( + resolveSharedProject: RegisterCollabSyncRoutesDeps['resolveSharedProject'], + projectId: string, + context: WorkspaceCollabContext, + principal: ResourceHubPrincipal, +): Promise<{ ok: true; project: TeamProject | null } | { ok: false }> { + try { + return { + ok: true, + project: await resolveSharedProject?.(projectId, { + workspaceId: context.workspaceId, + resourceTeamId: principal.teamId, + viewerMemberId: principal.memberId, + // This is an ownership lookup, not a pull authorization witness. The + // catalog result below supplies the authoritative owner. + ownerMemberId: '', + }) ?? null, + }; + } catch (error) { + console.warn('[od] failed to resolve public file project ownership:', error); + return { ok: false }; + } +} + +type RouteWorkspaceVerification = + | { ok: true; context: WorkspaceCollabContext | null } + | Exclude<VerifiedWorkspaceRequestContextResult, { ok: true }>; + +function normalizeWorkspaceVerification( + value: + | VerifiedWorkspaceRequestContextResult + | WorkspaceCollabContext + | null, +): RouteWorkspaceVerification { + if (value && 'ok' in value) return value; + if (value) return { ok: true, context: value }; + // Legacy injected test adapters used null as a route-specific denial. + // Production supplies the structured verifier result above. + return { ok: true, context: null }; +} + +function sendWorkspaceVerificationFailure( + res: Response, + verification: Exclude<RouteWorkspaceVerification, { ok: true }>, +) { + return res.status(verification.status).json({ + error: verification.code, + message: verification.message, + ...(verification.retryable ? { retryable: true } : {}), + }); +} + +export function registerCollabSyncRoutes( + app: Express, + deps: RegisterCollabSyncRoutesDeps, +): CollabSyncRoutesHandle { + const { + scheduler, + publishedVersion, + publishedHead, + projectSyncState, + projectOwnerMemberId, + requestTeamShare, + requestTeamUnshare, + pullLatest, + } = deps.collab; + const { + projectStore, + resolveProjectDir, + resolvePullDir, + resolveSharedProjectOwner, + resolveSharedProjectOwnerForStatus, + resolveSharedProject, + markTeamProjectRevoked, + isTeamProjectRevoked, + markSharedProjectPlaceholder, + retireUnmaterializedSharedPlaceholder, + invalidateTeamProjectCatalog, + resolveOwnerDisplayName, + notifyFilesChanged, + notifyProjectMetadataChanged, + } = deps; + const readManifest = deps.readManifest ?? readProjectManifest; + const ownerEnrichmentCache = new Map< + string, + { + entry: { displayName: string; role: 'owner' | 'admin' | 'member' } | null; + resolvedAt: number; + } + >(); + const ownerEnrichmentInFlight = new Map<string, Promise<void>>(); + const headEnrichmentCache = new Map< + string, + { head: number | null; scope: TeamMirrorPullScope | null } + >(); + const headEnrichmentInFlight = new Map<string, Promise<void>>(); + const OWNER_ENRICHMENT_TTL_MS = 30_000; + const reportPullTiming = ( + event: Parameters<NonNullable<RegisterCollabSyncRoutesDeps['onPullTiming']>>[0], + ): void => { + try { + deps.onPullTiming?.(event); + } catch { + // Diagnostics are observational and must never affect pull behavior. + } + }; + + async function verifyWorkspaceContextForRequest( + req: Request, + projectId?: string, + verifier = deps.verifyWorkspaceRequest, + ): Promise<RouteWorkspaceVerification> { + if (!verifier) { + return { ok: true, context: null }; + } + try { + return normalizeWorkspaceVerification( + await verifier(req, projectId), + ); + } catch { + return { + ok: false, + status: 503, + code: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + message: 'workspace membership authority is temporarily unavailable', + retryable: true, + }; + } + } + + function verifiedWorkspaceContextForRequest( + req: Request, + projectId?: string, + ): Promise<RouteWorkspaceVerification> { + return verifyWorkspaceContextForRequest( + req, + projectId, + deps.verifyWorkspaceRequest, + ); + } + + function verifiedWorkspaceReadContextForRequest( + req: Request, + projectId?: string, + ): Promise<RouteWorkspaceVerification> { + return verifyWorkspaceContextForRequest( + req, + projectId, + deps.verifyWorkspaceReadRequest ?? deps.verifyWorkspaceRequest, + ); + } + + async function statusIdentityForRequest(projectId: string, req: { + get(name: string): string | undefined; + headers: { authorization?: string | string[] | undefined }; + }): Promise<{ + verification: RouteWorkspaceVerification; + context: WorkspaceCollabContext | null; + principal: ResourceHubPrincipal | null; + workspaceId: string | null; + }> { + const verification = await verifiedWorkspaceReadContextForRequest( + req as Request, + projectId, + ); + const context = verification.ok ? verification.context : null; + return { + verification, + context, + principal: contextToResourceHubPrincipal(context), + workspaceId: context?.workspaceId?.trim() || null, + }; + } + + async function pullAccessForRequest( + projectId: string, + req: { + get(name: string): string | undefined; + headers: { authorization?: string | string[] | undefined }; + }, + knownOwnerMemberId?: string | null, + capturedIdentity?: { + principal: ResourceHubPrincipal | null; + workspaceId: string | null; + }, + ): Promise<{ + verification: RouteWorkspaceVerification; + principal: ResourceHubPrincipal | null; + scope: TeamMirrorPullScope | null; + }> { + const verification = capturedIdentity + ? { ok: true as const, context: null } + : await verifiedWorkspaceContextForRequest(req as Request, projectId); + const context = verification.ok ? verification.context : null; + const viewerPrincipal = + capturedIdentity?.principal ?? + contextToResourceHubPrincipal(context); + const workspaceId = capturedIdentity + ? capturedIdentity.workspaceId + : context?.workspaceId; + const viewerMemberId = viewerPrincipal?.memberId ?? null; + let ownerMemberId = knownOwnerMemberId ?? null; + if (knownOwnerMemberId === undefined) { + try { + ownerMemberId = + workspaceId && viewerMemberId + ? (await resolveSharedProjectOwner?.(projectId, { + workspaceId, + workspaceMemberId: viewerMemberId, + })) + ?? projectOwnerMemberId(projectId, viewerPrincipal) + ?? null + : null; + } catch { + ownerMemberId = null; + } + } + const resourceTeamId = capturedIdentity + ? viewerPrincipal?.teamId + : context?.workspaceType === 'team' + && context.memberStatus === 'active' + && context.lifecycleState === 'active' + && context.workspaceId === workspaceId + && context.workspaceMemberId === viewerPrincipal?.memberId + ? context.teamId ?? context.workspaceId + : null; + const scope = + ownerMemberId && + viewerPrincipal && + workspaceId && + resourceTeamId && + (capturedIdentity != null || context?.workspaceType === 'team') + ? { + workspaceId, + resourceTeamId, + viewerMemberId: viewerPrincipal.memberId, + ownerMemberId, + } + : null; + const principal = ownerMemberId && viewerPrincipal?.teamId + ? { + ...viewerPrincipal, + ...(scope ? { teamId: scope.resourceTeamId } : {}), + memberId: ownerMemberId, + role: ownerMemberId === viewerPrincipal.memberId ? viewerPrincipal.role : 'member' as const, + } + : viewerPrincipal; + return { verification, principal, scope }; + } + + async function canShareProjectsForRequest( + req: Request, + verifiedContext?: WorkspaceCollabContext | null, + ): Promise<boolean> { + const verification = + verifiedContext === undefined + ? await verifiedWorkspaceContextForRequest(req) + : { ok: true as const, context: verifiedContext }; + const context = verification.ok ? verification.context : null; + return context?.permissions.canShareProjects === true; + } + + async function verifiedPublishPrincipalForRequest( + req: Request, + projectId: string, + ): Promise< + | { ok: true; principal: ResourceHubPrincipal } + | { + ok: false; + status: 400 | 403 | 503; + error: string; + message?: string; + retryable?: true; + } + > { + const verification = await verifiedWorkspaceContextForRequest(req, projectId); + if (!verification.ok) { + return { + ok: false, + status: verification.status, + error: verification.code, + message: verification.message, + ...(verification.retryable ? { retryable: true } : {}), + }; + } + const context = verification.context; + const principal = contextToResourceHubPrincipal(context); + if ( + !context + || !principal + || context.memberStatus !== 'active' + || context.lifecycleState !== 'active' + || !context.permissions.canWriteSyncedFiles + ) { + return { + ok: false, + status: 403, + error: 'WORKSPACE_PROJECT_PUBLISH_DENIED', + }; + } + let ownerMemberId: string | null; + try { + ownerMemberId = + await resolveSharedProjectOwner?.(projectId, { + workspaceId: context.workspaceId, + workspaceMemberId: context.workspaceMemberId, + }) + ?? projectOwnerMemberId(projectId, principal); + } catch { + return { + ok: false, + status: 503, + error: 'WORKSPACE_PROJECT_OWNERSHIP_UNAVAILABLE', + }; + } + if (ownerMemberId !== principal.memberId) { + return { + ok: false, + status: 403, + error: 'WORKSPACE_PROJECT_PUBLISH_DENIED', + }; + } + return { ok: true, principal }; + } + + async function capturedScopeIsStillAuthorized(scope: TeamMirrorPullScope): Promise<boolean> { + try { + return await deps.verifyWorkspaceScope?.(scope) ?? false; + } catch { + return false; + } + } + + interface PreparedPulledProjectRegistration { + existing: { name?: string | null } | null; + fallbackName: string; + manifest: PulledProjectManifest | null; + now: number; + projectId: string; + } + + async function preparePulledProjectRegistration( + projectId: string, + scope: TeamMirrorPullScope | null, + projectDirOverride?: string, + ): Promise<PreparedPulledProjectRegistration | null> { + if (!projectStore || !resolvePullDir) { + if (scope) throw new Error('team mirror project store unavailable'); + return null; + } + const existing = projectStore.get?.(projectId); + if (!scope) { + if (!existing && projectStore.has(projectId)) return null; + if (existing && cleanPulledProjectName(existing.name) !== PULLED_PROJECT_PLACEHOLDER_NAME) return null; + } + const projectDir = projectDirOverride ?? resolvePullDir(projectId); + let manifest: PulledProjectManifest | null = null; + try { + manifest = await readManifest(projectDir); + } catch { + manifest = null; + } + return { + existing: existing ?? null, + fallbackName: await resolvePulledProjectName(projectDir, manifest), + manifest, + now: Date.now(), + projectId, + }; + } + + /** + * Register/refresh the local project record for a just-pulled shared + * project. This function is deliberately synchronous: a scoped caller does + * its final authoritative catalog read and workspace-identity check + * immediately before entering the SQLite transaction, with no ambient + * metadata await able to reopen a workspace/unshare race in between. + */ + function registerPreparedPulledProject( + prepared: PreparedPulledProjectRegistration | null, + scope: TeamMirrorPullScope | null, + teamProject: TeamProject | null, + receipt?: AuthorizedTeamProjectPullReceipt, + ): boolean { + if (!prepared || !projectStore) return false; + const { existing, fallbackName, manifest, now, projectId } = prepared; + const input = { + id: projectId, + name: cleanPulledProjectName(teamProject?.name) ?? fallbackName, + skillId: teamProject?.skillId ?? manifest?.skillId ?? null, + designSystemId: teamProject?.designSystemId ?? manifest?.designSystemId ?? null, + ...(teamProject?.metadata ? { metadata: teamProject.metadata } : {}), + createdAt: typeof teamProject?.createdAt === 'number' + ? teamProject.createdAt + : typeof manifest?.createdAt === 'number' + ? manifest.createdAt + : now, + updatedAt: typeof teamProject?.updatedAt === 'number' + ? teamProject.updatedAt + : typeof manifest?.updatedAt === 'number' + ? manifest.updatedAt + : now, + }; + if (scope) { + if (receipt) { + if (!projectStore.materializeAuthorizedTeamMirror) { + throw new Error('authorized team mirror materializer unavailable'); + } + return projectStore.materializeAuthorizedTeamMirror( + input, + scope, + receipt, + ).localRecordChanged; + } + if (!projectStore.materializeTeamMirror) { + throw new Error('team mirror materializer unavailable'); + } + return projectStore.materializeTeamMirror(input, scope).localRecordChanged; + } + if (existing) { + if (!projectStore.update) return false; + projectStore.update(input); + return true; + } + projectStore.register(input); + return true; + } + + /** + * Register a minimal placeholder project record the moment a member opens a + * shared project they don't have locally yet — synchronously, with no hub + * round-trip. Without it `getProject` fails for the multi-second window before + * the resource pull materializes the project, and every project route + * (conversations, events SSE, tabs, files) 404s. The web answers those 404s + * with retry storms + EventSource reconnects — the request flood that made a + * shared project take tens of seconds to open. The post-pull + * `registerPulledProject` overwrites the placeholder name with the real one; + * this is a no-op once the project is known locally. + */ + function ensureSharedProjectPlaceholder(projectId: string): void { + if (!projectStore || projectStore.has(projectId)) return; + const now = Date.now(); + projectStore.register({ + id: projectId, + name: PULLED_PROJECT_PLACEHOLDER_NAME, + skillId: null, + designSystemId: null, + createdAt: now, + updatedAt: now, + }); + // Stamp the record as an unmaterialized placeholder so no publish path + // ever treats its (empty) content directory as content authority until a + // pull lands real hub content (the recvqzaDvUU6B3 fresh-install wipe + // guard — see collab/shared-project-placeholder.ts). + markSharedProjectPlaceholder?.(projectId, true); + } + + /** + * Invariant: opening a shared project whose only local record is an + * unmaterialized placeholder starts that project's content pull on the very + * request that discovered it — for EVERY viewer, owner or member. + * + * Neither side of the product had another way to start it on first open. The + * web's auto-pull is gated on `publishedVersion` advancing past its cursor, + * and a fresh daemon's first status response cannot carry a published head: + * `collab.publishedVersion()` reads an in-process map that has never been + * written, and the real hub head is fetched fire-and-forget into + * `headEnrichmentCache` for a LATER poll to consume. So a brand-new member + * who opened a shared project on a fresh install got a placeholder, an empty + * file list, and no pull at all — materialization arrived only whenever a + * proactive lane (hub push / reconnect catch-up / the recovery floor) next + * fired, which is why the content appeared to show up "only on the second + * open". + * + * Fire-and-forget by design: the pull replaces the whole project tree and + * must never hold the status response open. Callers surface progress through + * `awaitingFirstMaterialization` + `contentTransferState` instead. + */ + function materializePlaceholderOnOpen( + projectId: string, + req: Parameters<typeof pullAccessForRequest>[1], + viewer: { + ownerMemberId: string | null; + callerIsOwner: boolean; + }, + ): void { + if (!viewer.ownerMemberId) return; + void (async () => { + // Status may have been authorized by the bounded read lease. Pulling and + // materializing bytes is a mutation, so deliberately omit the captured + // status identity and force `pullAccessForRequest` through fresh + // `verifyWorkspaceRequest` authority. + const { principal: resourcePrincipal, scope } = await pullAccessForRequest( + projectId, + req, + viewer.ownerMemberId, + ); + if (!scope) return; + try { + await pullSharedProjectCoalesced(projectId, resourcePrincipal, scope); + } catch (error) { + // Retracted-share heal (飞书 recvqA6qhV7St1): the catalog names this + // caller as the project's owner, yet the published pull answered + // `resource_not_found` — the hub's tombstone gate. A live share can + // never produce that pair; it is the hub-authoritative signature of a + // HALF-LANDED retraction: an unshare's `resource remove` landed but + // its `team-projects remove` did not, leaving a dangling catalog row. + // On a fresh data root there is no `cloudTombstonedAt` left to + // suppress it, so the retracted project revives as a ghost team card + // for every member (reproduced live on the feature-test hub, + // 2026-07-27). Finish the retraction from the hub's own state instead + // of trusting local memory: complete the catalog removal (unpublish is + // idempotent against the tombstone), retire the contentless + // placeholder this open registered, and drop the cached listing. + // + // Owner-only: retracting a share is the sharer's action. A member who + // hits the same tombstone has merely lost access and must not unshare + // anyone's project on their behalf. + if (!viewer.callerIsOwner) throw error; + if (!isRetractedHubResourceError(error)) throw error; + if ( + !projectStore?.get || + !isUnmaterializedSharedPlaceholder(projectStore.get(projectId)) + ) return; + await requestTeamUnshare(projectId, resourcePrincipal ?? undefined); + retireUnmaterializedSharedPlaceholder?.(projectId); + invalidateTeamProjectCatalog?.(); + } + })().catch(() => undefined); + } + + app.post('/api/projects/:id/collab/changed', async (req, res) => { + const projectId = req.params.id; + const authorization = await verifiedPublishPrincipalForRequest(req, projectId); + if (!authorization.ok) { + return res.status(authorization.status).json({ + error: authorization.error, + ...(authorization.message ? { message: authorization.message } : {}), + ...(authorization.retryable ? { retryable: true } : {}), + }); + } + scheduler.notifyChanged(projectId, 'change', authorization.principal); + return res.json({ ok: true }); + }); + + app.post('/api/projects/:id/collab/publish', async (req, res) => { + const projectId = req.params.id; + const authorization = await verifiedPublishPrincipalForRequest(req, projectId); + if (!authorization.ok) { + return res.status(authorization.status).json({ + error: authorization.error, + ...(authorization.message ? { message: authorization.message } : {}), + ...(authorization.retryable ? { retryable: true } : {}), + }); + } + scheduler.notifyChanged(projectId, 'run', authorization.principal); + scheduler.runBoundary(projectId, authorization.principal); + return res.json({ ok: true }); + }); + + app.post(/^\/api\/projects\/([^/]+)\/files\/(.+)\/publish-public$/u, async (req, res) => { + const params = req.params as unknown as { 0?: string; 1?: string }; + const projectId = String(params[0] ?? ''); + const filePath = normalizePublicFilePath(String(params[1] ?? '')); + if (!projectId || !filePath) { + return res.status(400).json({ error: 'invalid_file_path' }); + } + const verification = await verifiedWorkspaceContextForRequest(req, projectId); + if (!verification.ok) { + return sendWorkspaceVerificationFailure(res, verification); + } + const verifiedContext = verification.context; + const principal = publicFilePrincipal(verifiedContext); + if (!verifiedContext || !principal) { + return res.status(409).json(workspaceIdentityRequiredBody()); + } + if (!await canShareProjectsForRequest(req, verifiedContext)) { + return res.status(403).json({ error: 'WORKSPACE_PROJECT_SHARE_DENIED' }); + } + const sharedProjectResult = await resolveSharedProjectForPublicFile( + resolveSharedProject, + projectId, + verifiedContext, + principal, + ); + if (!sharedProjectResult.ok) { + return res.status(503).json({ error: 'WORKSPACE_PROJECT_OWNERSHIP_UNAVAILABLE' }); + } + const sharedProject = sharedProjectResult.project; + if (sharedProject?.ownerMemberId && sharedProject.ownerMemberId !== principal.memberId) { + return res.status(403).json({ error: 'WORKSPACE_PROJECT_PUBLISH_DENIED' }); + } + const baseUrl = publicResourceHubBaseUrl(); + if (!baseUrl) { + return res.status(502).json({ error: 'PUBLIC_FILE_URL_UNAVAILABLE' }); + } + if (!resolveProjectDir) { + return res.status(500).json({ error: 'PROJECT_DIR_UNAVAILABLE' }); + } + + const projectDir = await resolveProjectDir(projectId); + let data: Buffer; + try { + const sourceFile = await resolvePublicSourceFile(projectDir, filePath); + data = await readFile(sourceFile); + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code; + return res.status(code === 'ENOENT' ? 404 : 400).json({ + error: code === 'ENOENT' ? 'FILE_NOT_FOUND' : 'FILE_UNAVAILABLE', + }); + } + + const resourceId = publicFileResourceIdFor(projectId, filePath, principal); + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'od-public-file-')); + try { + const targetFile = path.join(tempDir, filePath); + await mkdir(path.dirname(targetFile), { recursive: true }); + await writeFile(targetFile, data); + const metadata = { + source: 'open-design', + projectId, + fileName: filePath, + }; + await runVelaResourceCommand([ + 'push', + PUBLIC_FILE_RESOURCE_KIND, + resourceId, + tempDir, + '--ref', + PUBLIC_FILE_REF, + '--metadata-json', + JSON.stringify(metadata), + '--json', + ], principal.teamId); + const snapshot = parseVelaResourceSnapshot(await runVelaResourceCommand([ + 'snapshot', + resourceId, + '--ref', + PUBLIC_FILE_REF, + '--name', + path.basename(filePath), + '--json', + ], principal.teamId)); + if (!snapshot) { + return res.status(502).json({ error: 'PUBLIC_SNAPSHOT_UNAVAILABLE' }); + } + const publication = { + url: publicSnapshotFileUrl(baseUrl, snapshot.slug, filePath), + slug: snapshot.slug, + fileName: filePath, + }; + publicFilePublications.set(publicFilePublicationKey(projectId, filePath, principal), publication); + return res.json(publication); + } catch (error) { + console.warn('[od] failed to publish public project file:', error); + return res.status(502).json({ error: 'PUBLIC_FILE_PUBLISH_UNAVAILABLE' }); + } finally { + await rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } + }); + + app.delete(/^\/api\/projects\/([^/]+)\/files\/(.+)\/publish-public$/u, async (req, res) => { + const params = req.params as unknown as { 0?: string; 1?: string }; + const projectId = String(params[0] ?? ''); + const filePath = normalizePublicFilePath(String(params[1] ?? '')); + const slug = typeof (req.body as { slug?: unknown } | undefined)?.slug === 'string' + ? (req.body as { slug: string }).slug.trim() + : ''; + if (!projectId || !filePath || !slug) { + return res.status(400).json({ error: 'invalid_public_file' }); + } + const verification = await verifiedWorkspaceContextForRequest(req, projectId); + if (!verification.ok) { + return sendWorkspaceVerificationFailure(res, verification); + } + const verifiedContext = verification.context; + const principal = publicFilePrincipal(verifiedContext); + if (!verifiedContext || !principal) { + return res.status(409).json(workspaceIdentityRequiredBody()); + } + if (!await canShareProjectsForRequest(req, verifiedContext)) { + return res.status(403).json({ error: 'WORKSPACE_PROJECT_SHARE_DENIED' }); + } + const sharedProjectResult = await resolveSharedProjectForPublicFile( + resolveSharedProject, + projectId, + verifiedContext, + principal, + ); + if (!sharedProjectResult.ok) { + return res.status(503).json({ error: 'WORKSPACE_PROJECT_OWNERSHIP_UNAVAILABLE' }); + } + const sharedProject = sharedProjectResult.project; + if (sharedProject?.ownerMemberId && sharedProject.ownerMemberId !== principal.memberId) { + return res.status(403).json({ error: 'WORKSPACE_PROJECT_PUBLISH_DENIED' }); + } + const resourceId = publicFileResourceIdFor(projectId, filePath, principal); + try { + await runVelaResourceCommand([ + 'snapshot-redact', + resourceId, + slug, + '--json', + ], principal.teamId); + publicFilePublications.delete(publicFilePublicationKey(projectId, filePath, principal)); + return res.json({ ok: true, slug, fileName: filePath }); + } catch (error) { + console.warn('[od] failed to unpublish public project file:', error); + return res.status(502).json({ error: 'PUBLIC_FILE_UNPUBLISH_UNAVAILABLE' }); + } + }); + + app.get(/^\/api\/projects\/([^/]+)\/files\/(.+)\/publish-public$/u, async (req, res) => { + const params = req.params as unknown as { 0?: string; 1?: string }; + const projectId = String(params[0] ?? ''); + const filePath = normalizePublicFilePath(String(params[1] ?? '')); + if (!projectId || !filePath) { + return res.status(400).json({ error: 'invalid_file_path' }); + } + const verification = await verifiedWorkspaceContextForRequest(req, projectId); + if (!verification.ok) { + return sendWorkspaceVerificationFailure(res, verification); + } + const verifiedContext = verification.context; + const principal = publicFilePrincipal(verifiedContext); + if (!verifiedContext || !principal) { + return res.status(409).json(workspaceIdentityRequiredBody()); + } + if (!await canShareProjectsForRequest(req, verifiedContext)) { + return res.status(403).json({ error: 'WORKSPACE_PROJECT_SHARE_DENIED' }); + } + const sharedProjectResult = await resolveSharedProjectForPublicFile( + resolveSharedProject, + projectId, + verifiedContext, + principal, + ); + if (!sharedProjectResult.ok) { + return res.status(503).json({ error: 'WORKSPACE_PROJECT_OWNERSHIP_UNAVAILABLE' }); + } + const sharedProject = sharedProjectResult.project; + if (sharedProject?.ownerMemberId && sharedProject.ownerMemberId !== principal.memberId) { + return res.status(403).json({ error: 'WORKSPACE_PROJECT_PUBLISH_DENIED' }); + } + return res.json({ + publication: publicFilePublications.get(publicFilePublicationKey(projectId, filePath, principal)) ?? null, + }); + }); + + app.post('/api/projects/:id/collab/sync-intent', async (req, res) => { + const event = (req.body as { event?: unknown } | undefined)?.event; + if (typeof event !== 'string' || !SYNC_INTENT_EVENTS.has(event as ProjectSyncIntentEvent)) { + return res.status(400).json({ error: 'invalid sync intent event' }); + } + const projectId = req.params.id; + const verification = await verifiedWorkspaceContextForRequest(req, projectId); + if (!verification.ok) { + return sendWorkspaceVerificationFailure(res, verification); + } + const context = verification.context; + const principal = contextToResourceHubPrincipal(context); + + if (event === 'project_team_share_requested') { + if (!principal || !(await canShareProjectsForRequest(req, context))) { + return res.status(403).json({ error: 'WORKSPACE_PROJECT_SHARE_DENIED' }); + } + const sharerMemberId = principal.memberId; + const existingOwnerMemberId = await resolveSharedProjectOwner?.(projectId, { + workspaceId: context!.workspaceId, + workspaceMemberId: context!.workspaceMemberId, + }) ?? null; + if ( + existingOwnerMemberId && + existingOwnerMemberId !== sharerMemberId + ) { + return res.json({ + ok: true, + syncState: 'synced', + publishedVersion: publishedVersion(projectId, principal), + }); + } + let nextPublishedVersion: number | null; + try { + ({ version: nextPublishedVersion } = await requestTeamShare(projectId, principal ?? sharerMemberId)); + } catch (error) { + console.warn('[od] failed to publish team-shared project bytes:', error); + return res.status(502).json({ error: 'TEAM_PROJECT_PUBLISH_UNAVAILABLE' }); + } + if (nextPublishedVersion == null) { + return res.status(502).json({ error: 'TEAM_PROJECT_PUBLISH_UNAVAILABLE' }); + } + deps.onTeamShareStateChanged?.({ + projectId, + principal, + visibility: 'team', + ownerMemberId: sharerMemberId ?? null, + updatedByMemberId: sharerMemberId ?? null, + }); + return res.json({ + ok: true, + syncState: projectSyncState(projectId, principal), + publishedVersion: nextPublishedVersion, + }); + } + + if (event === 'project_team_unshare_requested') { + if (!principal || !(await canShareProjectsForRequest(req, context))) { + return res.status(403).json({ error: 'WORKSPACE_PROJECT_SHARE_DENIED' }); + } + const callerMemberId = principal.memberId; + const remoteOwnerMemberId = await resolveSharedProjectOwner?.(projectId, { + workspaceId: context!.workspaceId, + workspaceMemberId: context!.workspaceMemberId, + }) ?? null; + const ownerMemberId = + remoteOwnerMemberId ?? projectOwnerMemberId(projectId, principal); + if (ownerMemberId && ownerMemberId !== callerMemberId) { + return res.status(403).json({ error: 'WORKSPACE_PROJECT_UNSHARE_DENIED' }); + } + await requestTeamUnshare(projectId, principal); + deps.onTeamShareStateChanged?.({ + projectId, + principal, + visibility: 'personal', + ownerMemberId, + updatedByMemberId: callerMemberId ?? null, + }); + } + + res.json({ ok: true, syncState: projectSyncState(projectId, principal) }); + }); + + /** + * The one shared-project content pull flow, shared verbatim between + * `POST /api/projects/:id/collab/pull` and the daemon-internal handle + * (`CollabSyncRoutesHandle.pullSharedProject`, driven by the hub push + * channel's proactive pull). Extracted so the two entry points cannot + * drift: revocation gate → hub pull → register-on-pull → post-pull + * signals, in that order. + */ + async function pullSharedProjectOnce( + projectId: string, + principal: ResourceHubPrincipal | null, + scope: TeamMirrorPullScope | null, + authorizationWitness?: ProactivePullAuthorizationWitness, + expectedVersion?: number, + authorizedStageInvocation?: AuthorizedProactivePullInvocation, + ): Promise<CollabSyncPullOutcome> { + const profileReceivedAtMs = + authorizedStageInvocation?.profileReceivedAtMs; + type AuthorizedPullTimingPhase = + | 'authorized-stage-started' + | 'authorized-stage-done' + | 'authorized-receipt-validated' + | 'authorized-scope-revalidated' + | 'promotion-started' + | 'promotion-done' + | 'version-persisted'; + const reportAuthorizedPullTiming = ( + phase: AuthorizedPullTimingPhase, + status?: Extract< + CollabSyncPullTimingStatus, + 'pulled' | 'staged' | 'capability-unavailable' | 'failed' + >, + version = expectedVersion, + ): void => { + reportPullTiming({ + phase, + projectId, + ...(version != null ? { version } : {}), + ...(profileReceivedAtMs != null + ? { receivedAtMs: profileReceivedAtMs } + : {}), + atMs: Date.now(), + ...(status ? { status } : {}), + }); + }; + reportPullTiming({ + phase: 'route-started', + projectId, + ...(expectedVersion != null ? { version: expectedVersion } : {}), + ...(profileReceivedAtMs != null + ? { receivedAtMs: profileReceivedAtMs } + : {}), + atMs: Date.now(), + }); + let terminalStatus: 'pulled' | 'revoked' | 'register_failed' | 'threw' = + 'threw'; + let terminalVersion: number | undefined; + const complete = ( + outcome: CollabSyncPullOutcome, + ): CollabSyncPullOutcome => { + terminalStatus = outcome.status; + if (outcome.status === 'pulled' && outcome.version != null) { + terminalVersion = outcome.version; + } + return outcome; + }; + try { + let authoritativeSharedProject: TeamProject | null = null; + let authorizedCapabilityFallbackVersion: number | null = null; + const authorizedPull = deps.authorizedTeamProjectPull; + const hasStageInvocation = authorizedStageInvocation !== undefined; + const hasAuthorizedStageBrand = Boolean( + scope && + isBoundProactivePullInvocation( + authorizedStageInvocation, + { + projectId, + workspaceId: scope.workspaceId, + resourceTeamId: scope.resourceTeamId, + viewerMemberId: scope.viewerMemberId, + ownerMemberId: scope.ownerMemberId, + }, + expectedVersion, + ), + ); + if (hasStageInvocation && !hasAuthorizedStageBrand) { + return complete({ status: 'register_failed' }); + } + if ( + hasAuthorizedStageBrand && + ( + !authorizedStageInvocation || + authorizedStageInvocation.signal.aborted || + !authorizedStageInvocation.isStillExpected() || + !authorizedPull || + !resolvePullDir + ) + ) { + return complete({ status: 'register_failed' }); + } + const useAuthorizedStage = hasAuthorizedStageBrand; + if ( + useAuthorizedStage && + scope && + authorizedPull && + resolvePullDir && + authorizedStageInvocation && + expectedVersion != null + ) { + const authorizedInvocationIsStillValid = async (): Promise<boolean> => { + if ( + !deps.verifyWorkspaceScope || + !isAuthorizedProactivePullInvocation( + authorizedStageInvocation, + { + projectId, + workspaceId: scope.workspaceId, + resourceTeamId: scope.resourceTeamId, + viewerMemberId: scope.viewerMemberId, + ownerMemberId: scope.ownerMemberId, + }, + expectedVersion, + ) + ) { + return false; + } + try { + const scopeStillAuthorized = await deps.verifyWorkspaceScope(scope); + return ( + scopeStillAuthorized && + isAuthorizedProactivePullInvocation( + authorizedStageInvocation, + { + projectId, + workspaceId: scope.workspaceId, + resourceTeamId: scope.resourceTeamId, + viewerMemberId: scope.viewerMemberId, + ownerMemberId: scope.ownerMemberId, + }, + expectedVersion, + ) + ); + } catch { + return false; + } + }; + const shouldRetryStaleReceipt = async ( + error: unknown, + attempt: number, + ): Promise<boolean> => { + if ( + attempt !== 0 || + !isAuthorizedTeamProjectPullReceiptExpired(error) || + authorizedStageInvocation.signal.aborted + ) { + return false; + } + return authorizedInvocationIsStillValid(); + }; + for ( + let authorizedAttempt = 0; + authorizedAttempt < 2; + authorizedAttempt += 1 + ) { + if (!(await authorizedInvocationIsStillValid())) { + return complete({ status: 'register_failed' }); + } + let staged: StagedAuthorizedTeamProjectPull | null = null; + reportAuthorizedPullTiming('authorized-stage-started'); + try { + staged = await (authorizedPull.stage ?? stageAuthorizedTeamProjectPull)({ + projectId, + liveDir: resolvePullDir(projectId), + scope, + expectedVersion, + signal: authorizedStageInvocation.signal, + }); + reportAuthorizedPullTiming('authorized-stage-done', 'staged'); + } catch (error) { + const capabilityUnavailable = + isAuthorizedTeamProjectPullUnavailable(error); + reportAuthorizedPullTiming( + 'authorized-stage-done', + capabilityUnavailable ? 'capability-unavailable' : 'failed', + ); + if (!capabilityUnavailable) { + if (await shouldRetryStaleReceipt(error, authorizedAttempt)) { + continue; + } + console.warn('[od] authorized proactive team pull failed closed:', { + projectId, + version: expectedVersion, + ...errorLogFields(error), + }); + return complete({ status: 'register_failed' }); + } + // Old CLIs can materialize successfully while returning no version. + // The event version is only a proven lower bound after the legacy + // pull and every post-pull authorization/registration gate succeeds; + // it is never persisted as an authorized receipt. + authorizedCapabilityFallbackVersion = expectedVersion; + } + if (staged) { + let localRecordChanged = false; + let promotionStarted = false; + let retryAuthorizedStage = false; + let cleanupSucceeded = true; + try { + validateAuthorizedTeamProjectPullReceipt(staged.receipt, { + projectId, + scope, + expectedVersion, + }); + reportAuthorizedPullTiming('authorized-receipt-validated'); + const prepared = await preparePulledProjectRegistration( + projectId, + scope, + staged.stageDir, + ); + if (!(await authorizedInvocationIsStillValid())) { + throw new Error( + 'authorized team project scope changed before promotion', + ); + } + reportAuthorizedPullTiming('authorized-scope-revalidated'); + reportAuthorizedPullTiming('promotion-started'); + promotionStarted = true; + const result = await ( + authorizedPull.promote ?? promoteAuthorizedTeamProjectStage + )({ + receipt: staged.receipt, + liveDir: resolvePullDir(projectId), + stageDir: staged.stageDir, + expectedStageIdentity: staged.identity, + journalDir: authorizedPull.journalDir, + isScopeStillAuthorized: + authorizedStageInvocation.isStillExpected, + isExpectedVersion: + authorizedStageInvocation.isStillExpected, + validateReceipt: () => + validateAuthorizedTeamProjectPullReceipt(staged!.receipt, { + projectId, + scope, + expectedVersion, + }), + commit: () => { + const committed = { + localRecordChanged: registerPreparedPulledProject( + prepared, + scope, + null, + staged!.receipt, + ), + }; + reportAuthorizedPullTiming( + 'version-persisted', + undefined, + staged!.receipt.version, + ); + return committed; + }, + onPostCommitCleanupError: (error) => { + console.warn( + '[od] authorized team project committed; deferred promotion cleanup:', + { + projectId, + version: expectedVersion, + ...errorLogFields(error), + }, + ); + }, + }); + reportAuthorizedPullTiming('promotion-done', 'pulled'); + localRecordChanged = result.localRecordChanged; + } catch (error) { + if (promotionStarted) { + reportAuthorizedPullTiming('promotion-done', 'failed'); + } + const versionStillExpected = + authorizedStageInvocation.isStillExpected(); + const reason = !versionStillExpected + ? 'version-superseded' + : 'promotion-failed'; + retryAuthorizedStage = await shouldRetryStaleReceipt( + error, + authorizedAttempt, + ); + if (!retryAuthorizedStage) { + console.warn('[od] failed to promote authorized team project', { + projectId, + version: expectedVersion, + reason, + ...errorLogFields(error), + }); + return complete({ status: 'register_failed' }); + } + } finally { + try { + await staged.cleanup(); + } catch (error) { + cleanupSucceeded = false; + console.warn('[od] failed to clean authorized team project stage:', { + projectId, + version: expectedVersion, + ...errorLogFields(error), + }); + } + } + if (retryAuthorizedStage) { + if (!cleanupSucceeded) { + return complete({ status: 'register_failed' }); + } + continue; + } + notifyFilesChanged?.(projectId); + if (localRecordChanged) notifyProjectMetadataChanged?.(projectId); + markTeamProjectRevoked?.(projectId, false); + // Real hub content is on disk and registered — the local record is no + // longer an unmaterialized placeholder, so publishing may resume. + markSharedProjectPlaceholder?.(projectId, false); + return complete({ + status: 'pulled', + version: staged.receipt.version, + }); + } + break; + } + } + const reuseInitialAuthorization = Boolean( + scope && + isFreshProactivePullAuthorizationWitness(authorizationWitness, { + projectId, + workspaceId: scope.workspaceId, + resourceTeamId: scope.resourceTeamId, + viewerMemberId: scope.viewerMemberId, + ownerMemberId: scope.ownerMemberId, + }, expectedVersion), + ); + if (reuseInitialAuthorization) { + reportPullTiming({ + phase: 'initial-authorization-reused', + projectId, + version: authorizationWitness!.version, + atMs: Date.now(), + }); + } else { + // The initial active-scope check and authoritative catalog lookup are + // independent, read-only safety gates. A fresh, branded proactive + // witness already ran both immediately before this internal call; HTTP + // callers have no path to provide one and always execute these gates. + const initialSharedProjectRead = resolveSharedProject + ? Promise.resolve() + .then(() => resolveSharedProject(projectId, scope)) + .then( + (project) => ({ ok: true as const, project }), + () => ({ ok: false as const }), + ) + : null; + if (scope && !(await capturedScopeIsStillAuthorized(scope))) { + return complete({ status: 'register_failed' }); + } + // Revocation gate: a project may only be pulled while it is still shared + // to the caller's team. Transient uncertainty fails closed for scoped + // pulls; the post-transport gate below always repeats this uncached. + if (initialSharedProjectRead) { + let stillShared = true; + const initialSharedProject = await initialSharedProjectRead; + if (initialSharedProject.ok) { + authoritativeSharedProject = initialSharedProject.project; + stillShared = authoritativeSharedProject != null && + (!scope || authoritativeSharedProject.ownerMemberId === scope.ownerMemberId); + } else { + if (scope) return complete({ status: 'register_failed' }); + stillShared = true; + } + if (scope && !(await capturedScopeIsStillAuthorized(scope))) { + return complete({ status: 'register_failed' }); + } + if (!stillShared) { + // The project has left the team: mark the stale local mirror revoked + // so its files stop being served (files remain on disk). + markTeamProjectRevoked?.(projectId, true); + return complete({ status: 'revoked' }); + } + } else if (scope) { + return complete({ status: 'register_failed' }); + } + } + reportPullTiming({ + phase: 'transport-invoke', + projectId, + atMs: Date.now(), + }); + let result: Awaited<ReturnType<typeof pullLatest>>; + try { + result = await pullLatest(projectId, principal); + } catch (error) { + reportPullTiming({ + phase: 'transport-done', + projectId, + atMs: Date.now(), + status: 'threw', + }); + throw error; + } + reportPullTiming({ + phase: 'transport-done', + projectId, + ...(result.version != null + ? { version: result.version } + : authorizedCapabilityFallbackVersion != null + ? { version: authorizedCapabilityFallbackVersion } + : {}), + atMs: Date.now(), + }); + const materializedVersion = + result.version ?? authorizedCapabilityFallbackVersion; + if (materializedVersion !== null) { + let prepared: PreparedPulledProjectRegistration | null = null; + try { + prepared = await preparePulledProjectRegistration(projectId, scope); + } catch (error) { + console.warn('[od] failed to prepare pulled team project:', error); + return complete({ status: 'register_failed' }); + } + reportPullTiming({ + phase: 'registration-prepared', + projectId, + version: materializedVersion, + atMs: Date.now(), + }); + + // The initial catalog result only authorized starting the transfer. The + // owner may unshare while bytes are in flight, so scoped materialization + // requires a second uncached authoritative read after every other async + // metadata operation has completed. + if (scope) { + try { + authoritativeSharedProject = await resolveSharedProject!(projectId, scope); + } catch { + return complete({ status: 'register_failed' }); + } + if (!authoritativeSharedProject) { + markTeamProjectRevoked?.(projectId, true); + return complete({ status: 'revoked' }); + } + if (authoritativeSharedProject.ownerMemberId !== scope.ownerMemberId) { + return complete({ status: 'register_failed' }); + } + reportPullTiming({ + phase: 'catalog-revalidated', + projectId, + version: materializedVersion, + atMs: Date.now(), + }); + // Keep this check adjacent to the synchronous SQLite transaction. + // Nothing below may await before materializeTeamMirror revalidates the + // binding and commits it. + if (!(await capturedScopeIsStillAuthorized(scope))) { + return complete({ status: 'register_failed' }); + } + reportPullTiming({ + phase: 'scope-revalidated', + projectId, + version: materializedVersion, + atMs: Date.now(), + }); + } else if (resolveSharedProject) { + try { + authoritativeSharedProject = await resolveSharedProject(projectId, null); + } catch { + authoritativeSharedProject = null; + } + } + + let localRecordChanged = false; + try { + localRecordChanged = registerPreparedPulledProject( + prepared, + scope, + authoritativeSharedProject, + ); + } catch (error) { + console.warn('[od] failed to register pulled team project:', error); + return complete({ status: 'register_failed' }); + } + reportPullTiming({ + phase: 'mirror-materialized', + projectId, + version: materializedVersion, + atMs: Date.now(), + }); + // Persist the exact version before notifying readers. A file-change + // subscriber may immediately re-check /collab/status; it must never + // observe the new bytes paired with the previous durable cursor. + if (scope && deps.writeMaterializedVersion) { + try { + reportPullTiming({ + phase: 'version-write-started', + projectId, + version: materializedVersion, + atMs: Date.now(), + }); + await deps.writeMaterializedVersion( + projectId, + scope, + materializedVersion, + ); + reportPullTiming({ + phase: 'persisted', + projectId, + version: materializedVersion, + atMs: Date.now(), + }); + try { + await deps.onLegacyPullMaterialized?.( + projectId, + scope, + materializedVersion, + ); + } catch (error) { + // The bytes, mirror binding, and durable cursor are already + // committed. Coordinator notification is recoverable from that + // cursor on its next retry and must never turn success into 502. + console.warn( + '[od] failed to notify proactive coordinator of legacy team pull:', + { + projectId, + version: materializedVersion, + ...errorLogFields(error), + }, + ); + } + } catch (error) { + console.warn('[od] failed to persist pulled team project version:', error); + return complete({ status: 'register_failed' }); + } + } + // The pull already materialized new bytes on disk at this point — + // notify now rather than relying on the project's chokidar watcher, + // which the pull's directory-replace can silently orphan (see + // `notifyFilesChanged`'s doc comment). A currently-open FileViewer tab + // for this project refreshes on the same `file-changed` path a real + // local edit would take. + notifyFilesChanged?.(projectId); + if (localRecordChanged) { + // The register above swapped the "共享项目" placeholder record for + // the real name (or first-registered the record): push the existing + // `project-metadata-changed` thin signal so the open project view + // re-reads the record and the sidebar/tab title follows without a + // manual reload (recvqhwv6RPU1j). + notifyProjectMetadataChanged?.(projectId); + } + // Real hub content is on disk and registered — the local record is no + // longer an unmaterialized placeholder, so publishing may resume. + markSharedProjectPlaceholder?.(projectId, false); + } + // A successful pull means the project is shared again (or still is): clear + // any prior revocation so its files are served normally. + markTeamProjectRevoked?.(projectId, false); + return complete({ status: 'pulled', version: materializedVersion }); + } finally { + reportPullTiming({ + phase: 'route-completed', + projectId, + ...(terminalVersion != null ? { version: terminalVersion } : {}), + ...(profileReceivedAtMs != null + ? { receivedAtMs: profileReceivedAtMs } + : {}), + atMs: Date.now(), + status: terminalStatus, + }); + } + } + + // In-flight pulls keyed by project + resource-hub scope. A hub-event + // proactive pull and a member web's poll-triggered POST that race each + // other coalesce onto ONE materialization (the `vela resource pull` + // transport replaces the whole project directory, so a duplicate pull is a + // full-tree transfer, not a cheap no-op). The scope key includes the + // principal's team + member ids because the same project can be shared + // under more than one scope (see `scopedProjectKey` in collab/runtime.ts) — + // only identically-routed pulls may share a result. + const pullsInFlight = new Map<string, Promise<CollabSyncPullOutcome>>(); + const projectPullTails = new Map<string, Promise<void>>(); + + function pullSharedProjectCoalesced( + projectId: string, + principal: ResourceHubPrincipal | null, + scope: TeamMirrorPullScope | null, + authorizationWitness?: ProactivePullAuthorizationWitness, + expectedVersion?: number, + authorizedStageInvocation?: AuthorizedProactivePullInvocation, + ): Promise<CollabSyncPullOutcome> { + const mutationKey = JSON.stringify([ + projectId, + principal?.teamId ?? null, + principal?.memberId ?? null, + scope?.workspaceId ?? null, + scope?.resourceTeamId ?? null, + scope?.viewerMemberId ?? null, + scope?.ownerMemberId ?? null, + ]); + const hasInvalidAuthorizedInvocation = + authorizedStageInvocation !== undefined && + !isAuthorizedProactivePullInvocation( + authorizedStageInvocation, + { + projectId, + workspaceId: scope?.workspaceId ?? '', + resourceTeamId: scope?.resourceTeamId ?? '', + viewerMemberId: scope?.viewerMemberId ?? '', + ownerMemberId: scope?.ownerMemberId ?? '', + }, + expectedVersion, + ); + // Valid legacy and authorized callers share one exact-scope mutation key: + // both replace the same live project tree. Invalid/stale authorized + // invocations stay isolated so they can only fail closed, never borrow a + // successful legacy result. + const key = hasInvalidAuthorizedInvocation + ? JSON.stringify([mutationKey, 'invalid-stage']) + : mutationKey; + const existing = pullsInFlight.get(key); + if (existing) { + if (!authorizedStageInvocation) return existing; + return existing.then((outcome) => + isAuthorizedProactivePullInvocation( + authorizedStageInvocation, + { + projectId, + workspaceId: scope?.workspaceId ?? '', + resourceTeamId: scope?.resourceTeamId ?? '', + viewerMemberId: scope?.viewerMemberId ?? '', + ownerMemberId: scope?.ownerMemberId ?? '', + }, + expectedVersion, + ) + ? outcome + : { status: 'register_failed' }, + ); + } + const transferToken = scope + ? deps.beginContentTransfer?.(projectId, scope, expectedVersion) + : undefined; + const previous = projectPullTails.get(projectId) ?? Promise.resolve(); + let run!: Promise<CollabSyncPullOutcome>; + run = (async () => { + let outcome: CollabSyncPullOutcome | null = null; + try { + await previous.catch(() => undefined); + outcome = await pullSharedProjectOnce( + projectId, + principal, + scope, + authorizationWitness, + expectedVersion, + authorizedStageInvocation, + ); + return outcome; + } finally { + if (scope && transferToken) { + deps.finishContentTransfer?.( + projectId, + scope, + transferToken, + outcome?.status === 'pulled' + ? outcome.version ?? expectedVersion + : expectedVersion, + ); + } + if (pullsInFlight.get(key) === run) { + pullsInFlight.delete(key); + } + } + })(); + const tail = run.then( + () => undefined, + () => undefined, + ); + projectPullTails.set(projectId, tail); + void tail.finally(() => { + if (projectPullTails.get(projectId) === tail) { + projectPullTails.delete(projectId); + } + }); + pullsInFlight.set(key, run); + return run; + } + + app.post('/api/projects/:id/collab/pull', async (req, res) => { + const projectId = req.params.id; + const { verification, principal, scope } = + await pullAccessForRequest(projectId, req); + if (!verification.ok) { + return sendWorkspaceVerificationFailure(res, verification); + } + if (!principal || !scope) { + return res.status(403).json({ error: 'WORKSPACE_PROJECT_PULL_DENIED' }); + } + const outcome = await pullSharedProjectCoalesced(projectId, principal, scope); + if (outcome.status === 'revoked') { + return res.status(403).json({ error: 'WORKSPACE_PROJECT_PULL_DENIED' }); + } + if (outcome.status === 'register_failed') { + return res.status(502).json({ error: 'TEAM_PROJECT_PULL_REGISTER_UNAVAILABLE' }); + } + res.json({ ok: true, version: outcome.version }); + }); + + app.get('/api/projects/:id/collab/status', async (req, res) => { + const projectId = req.params.id; + if (isTeamProjectRevoked?.(projectId)) { + return res.status(404).json({ error: 'PROJECT_NOT_FOUND' }); + } + const { + verification, + context, + principal, + workspaceId: resolvedWorkspaceId, + } = await statusIdentityForRequest(projectId, req); + if (!verification.ok) { + return sendWorkspaceVerificationFailure(res, verification); + } + if (!context || !resolvedWorkspaceId) { + return res.status(403).json({ error: 'WORKSPACE_PROJECT_STATUS_DENIED' }); + } + let syncState = projectSyncState(projectId, principal); + let ownerMemberId = projectOwnerMemberId(projectId, principal); + let ownerDisplayName: string | undefined; + let ownerRole: 'owner' | 'admin' | 'member' | undefined; + // Resolve ownership first through the CACHED hub owner lookup. This decides + // whether the project is shared at all — and a project that is local-only AND + // unowned on the hub is a genuine personal project with no hub-published head. + // Answering its version from local state lets us skip the uncached ~2s + // publishedHead round-trip that otherwise ran on every status poll. That hub + // call was the reason a member's OWN project flashed the "shared read-only" + // notice for seconds after opening: the front end fails closed until + // /collab/status confirms ownership, so a slow status made the flash long. + const statusOwnerResolver = + resolveSharedProjectOwnerForStatus ?? resolveSharedProjectOwner; + if (ownerMemberId == null && statusOwnerResolver) { + try { + const hubOwner = + resolvedWorkspaceId && principal?.memberId + ? await statusOwnerResolver(projectId, { + workspaceId: resolvedWorkspaceId, + workspaceMemberId: principal.memberId, + }) + : null; + if (hubOwner != null) { + if (syncState === 'local_only') syncState = 'synced'; + ownerMemberId = hubOwner; + } + } catch { + // Hub unavailable: fall back to the local state. + } + } + // The caller owns the project when the resolved owner id matches their own + // member id. The owner is the single writer of their own project: the front + // end shows them an editable surface (not the "shared by X" banner) and they + // never auto-pull, so they need NEITHER the owner display-name directory + // lookup NOR the hub published-head round-trip. Both are uncached ~1-3s vela + // calls, and running them made a member's own shared project sit in the + // fail-closed "shared read-only" state (disabled history/share, disabled + // composer) for tens of seconds before /collab/status confirmed ownership. + const callerIsOwner = + ownerMemberId != null && principal?.memberId != null && ownerMemberId === principal.memberId; + // Anyone opening a shared project absent from this daemon's local DB needs + // the placeholder so the project's other routes stop 404ing while the pull + // runs (see ensureSharedProjectPlaceholder). This covers BOTH a member + // viewing someone else's shared project AND an owner opening their OWN shared + // project that was created/shared on another machine (or attributed to them + // by a smoke test) and never materialized here: the owner never auto-pulls, + // so without this its conversations/events/tabs 404 forever and the left pane + // hangs for a minute. ensureSharedProjectPlaceholder no-ops once the project + // is known locally, so an owner's normal local project is untouched. The web + // polls /collab/status on open, so this fires before the conversations/events + // retry storm builds up. + if (ownerMemberId) { + ensureSharedProjectPlaceholder(projectId); + } + // Whether this daemon's only local record for the project is still an + // unmaterialized placeholder. Purely local and synchronous — it needs no + // hub round-trip, which is exactly why it is the signal the client can act + // on from the FIRST status response (see `awaitingFirstMaterialization` on + // CollabSyncStatusResponse). + const awaitingFirstMaterialization = Boolean( + projectStore?.get && isUnmaterializedSharedPlaceholder(projectStore.get(projectId)), + ); + if (awaitingFirstMaterialization) { + materializePlaceholderOnOpen(projectId, req, { + ownerMemberId, + callerIsOwner, + }); + } + // A verified local mirror binding is enough to return shared identity and + // unlock presence immediately. The owner-name directory and published-head + // calls are remote enrichment: neither may hold this status response open. + // Cache them by the exact viewer/team/owner/project tuple so a later poll can + // consume the result without leaking it across workspace scopes. Unknown local + // ownership still fails closed above; this path never guesses an owner. The + // web's default status poll is 5s, so settled enrichment becomes visible on + // the next poll (<=5s); this local-first fix does not add another SSE contract. + const needsHubHead = (syncState !== 'local_only' || ownerMemberId != null) && !callerIsOwner; + const enrichmentKey = JSON.stringify([ + resolvedWorkspaceId ?? '', + principal?.teamId ?? '', + principal?.memberId ?? '', + ownerMemberId ?? '', + projectId, + ]); + const cachedOwnerName = readLruEntry(ownerEnrichmentCache, enrichmentKey); + const ownerNameEntry = cachedOwnerName?.entry ?? null; + if (ownerNameEntry) { + ownerDisplayName = ownerNameEntry.displayName; + ownerRole = ownerNameEntry.role; + } + if ( + ownerMemberId && + resolvedWorkspaceId && + context && + principal && + !callerIsOwner && + resolveOwnerDisplayName && + (!cachedOwnerName || Date.now() - cachedOwnerName.resolvedAt >= OWNER_ENRICHMENT_TTL_MS) && + !ownerEnrichmentInFlight.has(enrichmentKey) + ) { + const refreshOwner = resolveOwnerDisplayName(ownerMemberId, context) + .then((entry) => { + writeLruEntry(ownerEnrichmentCache, enrichmentKey, { + entry, + resolvedAt: Date.now(), + }); + }) + .catch(() => undefined) + .finally(() => { + if (ownerEnrichmentInFlight.get(enrichmentKey) === refreshOwner) { + ownerEnrichmentInFlight.delete(enrichmentKey); + } + }); + ownerEnrichmentInFlight.set(enrichmentKey, refreshOwner); + } + + let headResult = + readLruEntry(headEnrichmentCache, enrichmentKey) ?? + { head: publishedVersion(projectId, principal), scope: null }; + if (needsHubHead && !headEnrichmentInFlight.has(enrichmentKey)) { + const expectedWorkspaceId = resolvedWorkspaceId; + const expectedPrincipal = principal; + const expectedOwnerMemberId = ownerMemberId; + const refreshHead = (async () => { + const { principal: resourcePrincipal, scope } = + await pullAccessForRequest( + projectId, + req, + expectedOwnerMemberId, + { + principal: expectedPrincipal, + workspaceId: expectedWorkspaceId, + }, + ); + if ( + !scope || + !expectedPrincipal || + scope.workspaceId !== expectedWorkspaceId || + scope.resourceTeamId !== expectedPrincipal.teamId || + scope.viewerMemberId !== expectedPrincipal.memberId || + scope.ownerMemberId !== expectedOwnerMemberId + ) { + return; + } + const head = await publishedHead(projectId, resourcePrincipal); + writeLruEntry(headEnrichmentCache, enrichmentKey, { head, scope }); + })() + .catch(() => undefined) + .finally(() => { + if (headEnrichmentInFlight.get(enrichmentKey) === refreshHead) { + headEnrichmentInFlight.delete(enrichmentKey); + } + }); + headEnrichmentInFlight.set(enrichmentKey, refreshHead); + } + let materializedVersion: number | null = null; + if (headResult.scope && deps.readMaterializedVersion) { + try { + materializedVersion = + deps.readMaterializedVersion(projectId, headResult.scope) ?? null; + } catch { + materializedVersion = null; + } + } + const transferScope = + resolvedWorkspaceId + && principal + && ownerMemberId + ? { + workspaceId: resolvedWorkspaceId, + resourceTeamId: principal.teamId, + viewerMemberId: principal.memberId, + ownerMemberId, + } + : null; + res.json({ + publishedVersion: headResult.head, + materializedVersion, + contentTransferState: + transferScope + ? deps.readContentTransferState?.(projectId, transferScope) ?? null + : null, + awaitingFirstMaterialization, + syncState, + ownerMemberId, + ...(ownerDisplayName ? { ownerDisplayName } : {}), + ...(ownerRole ? { ownerRole } : {}), + }); + }); + + return { + async pullSharedProject( + projectId: string, + scope: TeamMirrorPullScope, + authorizationWitness?: ProactivePullAuthorizationWitness, + expectedVersion?: number, + authorizedStageInvocation?: AuthorizedProactivePullInvocation, + ): Promise<CollabSyncPullOutcome> { + const principal: ResourceHubPrincipal = { + teamId: scope.resourceTeamId, + memberId: scope.ownerMemberId, + role: 'member', + lifecycleState: 'active', + workspaceType: 'team', + }; + return pullSharedProjectCoalesced( + projectId, + principal, + scope, + authorizationWitness, + expectedVersion, + authorizedStageInvocation, + ); + }, + }; +} diff --git a/apps/daemon/src/routes/deploy.ts b/apps/daemon/src/routes/deploy.ts index 869ea0e6fe2..463acf6ee39 100644 --- a/apps/daemon/src/routes/deploy.ts +++ b/apps/daemon/src/routes/deploy.ts @@ -1,7 +1,10 @@ import type { Express } from 'express'; import type { RouteDeps } from '../server-context.js'; +import type { AuthorizeProjectRequest } from '../collab/project-request-authority.js'; -export interface RegisterDeployRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'ids' | 'deploy' | 'projectStore'> {} +export interface RegisterDeployRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'ids' | 'deploy' | 'projectStore'> { + authorizeProjectRequest: AuthorizeProjectRequest; +} export function registerDeployRoutes(app: Express, ctx: RegisterDeployRoutesDeps) { const { db } = ctx; @@ -58,8 +61,12 @@ export function registerDeployRoutes(app: Express, ctx: RegisterDeployRoutesDeps } }); - app.get('/api/projects/:id/deployments', (req, res) => { + app.get('/api/projects/:id/deployments', async (req, res) => { try { + if (!getProject(db, req.params.id)) { + return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + } + if (!await ctx.authorizeProjectRequest(req, res, req.params.id, { mode: 'read' })) return; /** @type {import('@open-design/contracts').ProjectDeploymentsResponse} */ const body = { deployments: publicDeployments(listDeployments(db, req.params.id)) }; res.json(body); @@ -102,9 +109,18 @@ export function registerDeployRoutes(app: Express, ctx: RegisterDeployRoutesDeps if (typeof fileName !== 'string' || !fileName.trim()) { return sendApiError(res, 400, 'BAD_REQUEST', 'fileName required'); } + const deployProject = getProject(db, req.params.id); + if (!deployProject) { + return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + } + if (!await ctx.authorizeProjectRequest( + req, + res, + req.params.id, + { mode: 'write', capability: 'writeFiles' }, + )) return; const prior = getDeployment(db, req.params.id, fileName, providerId); - const deployProject = getProject(db, req.params.id); const files = await buildDeployFileSet( PROJECTS_DIR, req.params.id, @@ -187,6 +203,7 @@ export function registerDeployRoutes(app: Express, ctx: RegisterDeployRoutesDeps return sendApiError(res, 400, 'BAD_REQUEST', 'fileName required'); } const preflightProject = getProject(db, req.params.id); + if (!await ctx.authorizeProjectRequest(req, res, req.params.id, { mode: 'read' })) return; /** @type {import('@open-design/contracts').DeployPreflightResponse} */ const body = await prepareDeployPreflight( PROJECTS_DIR, @@ -215,17 +232,29 @@ export function registerDeployRoutes(app: Express, ctx: RegisterDeployRoutesDeps } -export interface RegisterDeploymentCheckRoutesDeps extends RouteDeps<'db' | 'http' | 'deploy'> {} +export interface RegisterDeploymentCheckRoutesDeps extends RouteDeps<'db' | 'http' | 'deploy' | 'projectStore'> { + authorizeProjectRequest: AuthorizeProjectRequest; +} export function registerDeploymentCheckRoutes(app: Express, ctx: RegisterDeploymentCheckRoutesDeps) { const { db } = ctx; const { sendApiError } = ctx.http; + const { getProject } = ctx.projectStore; const { getDeploymentById, CLOUDFLARE_PAGES_PROVIDER_ID, cloudflarePagesProjectNameFromDeployment, checkCloudflarePagesDeploymentLinks, checkDeploymentUrl, upsertDeployment, publicDeployment } = ctx.deploy; app.post( '/api/projects/:id/deployments/:deploymentId/check-link', async (req, res) => { try { + if (!getProject(db, req.params.id)) { + return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + } + if (!await ctx.authorizeProjectRequest( + req, + res, + req.params.id, + { mode: 'write', capability: 'writeFiles' }, + )) return; const existing = getDeploymentById( db, req.params.id, diff --git a/apps/daemon/src/routes/design-systems.ts b/apps/daemon/src/routes/design-systems.ts index e8ee0e34bde..52deafa15e9 100644 --- a/apps/daemon/src/routes/design-systems.ts +++ b/apps/daemon/src/routes/design-systems.ts @@ -1,4 +1,4 @@ -import type { Express } from 'express'; +import type { Express, Response } from 'express'; import fsp from 'node:fs/promises'; import path from 'node:path'; import type { RouteDeps } from '../server-context.js'; @@ -16,7 +16,17 @@ import type { DesignSystemRevisionInput, DesignSystemTokenContractRebuildInput, } from '../design-systems/generation-jobs.js'; -import type { openDatabase } from '../db.js'; +import { deleteWorkspaceResourceByResourceId, type openDatabase } from '../db.js'; +import { + enforceVerifiedWorkspaceResourceMutation, + enforceVerifiedWorkspaceResourceRead, + headerValue, + isWorkspaceResourceLocked, + resolveOptionalWorkspaceRequestAuthority, + workspaceResourceContextFromRequest, + type VerifyWorkspaceRequestAuthority, + type WorkspaceResourceAccessInput, +} from '../collab/workspace-resource-mutation.js'; import type { Project, ProjectFile } from '@open-design/contracts'; type DbHandle = ReturnType<typeof openDatabase>; @@ -33,21 +43,68 @@ type AvailableDesignSystemSummary = DesignSystemSummary & { const PACKAGED_SHOWCASE_PATH = 'system/kit.html'; export interface RegisterDesignSystemRoutesDeps extends RouteDeps<'db' | 'paths' | 'projectFiles' | 'projectStore'> { + verifyWorkspaceRequestAuthority: VerifyWorkspaceRequestAuthority; + workspaceResources: { + getWorkspaceResource: ( + db: DbHandle, + resourceType: string, + workspaceId: string, + resourceId: string, + ) => WorkspaceResourceAccessInput | null | undefined; + getWorkspaceResourceByResourceId: ( + db: DbHandle, + resourceType: string, + resourceId: string, + ) => WorkspaceResourceAccessInput | null | undefined; + }; designSystems: { buildUserDesignSystemArchive: ( root: string, id: string, ) => Promise<{ buffer: Buffer; baseName: string; title: string } | null>; - createUserDesignSystem: (root: string, input: UserDesignSystemInput) => Promise<DesignSystemSummary>; + /** + * Whether the caller may mutate (edit / publish-toggle / delete) `id`. + * Always true for a system the caller authored themselves. For a system + * materialized locally from a teammate's team share, true only when the + * caller can manage that share — the original sharer, or a workspace + * owner/admin (see `canManageSharedResource` in + * `collab/team-resource-share.ts`) — mirroring the "who can unshare" + * rule exactly. Without this gate, a plain member with a synced local + * copy could PATCH/DELETE a design system that was never theirs + * (recvqb6mfyqXLD): the UI hides the affordances, but nothing stopped a + * direct API call. + * + * `req` (spec 9.2) lets the implementation also refuse when the caller's + * own workspace is locked/deleted (billing lapse, deletion in progress) + * — a check design system never had, unlike project/plugin. + */ + canMutateUserDesignSystem: (root: string, id: string, req: any) => Promise<boolean>; + createUserDesignSystem: ( + root: string, + input: UserDesignSystemInput, + req: any, + ) => Promise<DesignSystemSummary>; deleteUserDesignSystem: (root: string, id: string) => Promise<boolean>; ensureUserDesignSystemWorkspaceProject: (db: DbHandle, id: string) => Promise<DesignSystemWorkspaceProject | null>; - listAllDesignSystems: () => Promise<AvailableDesignSystemSummary[]>; + listAllDesignSystems: (options?: { + workspaceId?: string | null; + }) => Promise<AvailableDesignSystemSummary[]>; listUserDesignSystemFiles: (root: string, id: string) => Promise<DesignSystemFileSummary[] | null>; listUserDesignSystemRevisions: (root: string, id: string) => Promise<DesignSystemRevision[] | null>; prepareDesignTokenContractRebuild: (root: string, id: string, options?: { force?: boolean }) => Promise<DesignTokenContractRebuildPreparation>; - readAvailableDesignSystem: (id: string) => Promise<string | null>; - readAvailableDesignSystemPackageInfo: (id: string) => Promise<DesignSystemPackageInfo | null>; - readAvailableDesignSystemStaticFile: (id: string, filePath: string) => Promise<{ + readAvailableDesignSystem: ( + id: string, + options?: { workspaceId?: string | null }, + ) => Promise<string | null>; + readAvailableDesignSystemPackageInfo: ( + id: string, + options?: { workspaceId?: string | null }, + ) => Promise<DesignSystemPackageInfo | null>; + readAvailableDesignSystemStaticFile: ( + id: string, + filePath: string, + options?: { workspaceId?: string | null }, + ) => Promise<{ bytes: Buffer; contentType: string; updatedAt: string; @@ -56,14 +113,45 @@ export interface RegisterDesignSystemRoutesDeps extends RouteDeps<'db' | 'paths' readUserDesignSystemFile: (root: string, id: string, filePath: string) => Promise<DesignSystemFileDetail | null>; renderDesignSystemPreview: (id: string, body: string) => string; renderDesignSystemShowcase: (id: string, body: string) => string; + /** + * Physically copies the real `assets/` files out of a user design + * system's workspace project (where an agent's Write/Edit tool calls + * actually land) into the canonical directory — the fix for spec 04 + * §9.3 (recvqb1t4FrckM): canonical is the only directory + * `team-resource-share` and the download archive read from, and until + * this existed nothing ever copied a regenerated logo back into it. + */ + syncUserDesignSystemAssetsFromWorkspace: ( + db: DbHandle, + id: string, + ) => Promise<{ ok: true; synced: string[] } | { ok: false; reason: 'not-found' | 'no-workspace-project' }>; updateUserDesignSystem: (root: string, id: string, input: UserDesignSystemInput) => Promise<DesignSystemSummary | null>; updateUserDesignSystemRevisionStatus: (root: string, id: string, revisionId: string, status: 'accepted' | 'rejected') => Promise<DesignSystemRevision | null>; + /** + * spec 04 §11: unshare `id` from the team hub BEFORE the local delete + * proceeds, but only when it is CURRENTLY on the live team share list + * (`designSystemsTeamShare.sharedResources()` in server.ts) — never on + * `isTeamSyncedUserDesignSystem` alone. That flag is true only on a + * teammate's PULLED copy; the sharer deleting their OWN original always + * reads `teamSynced: false`, which is exactly why the hub index used to + * survive this route untouched and teammates kept seeing the deleted + * design system. Returns whether an unshare actually ran (false when the + * system was never shared, or team sharing isn't configured) so tests can + * assert on the real state transition instead of a call-was-made mock. + */ + unshareTeamDesignSystemIfShared: (id: string, req: any) => Promise<boolean>; }; generationJobs: { get: (jobId: string) => DesignSystemGenerationJob | null; rebuildTokenContract: (input: DesignSystemTokenContractRebuildInput) => DesignSystemGenerationJob; revise: (input: DesignSystemRevisionInput) => DesignSystemGenerationJob; - start: (input: UserDesignSystemInput) => DesignSystemGenerationJob; + start: ( + input: UserDesignSystemInput, + createDesignSystemForJob?: ( + root: string, + input: UserDesignSystemInput, + ) => Promise<DesignSystemSummary>, + ) => DesignSystemGenerationJob; }; }; @@ -83,6 +171,7 @@ export function registerDesignSystemRoutes(app: Express, ctx: RegisterDesignSyst const { CRAFT_DIR, USER_DESIGN_SYSTEMS_DIR } = ctx.paths; const { buildUserDesignSystemArchive, + canMutateUserDesignSystem, createUserDesignSystem, deleteUserDesignSystem, ensureUserDesignSystemWorkspaceProject, @@ -97,23 +186,182 @@ export function registerDesignSystemRoutes(app: Express, ctx: RegisterDesignSyst readUserDesignSystemFile, renderDesignSystemPreview, renderDesignSystemShowcase, + syncUserDesignSystemAssetsFromWorkspace, + unshareTeamDesignSystemIfShared, updateUserDesignSystem, updateUserDesignSystemRevisionStatus, } = ctx.designSystems; const designSystemGenerationJobs = ctx.generationJobs; + const generationJobScopes = new Map< + string, + { workspaceId: string; workspaceMemberId: string } | null + >(); + + const getBoundDesignSystem = ( + dbHandle: unknown, + workspaceId: string, + resourceId: string, + ) => ctx.workspaceResources.getWorkspaceResource( + dbHandle as DbHandle, + 'design_system', + workspaceId, + resourceId, + ); + const getDesignSystemBinding = ( + dbHandle: unknown, + resourceId: string, + ) => ctx.workspaceResources.getWorkspaceResourceByResourceId( + dbHandle as DbHandle, + 'design_system', + resourceId, + ); + + async function authorizeDesignSystemRead( + req: any, + res: Response, + id: string, + allowNavigationQuery = false, + ): Promise<boolean> { + return enforceVerifiedWorkspaceResourceRead( + 'design_system', + req, + res, + (_res, status, code, message, details) => + res.status(status).json({ error: code, message, ...details }), + getBoundDesignSystem, + getDesignSystemBinding, + db, + id, + ctx.verifyWorkspaceRequestAuthority, + { allowNavigationQuery }, + ); + } + + async function authorizeDesignSystemMutation( + req: any, + res: Response, + id: string, + ): Promise<boolean> { + return enforceVerifiedWorkspaceResourceMutation( + 'design_system', + req, + res, + (_res, status, code, message) => + res.status(status).json({ error: code, message }), + getBoundDesignSystem, + getDesignSystemBinding, + db, + id, + 'writeFiles', + ctx.verifyWorkspaceRequestAuthority, + ); + } + + async function resolveGenerationJobScope( + req: any, + res: Response, + ): Promise<{ workspaceId: string; workspaceMemberId: string } | null | 'denied'> { + const resolution = await resolveOptionalWorkspaceRequestAuthority( + req, + ctx.verifyWorkspaceRequestAuthority, + ); + if (!resolution.ok) { + res.status(resolution.status).json({ + error: resolution.code, + message: resolution.message, + ...(resolution.retryable ? { retryable: true } : {}), + }); + return 'denied'; + } + return resolution.context + ? { + workspaceId: resolution.context.workspaceId, + workspaceMemberId: resolution.context.workspaceMemberId, + } + : null; + } + + async function authorizeGenerationJobRead( + req: any, + res: Response, + job: DesignSystemGenerationJob, + ): Promise<boolean> { + const scope = generationJobScopes.get(job.id); + if (scope) { + const resolution = await resolveGenerationJobScope(req, res); + if (resolution === 'denied') return false; + if ( + !resolution + || resolution.workspaceId !== scope.workspaceId + || resolution.workspaceMemberId !== scope.workspaceMemberId + ) { + res.status(403).json({ error: 'WORKSPACE_DESIGN_SYSTEM_PERMISSION_DENIED' }); + return false; + } + return true; + } + return job.designSystemId + ? authorizeDesignSystemRead(req, res, job.designSystemId) + : true; + } + + // Workspace-lock gate (spec 9.2), unconditional and independent of + // `canMutateUserDesignSystem`'s own teamSynced/canUnshare verdict — a + // locked/deleted workspace (billing lapse, deletion in progress) must + // refuse every PATCH/DELETE regardless of who the caller is, the same + // guarantee `enforceWorkspaceResourceMutation` gives project/plugin/skill. + // Reuses that module's own `workspaceResourceContextFromRequest`/ + // `isWorkspaceResourceLocked` rather than re-deriving the header contract + // here. Checked at the route rather than folded silently into + // `canMutateUserDesignSystem`'s boolean so it applies no matter what a + // caller-supplied implementation of that hook decides. + function isRequestWorkspaceLocked(req: any): boolean { + const requestCtx = workspaceResourceContextFromRequest(req); + return Boolean(requestCtx && requestCtx !== 'missing' && isWorkspaceResourceLocked(requestCtx)); + } + + function sendWorkspaceScopeError(res: Response, error: unknown): boolean { + if ( + !error || + typeof error !== 'object' || + !('status' in error) || + (error.status !== 400 && error.status !== 403 && error.status !== 503) || + !('code' in error) || + typeof error.code !== 'string' + ) { + return false; + } + res.status(error.status).json({ + error: error.code, + message: error instanceof Error ? error.message : String(error.code), + ...('retryable' in error && error.retryable === true ? { retryable: true } : {}), + }); + return true; + } app.post('/api/design-systems', async (req, res) => { try { - const created = await createUserDesignSystem(USER_DESIGN_SYSTEMS_DIR, req.body || {}); + const created = await createUserDesignSystem( + USER_DESIGN_SYSTEMS_DIR, + req.body || {}, + req, + ); res.status(201).json({ ...created as object, designSystem: created }); } catch (err) { + if (sendWorkspaceScopeError(res, err)) return; res.status(400).json({ error: String(err) }); } }); app.post('/api/design-systems/generation-jobs', async (req, res) => { try { - const job = designSystemGenerationJobs.start(req.body || {}); + const scope = await resolveGenerationJobScope(req, res); + if (scope === 'denied') return; + const job = designSystemGenerationJobs.start( + req.body || {}, + (root, input) => createUserDesignSystem(root, input, req), + ); + generationJobScopes.set(job.id, scope); res.status(202).json({ job }); } catch (err) { res.status(400).json({ error: String(err) }); @@ -126,6 +374,7 @@ export function registerDesignSystemRoutes(app: Express, ctx: RegisterDesignSyst if (!job) { return res.status(404).json({ error: 'design system generation job not found' }); } + if (!(await authorizeGenerationJobRead(req, res, job))) return; res.json({ job }); } catch (err) { res.status(500).json({ error: String(err) }); @@ -134,6 +383,9 @@ export function registerDesignSystemRoutes(app: Express, ctx: RegisterDesignSyst app.post('/api/design-systems/:id/revision-jobs', async (req, res) => { try { + if (!(await authorizeDesignSystemMutation(req, res, req.params.id))) return; + const scope = await resolveGenerationJobScope(req, res); + if (scope === 'denied') return; const feedback = typeof req.body?.feedback === 'string' ? req.body.feedback : ''; if (!feedback.trim()) return res.status(400).json({ error: 'feedback is required' }); const job = designSystemGenerationJobs.revise({ @@ -142,6 +394,7 @@ export function registerDesignSystemRoutes(app: Express, ctx: RegisterDesignSyst sectionTitle: typeof req.body?.sectionTitle === 'string' ? req.body.sectionTitle : undefined, body: typeof req.body?.body === 'string' ? req.body.body : undefined, }); + generationJobScopes.set(job.id, scope); res.status(202).json({ job }); } catch (err) { res.status(400).json({ error: String(err) }); @@ -150,6 +403,9 @@ export function registerDesignSystemRoutes(app: Express, ctx: RegisterDesignSyst app.post('/api/design-systems/:id/token-contract/rebuild-jobs', async (req, res) => { try { + if (!(await authorizeDesignSystemMutation(req, res, req.params.id))) return; + const scope = await resolveGenerationJobScope(req, res); + if (scope === 'denied') return; const preparation = await prepareDesignTokenContractRebuild( USER_DESIGN_SYSTEMS_DIR, req.params.id, @@ -166,6 +422,7 @@ export function registerDesignSystemRoutes(app: Express, ctx: RegisterDesignSyst decision: preparation.decision, ...preparation.revision, }); + generationJobScopes.set(job.id, scope); res.status(202).json({ decision: preparation.decision, job }); } catch (err) { res.status(400).json({ error: String(err) }); @@ -174,6 +431,7 @@ export function registerDesignSystemRoutes(app: Express, ctx: RegisterDesignSyst app.get('/api/design-systems/:id/revisions', async (req, res) => { try { + if (!(await authorizeDesignSystemRead(req, res, req.params.id))) return; const revisions = await listUserDesignSystemRevisions( USER_DESIGN_SYSTEMS_DIR, req.params.id, @@ -189,6 +447,20 @@ export function registerDesignSystemRoutes(app: Express, ctx: RegisterDesignSyst app.patch('/api/design-systems/:id/revisions/:revisionId', async (req, res) => { try { + if (!(await authorizeDesignSystemMutation(req, res, req.params.id))) return; + // recvqb6mfyqXLD: accepting a revision commits its proposed body onto + // the canonical design system — the same "edit" this route family + // gates everywhere else (PATCH/DELETE/sync-assets above). Without this, + // a plain member viewing a teammate's team-synced design system could + // accept/reject its pending revision (surfaced to anyone who can read + // the system, not just the owner) with no server-side check at all, + // even after the UI stopped showing it as editable. + if (isRequestWorkspaceLocked(req)) { + return res.status(403).json({ error: 'WORKSPACE_LOCKED' }); + } + if (!(await canMutateUserDesignSystem(USER_DESIGN_SYSTEMS_DIR, req.params.id, req))) { + return res.status(403).json({ error: 'WORKSPACE_RESOURCE_MANAGE_DENIED' }); + } const status = typeof req.body?.status === 'string' ? req.body.status : ''; if (status !== 'accepted' && status !== 'rejected') { return res.status(400).json({ error: 'status must be accepted or rejected' }); @@ -210,15 +482,31 @@ export function registerDesignSystemRoutes(app: Express, ctx: RegisterDesignSyst app.get('/api/design-systems/:id', async (req, res) => { try { - const systems = await listAllDesignSystems(); + if (!(await authorizeDesignSystemRead(req, res, req.params.id))) return; + const workspaceId = headerValue(req, 'x-od-workspace-id'); + const systems = await listAllDesignSystems({ workspaceId }); const summary = systems.find((s) => s.id === req.params.id); const projectBody = await readDesignSystemWorkspaceTextFile(db, summary, 'DESIGN.md'); - const body = projectBody ?? await readAvailableDesignSystem(req.params.id); + const body = projectBody ?? await readAvailableDesignSystem(req.params.id, { workspaceId }); if (body === null || !summary) { return res.status(404).json({ error: 'design system not found' }); } - const packageInfo = await readAvailableDesignSystemPackageInfo(req.params.id); - const detail = { ...summary, body, ...(packageInfo ? { packageInfo } : {}) }; + const packageInfo = await readAvailableDesignSystemPackageInfo(req.params.id, { workspaceId }); + // recvqb6mfyqXLD: mirror the exact PATCH/DELETE verdict onto the read + // path too. `DesignSystemsTab` already re-derives an equivalent verdict + // from the separate `/team` share listing for its own list+detail pane, + // but a design system reached any other way — e.g. the direct + // `/design-systems/:id` route the Library's "Open design system" link + // and `LibrarySection` navigate to, which renders `DesignSystemFlow` + // directly — had no ownership signal at all and fell back to treating + // any non-built-in system as fully editable. Computing it once here, + // from the same `canMutateUserDesignSystem` the mutation routes below + // already gate on, means every detail surface can hide/disable its + // Publish toggle and Save button on the same authority the backend + // enforces, instead of each surface re-deriving (or forgetting to + // derive) its own verdict. + const canMutate = await canMutateUserDesignSystem(USER_DESIGN_SYSTEMS_DIR, req.params.id, req); + const detail = { ...summary, body, canMutate, ...(packageInfo ? { packageInfo } : {}) }; res.json({ ...detail, designSystem: detail }); } catch (err) { res.status(500).json({ error: String(err) }); @@ -227,7 +515,12 @@ export function registerDesignSystemRoutes(app: Express, ctx: RegisterDesignSyst app.get('/api/design-systems/:id/preview', async (req, res) => { try { - const body = await readAvailableDesignSystem(req.params.id); + if (!(await authorizeDesignSystemRead(req, res, req.params.id, true))) return; + const workspaceId = + headerValue(req, 'x-od-workspace-id') + ?? designSystemNavigationWorkspaceQuery(req)?.workspaceId + ?? null; + const body = await readAvailableDesignSystem(req.params.id, { workspaceId }); if (body === null) return res.status(404).type('text/plain').send('not found'); const html = renderDesignSystemPreview(req.params.id, body); res.type('text/html').send(html); @@ -238,8 +531,18 @@ export function registerDesignSystemRoutes(app: Express, ctx: RegisterDesignSyst app.get('/api/design-systems/:id/showcase', async (req, res) => { try { - const packaged = await readAvailableDesignSystemStaticFile(req.params.id, PACKAGED_SHOWCASE_PATH); + if (!(await authorizeDesignSystemRead(req, res, req.params.id, true))) return; + const workspaceId = + headerValue(req, 'x-od-workspace-id') + ?? designSystemNavigationWorkspaceQuery(req)?.workspaceId + ?? null; + const packaged = await readAvailableDesignSystemStaticFile( + req.params.id, + PACKAGED_SHOWCASE_PATH, + { workspaceId }, + ); if (packaged?.contentType.startsWith('text/html')) { + const workspaceQuery = designSystemNavigationWorkspaceQuery(req); res.setHeader('Cache-Control', 'no-store'); res.setHeader('Last-Modified', packaged.updatedAt); return res.type('text/html').send( @@ -247,10 +550,11 @@ export function registerDesignSystemRoutes(app: Express, ctx: RegisterDesignSyst packaged.bytes.toString('utf8'), req.params.id, path.posix.dirname(PACKAGED_SHOWCASE_PATH), + workspaceQuery, ), ); } - const body = await readAvailableDesignSystem(req.params.id); + const body = await readAvailableDesignSystem(req.params.id, { workspaceId }); if (body === null) return res.status(404).type('text/plain').send('not found'); const html = renderDesignSystemShowcase(req.params.id, body); res.type('text/html').send(html); @@ -261,8 +565,17 @@ export function registerDesignSystemRoutes(app: Express, ctx: RegisterDesignSyst app.get('/api/design-systems/:id/static', async (req, res) => { try { + if (!(await authorizeDesignSystemRead(req, res, req.params.id, true))) return; + const workspaceId = + headerValue(req, 'x-od-workspace-id') + ?? designSystemNavigationWorkspaceQuery(req)?.workspaceId + ?? null; const requestedPath = typeof req.query.path === 'string' ? req.query.path : ''; - const file = await readAvailableDesignSystemStaticFile(req.params.id, requestedPath); + const file = await readAvailableDesignSystemStaticFile( + req.params.id, + requestedPath, + { workspaceId }, + ); if (!file) return res.status(404).type('text/plain').send('not found'); res.setHeader('Cache-Control', 'no-store'); res.setHeader('Last-Modified', file.updatedAt); @@ -274,6 +587,7 @@ export function registerDesignSystemRoutes(app: Express, ctx: RegisterDesignSyst app.post('/api/design-systems/:id/workspace', async (req, res) => { try { + if (!(await authorizeDesignSystemMutation(req, res, req.params.id))) return; const workspace = await ensureUserDesignSystemWorkspaceProject(db, req.params.id); if (!workspace) { return res.status(404).json({ error: 'editable design system not found' }); @@ -286,6 +600,7 @@ export function registerDesignSystemRoutes(app: Express, ctx: RegisterDesignSyst app.get('/api/design-systems/:id/files', async (req, res) => { try { + if (!(await authorizeDesignSystemRead(req, res, req.params.id))) return; const files = await listUserDesignSystemFiles(USER_DESIGN_SYSTEMS_DIR, req.params.id); if (!files) { return res.status(404).json({ error: 'editable design system not found' }); @@ -298,6 +613,7 @@ export function registerDesignSystemRoutes(app: Express, ctx: RegisterDesignSyst app.get('/api/design-systems/:id/file', async (req, res) => { try { + if (!(await authorizeDesignSystemRead(req, res, req.params.id, true))) return; const requestedPath = typeof req.query.path === 'string' ? req.query.path : ''; const file = await readUserDesignSystemFile( USER_DESIGN_SYSTEMS_DIR, @@ -318,6 +634,7 @@ export function registerDesignSystemRoutes(app: Express, ctx: RegisterDesignSyst // null and surface as 404. app.get('/api/design-systems/:id/archive', async (req, res) => { try { + if (!(await authorizeDesignSystemRead(req, res, req.params.id, true))) return; const archive = await buildUserDesignSystemArchive(USER_DESIGN_SYSTEMS_DIR, req.params.id); if (!archive) { return res.status(404).json({ error: 'downloadable design system not found' }); @@ -341,6 +658,13 @@ export function registerDesignSystemRoutes(app: Express, ctx: RegisterDesignSyst app.patch('/api/design-systems/:id', async (req, res) => { try { + if (!(await authorizeDesignSystemMutation(req, res, req.params.id))) return; + if (isRequestWorkspaceLocked(req)) { + return res.status(403).json({ error: 'WORKSPACE_LOCKED' }); + } + if (!(await canMutateUserDesignSystem(USER_DESIGN_SYSTEMS_DIR, req.params.id, req))) { + return res.status(403).json({ error: 'WORKSPACE_RESOURCE_MANAGE_DENIED' }); + } const updated = await updateUserDesignSystem( USER_DESIGN_SYSTEMS_DIR, req.params.id, @@ -355,14 +679,72 @@ export function registerDesignSystemRoutes(app: Express, ctx: RegisterDesignSyst } }); + // Asset sync (spec 04 §9.3, recvqb1t4FrckM): a signal-only endpoint — the + // browser never uploads file bytes here. The daemon locates the design + // system's workspace project itself (same lookup + // `ensureUserDesignSystemWorkspaceProject` uses) and copies real files + // under that project's `assets/` directory into the canonical design + // system directory, entirely on the daemon side of the data-directory + // boundary. Gated the same way as PATCH/DELETE: a locked workspace or a + // caller who cannot manage the (possibly team-synced) design system may + // not trigger a write to canonical. + app.post('/api/design-systems/:id/sync-assets', async (req, res) => { + try { + if (!(await authorizeDesignSystemMutation(req, res, req.params.id))) return; + if (isRequestWorkspaceLocked(req)) { + return res.status(403).json({ error: 'WORKSPACE_LOCKED' }); + } + if (!(await canMutateUserDesignSystem(USER_DESIGN_SYSTEMS_DIR, req.params.id, req))) { + return res.status(403).json({ error: 'WORKSPACE_RESOURCE_MANAGE_DENIED' }); + } + const outcome = await syncUserDesignSystemAssetsFromWorkspace(db, req.params.id); + if (!outcome.ok) { + if (outcome.reason === 'not-found') { + return res.status(404).json({ error: 'editable design system not found' }); + } + // No workspace project to sync from yet — a benign no-op, not an + // error; the trigger sites call this speculatively on every asset + // write and run-end. + return res.json({ synced: [] }); + } + res.json({ synced: outcome.synced }); + } catch (err) { + res.status(500).json({ error: String(err) }); + } + }); + app.delete('/api/design-systems/:id', async (req, res) => { try { + if (!(await authorizeDesignSystemMutation(req, res, req.params.id))) return; + if (isRequestWorkspaceLocked(req)) { + return res.status(403).json({ error: 'WORKSPACE_LOCKED' }); + } + if (!(await canMutateUserDesignSystem(USER_DESIGN_SYSTEMS_DIR, req.params.id, req))) { + return res.status(403).json({ error: 'WORKSPACE_RESOURCE_MANAGE_DENIED' }); + } + // spec 04 §11: drop the hub-side share BEFORE the local delete, so a + // sharer deleting their OWN design system does not leave the hub index + // pointing at a canonical directory that is about to stop existing — + // otherwise `syncSharedTeamDesignSystem` (server.ts) keeps re-stamping + // `markTeamSynced()` onto every teammate's already-synced local copy + // forever, because the hub still reports the resource as shared. A + // thrown error here (e.g. the caller cannot actually manage the share) + // aborts before `deleteUserDesignSystem` runs, matching "unshare must + // succeed before the local delete proceeds". + await unshareTeamDesignSystemIfShared(req.params.id, req); const ok = await deleteUserDesignSystem(USER_DESIGN_SYSTEMS_DIR, req.params.id); if (!ok) { return res.status(404).json({ error: 'editable design system not found' }); } + // Envelope cleanup (spec 9.2): drop the `workspace_resources` binding + // row too, mirroring skill's DELETE route (routes/static-resource.ts) + // and plugin uninstall (plugins/installer.ts) — the generic table has + // no ON DELETE CASCADE, so skipping this leaves an orphan row pointing + // at a design system that no longer exists on disk. + deleteWorkspaceResourceByResourceId(db, 'design_system', req.params.id); res.status(204).end(); } catch (err) { + if (sendWorkspaceScopeError(res, err)) return; res.status(500).json({ error: String(err) }); } }); @@ -426,15 +808,26 @@ export function rewriteDesignSystemShowcaseAssetUrls( html: string, designSystemId: string, baseDir: string, + workspaceQuery?: { workspaceId: string; workspaceMemberId: string } | null, ): string { if (!html) return html; return html .replace(/\b(src|href)=(["'])([^"']+)\2/gi, (match, attr: string, quote: string, raw: string) => { - const rewritten = rewriteDesignSystemShowcaseAssetUrl(raw, designSystemId, baseDir); + const rewritten = rewriteDesignSystemShowcaseAssetUrl( + raw, + designSystemId, + baseDir, + workspaceQuery, + ); return rewritten === raw ? match : `${attr}=${quote}${rewritten}${quote}`; }) .replace(/url\(\s*(["']?)([^"')]+)\1\s*\)/gi, (match, quote: string, raw: string) => { - const rewritten = rewriteDesignSystemShowcaseAssetUrl(raw, designSystemId, baseDir); + const rewritten = rewriteDesignSystemShowcaseAssetUrl( + raw, + designSystemId, + baseDir, + workspaceQuery, + ); return rewritten === raw ? match : `url(${quote}${rewritten}${quote})`; }); } @@ -443,6 +836,7 @@ function rewriteDesignSystemShowcaseAssetUrl( rawUrl: string, designSystemId: string, baseDir: string, + workspaceQuery?: { workspaceId: string; workspaceMemberId: string } | null, ): string { const value = rawUrl.trim(); if ( @@ -467,7 +861,27 @@ function rewriteDesignSystemShowcaseAssetUrl( return rawUrl; } - const staticUrl = `/api/design-systems/${encodeURIComponent(designSystemId)}/static?path=${encodeURIComponent(relativePath)}`; + const staticUrl = + `/api/design-systems/${encodeURIComponent(designSystemId)}/static` + + `?path=${encodeURIComponent(relativePath)}` + + (workspaceQuery + ? `&workspaceId=${encodeURIComponent(workspaceQuery.workspaceId)}` + + `&workspaceMemberId=${encodeURIComponent(workspaceQuery.workspaceMemberId)}` + : ''); if (suffix.startsWith('?')) return `${staticUrl}&${suffix.slice(1)}`; return `${staticUrl}${suffix}`; } + +function designSystemNavigationWorkspaceQuery( + req: any, +): { workspaceId: string; workspaceMemberId: string } | null { + const workspaceId = + typeof req.query?.workspaceId === 'string' ? req.query.workspaceId.trim() : ''; + const workspaceMemberId = + typeof req.query?.workspaceMemberId === 'string' + ? req.query.workspaceMemberId.trim() + : ''; + return workspaceId && workspaceMemberId + ? { workspaceId, workspaceMemberId } + : null; +} diff --git a/apps/daemon/src/routes/genui.ts b/apps/daemon/src/routes/genui.ts index 92f51e2a8ac..bf34333105e 100644 --- a/apps/daemon/src/routes/genui.ts +++ b/apps/daemon/src/routes/genui.ts @@ -12,6 +12,7 @@ import { revokeProjectSurface, } from '../genui/index.js'; import { resolveProjectDir } from '../projects.js'; +import type { AuthorizeProjectRequest } from '../collab/project-request-authority.js'; export interface RegisterGenuiRoutesDeps { db: Database.Database; @@ -23,14 +24,29 @@ export interface RegisterGenuiRoutesDeps { paths: { PROJECTS_DIR: string; }; + authorizeProjectRequest: AuthorizeProjectRequest; } export function registerGenuiRoutes(app: Express, deps: RegisterGenuiRoutesDeps): void { const { db, design } = deps; const { PROJECTS_DIR } = deps.paths; + const authorizeRun = async ( + req: any, + res: any, + options: { mode: 'read' } | { mode: 'write'; capability: 'writeFiles' }, + ) => { + const run = design.runs.get(req.params.runId); + if (!run) { + res.status(404).json({ error: 'run not found' }); + return false; + } + if (!run.projectId) return true; + return deps.authorizeProjectRequest(req, res, run.projectId, options); + }; - app.get('/api/runs/:runId/genui', (req, res) => { + app.get('/api/runs/:runId/genui', async (req, res) => { try { + if (!await authorizeRun(req, res, { mode: 'read' })) return; const surfaces = listSurfacesForRun(db, req.params.runId); res.json({ runId: req.params.runId, surfaces }); } catch (err) { @@ -38,8 +54,14 @@ export function registerGenuiRoutes(app: Express, deps: RegisterGenuiRoutesDeps) } }); - app.get('/api/projects/:projectId/genui', (req, res) => { + app.get('/api/projects/:projectId/genui', async (req, res) => { try { + if (!await deps.authorizeProjectRequest( + req, + res, + req.params.projectId, + { mode: 'read' }, + )) return; const surfaces = listSurfacesForProject(db, req.params.projectId); res.json({ projectId: req.params.projectId, surfaces }); } catch (err) { @@ -49,6 +71,11 @@ export function registerGenuiRoutes(app: Express, deps: RegisterGenuiRoutesDeps) app.post('/api/runs/:runId/genui/:surfaceId/respond', async (req, res) => { try { + if (!await authorizeRun( + req, + res, + { mode: 'write', capability: 'writeFiles' }, + )) return; const body = req.body && typeof req.body === 'object' ? req.body : {}; const value = 'value' in body ? body.value : null; const respondedBy = @@ -105,8 +132,14 @@ export function registerGenuiRoutes(app: Express, deps: RegisterGenuiRoutesDeps) } }); - app.post('/api/projects/:projectId/genui/:surfaceId/revoke', (req, res) => { + app.post('/api/projects/:projectId/genui/:surfaceId/revoke', async (req, res) => { try { + if (!await deps.authorizeProjectRequest( + req, + res, + req.params.projectId, + { mode: 'write', capability: 'writeFiles' }, + )) return; const changed = revokeProjectSurface(db, { projectId: req.params.projectId, surfaceId: req.params.surfaceId, @@ -117,8 +150,14 @@ export function registerGenuiRoutes(app: Express, deps: RegisterGenuiRoutesDeps) } }); - app.post('/api/projects/:projectId/genui/prefill', (req, res) => { + app.post('/api/projects/:projectId/genui/prefill', async (req, res) => { try { + if (!await deps.authorizeProjectRequest( + req, + res, + req.params.projectId, + { mode: 'write', capability: 'writeFiles' }, + )) return; const body = req.body && typeof req.body === 'object' ? req.body : {}; const snapshotId = typeof body.snapshotId === 'string' ? body.snapshotId : ''; const surfaceId = typeof body.surfaceId === 'string' ? body.surfaceId : ''; @@ -147,8 +186,9 @@ export function registerGenuiRoutes(app: Express, deps: RegisterGenuiRoutesDeps) } }); - app.get('/api/runs/:runId/genui/:surfaceId', (req, res) => { + app.get('/api/runs/:runId/genui/:surfaceId', async (req, res) => { try { + if (!await authorizeRun(req, res, { mode: 'read' })) return; const row = db.prepare( `SELECT id FROM genui_surfaces WHERE run_id = ? AND surface_id = ? @@ -170,8 +210,9 @@ export function registerGenuiRoutes(app: Express, deps: RegisterGenuiRoutesDeps) } }); - app.get('/api/runs/:runId/devloop-iterations', (req, res) => { + app.get('/api/runs/:runId/devloop-iterations', async (req, res) => { try { + if (!await authorizeRun(req, res, { mode: 'read' })) return; const iterations = listIterationsForRun(db, req.params.runId); res.json({ runId: req.params.runId, iterations }); } catch (err) { @@ -179,8 +220,9 @@ export function registerGenuiRoutes(app: Express, deps: RegisterGenuiRoutesDeps) } }); - app.post('/api/runs/:runId/replay', (req, res) => { + app.post('/api/runs/:runId/replay', async (req, res) => { try { + if (!await authorizeRun(req, res, { mode: 'read' })) return; const body = req.body && typeof req.body === 'object' ? req.body : {}; const explicitSnapshotId = typeof body.snapshotId === 'string' ? body.snapshotId : ''; const snapshotId = explicitSnapshotId; diff --git a/apps/daemon/src/routes/handoff.ts b/apps/daemon/src/routes/handoff.ts index c5a96e6461f..14235991c70 100644 --- a/apps/daemon/src/routes/handoff.ts +++ b/apps/daemon/src/routes/handoff.ts @@ -1,10 +1,13 @@ import type { Express } from 'express'; import type { RouteDeps } from '../server-context.js'; +import type { AuthorizeProjectRequest } from '../collab/project-request-authority.js'; export interface RegisterHandoffRoutesDeps extends RouteDeps< 'db' | 'http' | 'paths' | 'projectStore' | 'conversations' | 'validation' | 'handoff' - > {} + > { + authorizeProjectRequest: AuthorizeProjectRequest; +} /** * `POST /api/projects/:id/handoff` — synthesise a "first user message" @@ -90,6 +93,7 @@ export function registerHandoffRoutes(app: Express, ctx: RegisterHandoffRoutesDe if (!project) { return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); } + if (!await ctx.authorizeProjectRequest(req, res, project.id, { mode: 'read' })) return; // Handoff is conversation-scoped — the conversation must exist AND // belong to this project, otherwise the synthesized transcript would diff --git a/apps/daemon/src/routes/host-tools.ts b/apps/daemon/src/routes/host-tools.ts index 1380f34f641..c3d4eb31681 100644 --- a/apps/daemon/src/routes/host-tools.ts +++ b/apps/daemon/src/routes/host-tools.ts @@ -25,9 +25,12 @@ import type { OpenProjectInEditorResponse, } from '@open-design/contracts'; import type { RouteDeps } from '../server-context.js'; +import type { AuthorizeProjectRequest } from '../collab/project-request-authority.js'; export interface RegisterHostToolsRoutesDeps - extends RouteDeps<'db' | 'http' | 'paths' | 'projectStore' | 'projectFiles'> {} + extends RouteDeps<'db' | 'http' | 'paths' | 'projectStore' | 'projectFiles'> { + authorizeProjectRequest: AuthorizeProjectRequest; +} export type RealPlatform = 'darwin' | 'win32' | 'linux'; export type Platform = RealPlatform | 'unknown'; @@ -344,6 +347,7 @@ export function registerHostToolsRoutes(app: Express, ctx: RegisterHostToolsRout if (!project) { return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); } + if (!await ctx.authorizeProjectRequest(req, res, project.id, { mode: 'read' })) return; const resolvedDir = projectHostOpenDir( PROJECTS_DIR, project, diff --git a/apps/daemon/src/routes/library.ts b/apps/daemon/src/routes/library.ts index 63c40300c1d..28e24d27a12 100644 --- a/apps/daemon/src/routes/library.ts +++ b/apps/daemon/src/routes/library.ts @@ -47,6 +47,13 @@ import { import { reconcileLibrary, type ReconcileLibraryResult } from '../library-sync.js'; import { fetchExternalBrandAsset } from '../brands/safe-fetch.js'; import { ensureProjectSubdir } from '../projects.js'; +import { + authorizeCreatedProjectWorkspace, + bindCreatedProjectToWorkspace, + sendCreatedProjectWorkspaceError, +} from '../collab/created-project-workspace.js'; +import type { BoundWorkspaceResourceMutationGate } from '../collab/workspace-resource-mutation.js'; +import type { WorkspaceDirectoryFetchResult } from '../collab/vela-workspace-context.js'; import { confirmPairing, libraryConnectionStatus, @@ -57,7 +64,10 @@ import { export interface RegisterLibraryRoutesDeps extends RouteDeps< 'db' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'auth' - > {} + > { + fetchProjectCreationWorkspaceDirectory?: () => Promise<WorkspaceDirectoryFetchResult>; + enforceWorkspaceProjectMutation?: BoundWorkspaceResourceMutationGate; +} const MAX_REMOTE_BYTES = 25 * 1024 * 1024; @@ -162,10 +172,29 @@ export function registerLibraryRoutes(app: Express, ctx: RegisterLibraryRoutesDe const { sendApiError, createSseResponse, requireLocalDaemonRequest, isLocalSameOrigin, resolvedPortRef } = ctx.http; const { LIBRARY_DIR, PROJECTS_DIR, USER_DESIGN_SYSTEMS_DIR } = ctx.paths; - const { getProject, insertProject } = ctx.projectStore; + const { + getProject, + insertProject, + ensureWorkspaceProject, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + } = ctx.projectStore; const { writeProjectFile } = ctx.projectFiles; const { insertConversation } = ctx.conversations; const { authorizeToolRequest } = ctx.auth; + async function enforceProjectWrite(req: Request, res: Response, projectId: string) { + if (!ctx.enforceWorkspaceProjectMutation) return true; + return ctx.enforceWorkspaceProjectMutation( + req, + res, + sendApiError, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + db, + projectId, + 'writeFiles', + ); + } // Copy an asset's bytes into a project (under a `library/` subdir) and record // the project usage as a source back-link. Shared by the loopback apply route @@ -580,6 +609,7 @@ export function registerLibraryRoutes(app: Express, ctx: RegisterLibraryRoutesDe if (!asset) return sendApiError(res, 404, 'NOT_FOUND', 'asset not found'); const projectId = typeof req.body?.projectId === 'string' ? req.body.projectId : ''; if (!projectId) return sendApiError(res, 400, 'BAD_REQUEST', 'projectId is required'); + if (!await enforceProjectWrite(req, res, projectId)) return; try { const includeElement = req.body?.includeElement === true; const result = await applyAssetToProject(asset, projectId, 'manual-upload', req.body?.dir, includeElement); @@ -604,6 +634,13 @@ export function registerLibraryRoutes(app: Express, ctx: RegisterLibraryRoutesDe if (asset.kind !== 'html') { return sendApiError(res, 400, 'NOT_HTML', 'only html captures can be opened as an editable page'); } + const createWorkspace = await authorizeCreatedProjectWorkspace( + req, + ctx.fetchProjectCreationWorkspaceDirectory, + ); + if (!createWorkspace.ok) { + return sendCreatedProjectWorkspaceError(res, createWorkspace); + } const bytesPath = resolveAssetBytesPath(asset, PROJECTS_DIR); if (!bytesPath) return sendApiError(res, 404, 'NOT_FOUND', 'asset bytes not available'); try { @@ -634,6 +671,15 @@ export function registerLibraryRoutes(app: Express, ctx: RegisterLibraryRoutesDe createdAt: now, updatedAt: now, }); + // A capture opened as an editable page is a project the user will + // immediately chat into, so it needs the same home workspace every other + // created project gets — an unbound one is denied its first run outright. + bindCreatedProjectToWorkspace( + (input) => ensureWorkspaceProject(db, input), + createWorkspace.context, + projectId, + now, + ); // writeProjectFile ensures the project dir; write the capture as the // editable entry file. No artifact manifest — a plain HTML file avoids // the publication/stub guards (a captured page is arbitrary markup) while @@ -673,6 +719,7 @@ export function registerLibraryRoutes(app: Express, ctx: RegisterLibraryRoutesDe if (!asset) return sendApiError(res, 404, 'NOT_FOUND', 'asset not found'); const projectId = grant.projectId ?? (typeof req.body?.projectId === 'string' ? req.body.projectId : ''); if (!projectId) return sendApiError(res, 400, 'BAD_REQUEST', 'projectId is required'); + if (!await enforceProjectWrite(req, res, projectId)) return; try { const includeElement = req.body?.includeElement === true; const result = await applyAssetToProject(asset, projectId, 'agent-task', req.body?.dir, includeElement); diff --git a/apps/daemon/src/routes/live-artifact.ts b/apps/daemon/src/routes/live-artifact.ts index cd7b8b51fac..dcf781e3270 100644 --- a/apps/daemon/src/routes/live-artifact.ts +++ b/apps/daemon/src/routes/live-artifact.ts @@ -1,7 +1,14 @@ import type { Express } from 'express'; import type { RouteDeps } from '../server-context.js'; +import type { + AuthorizeProjectRequest, + AuthorizeProjectToolRequest, +} from '../collab/project-request-authority.js'; -export interface RegisterLiveArtifactRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'auth' | 'liveArtifacts' | 'projectStore'> {} +export interface RegisterLiveArtifactRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'auth' | 'liveArtifacts' | 'projectStore'> { + authorizeProjectRequest: AuthorizeProjectRequest; + authorizeProjectToolRequest: AuthorizeProjectToolRequest; +} export function registerLiveArtifactRoutes(app: Express, ctx: RegisterLiveArtifactRoutesDeps) { const { db } = ctx; @@ -9,13 +16,25 @@ export function registerLiveArtifactRoutes(app: Express, ctx: RegisterLiveArtifa const { PROJECTS_DIR } = ctx.paths; const { authorizeToolRequest, requestProjectOverride, requestRunOverride } = ctx.auth; const { createLiveArtifact, listLiveArtifacts, updateLiveArtifact, refreshLiveArtifact, emitLiveArtifactEvent, emitLiveArtifactRefreshEvent, readLiveArtifactCode, setLiveArtifactCodeHeaders, ensureLiveArtifactPreview, setLiveArtifactPreviewHeaders, getLiveArtifact, listLiveArtifactRefreshLogEntries, deleteLiveArtifact } = ctx.liveArtifacts; - const { updateProject } = ctx.projectStore; + const { getProject, updateProject } = ctx.projectStore; + const authorizeProject = async ( + req: any, + res: any, + projectId: string, + options: { mode: 'read'; allowNavigationQuery?: boolean } | { + mode: 'write'; + capability: 'writeFiles'; + }, + ) => { + return ctx.authorizeProjectRequest(req, res, projectId, options); + }; app.get('/api/live-artifacts', async (req, res) => { try { const projectId = typeof req.query.projectId === 'string' ? req.query.projectId : undefined; if (!projectId) { return sendApiError(res, 400, 'BAD_REQUEST', 'projectId query parameter is required'); } + if (!await authorizeProject(req, res, projectId, { mode: 'read' })) return; const artifacts = await listLiveArtifacts({ projectsRoot: PROJECTS_DIR, @@ -37,6 +56,12 @@ export function registerLiveArtifactRoutes(app: Express, ctx: RegisterLiveArtifa if (!projectId) { return sendApiError(res, 400, 'BAD_REQUEST', 'projectId query parameter is required'); } + if (!await authorizeProject( + req, + res, + projectId, + { mode: 'read', allowNavigationQuery: true }, + )) return; const variant = typeof req.query.variant === 'string' ? req.query.variant : 'rendered'; if (variant === 'template' || variant === 'rendered-source') { @@ -71,6 +96,7 @@ export function registerLiveArtifactRoutes(app: Express, ctx: RegisterLiveArtifa if (!projectId) { return sendApiError(res, 400, 'BAD_REQUEST', 'projectId query parameter is required'); } + if (!await authorizeProject(req, res, projectId, { mode: 'read' })) return; const record = await getLiveArtifact({ projectsRoot: PROJECTS_DIR, @@ -89,6 +115,7 @@ export function registerLiveArtifactRoutes(app: Express, ctx: RegisterLiveArtifa if (!projectId) { return sendApiError(res, 400, 'BAD_REQUEST', 'projectId query parameter is required'); } + if (!await authorizeProject(req, res, projectId, { mode: 'read' })) return; const refreshes = await listLiveArtifactRefreshLogEntries({ projectsRoot: PROJECTS_DIR, @@ -116,6 +143,11 @@ export function registerLiveArtifactRoutes(app: Express, ctx: RegisterLiveArtifa details: { suppliedRunId: createdByRunId }, }); } + if (!await ctx.authorizeProjectToolRequest( + res, + toolGrant.projectId, + { mode: 'write', capability: 'writeFiles' }, + )) return; const record = await createLiveArtifact({ projectsRoot: PROJECTS_DIR, @@ -142,6 +174,11 @@ export function registerLiveArtifactRoutes(app: Express, ctx: RegisterLiveArtifa details: { suppliedProjectId: projectId }, }); } + if (!await ctx.authorizeProjectToolRequest( + res, + toolGrant.projectId, + { mode: 'read' }, + )) return; const artifacts = await listLiveArtifacts({ projectsRoot: PROJECTS_DIR, @@ -166,6 +203,11 @@ export function registerLiveArtifactRoutes(app: Express, ctx: RegisterLiveArtifa if (typeof artifactId !== 'string' || artifactId.length === 0) { return sendApiError(res, 400, 'BAD_REQUEST', 'artifactId is required'); } + if (!await ctx.authorizeProjectToolRequest( + res, + toolGrant.projectId, + { mode: 'write', capability: 'writeFiles' }, + )) return; const record = await updateLiveArtifact({ projectsRoot: PROJECTS_DIR, @@ -195,6 +237,11 @@ export function registerLiveArtifactRoutes(app: Express, ctx: RegisterLiveArtifa if (typeof artifactId !== 'string' || artifactId.length === 0) { return sendApiError(res, 400, 'BAD_REQUEST', 'artifactId is required'); } + if (!await ctx.authorizeProjectToolRequest( + res, + toolGrant.projectId, + { mode: 'write', capability: 'writeFiles' }, + )) return; let result; try { @@ -233,6 +280,12 @@ export function registerLiveArtifactRoutes(app: Express, ctx: RegisterLiveArtifa if (!projectId) { return sendApiError(res, 400, 'BAD_REQUEST', 'projectId query parameter is required'); } + if (!await authorizeProject( + req, + res, + projectId, + { mode: 'write', capability: 'writeFiles' }, + )) return; const record = await updateLiveArtifact({ projectsRoot: PROJECTS_DIR, @@ -253,6 +306,12 @@ export function registerLiveArtifactRoutes(app: Express, ctx: RegisterLiveArtifa if (!projectId) { return sendApiError(res, 400, 'BAD_REQUEST', 'projectId query parameter is required'); } + if (!await authorizeProject( + req, + res, + projectId, + { mode: 'write', capability: 'writeFiles' }, + )) return; const existing = await getLiveArtifact({ projectsRoot: PROJECTS_DIR, @@ -282,6 +341,12 @@ export function registerLiveArtifactRoutes(app: Express, ctx: RegisterLiveArtifa if (!projectId) { return sendApiError(res, 400, 'BAD_REQUEST', 'projectId query parameter is required'); } + if (!await authorizeProject( + req, + res, + projectId, + { mode: 'write', capability: 'writeFiles' }, + )) return; let result; try { diff --git a/apps/daemon/src/routes/media.ts b/apps/daemon/src/routes/media.ts index 31b9b379338..df8020c899d 100644 --- a/apps/daemon/src/routes/media.ts +++ b/apps/daemon/src/routes/media.ts @@ -8,6 +8,10 @@ import type { AnalyticsContext } from '../analytics.js'; import { defaultMediaExecutionPolicy, mediaPolicyDenial } from '../media/policy.js'; import type { ImageGenerationRequestSummary } from '../media/image-generation-retry.js'; import type { RouteDeps } from '../server-context.js'; +import type { + AuthorizeProjectRequest, + AuthorizeProjectToolRequest, +} from '../collab/project-request-authority.js'; import { proxyDispatcherRequestInit } from '../connectionTest.js'; import { aihubmixCatalogUrl, @@ -16,7 +20,15 @@ import { type AIHubMixCatalogType, } from '../integrations/aihubmix.js'; import { isSandboxModeEnabled } from '../sandbox-mode.js'; -import type { ToolTokenGrant } from '../tool-tokens.js'; +import { + MEDIA_TASK_WAIT_TOOL_ENDPOINT, + type ToolTokenGrant, +} from '../tool-tokens.js'; +import { + authorizePersistedAutomationWorkspaceScope, + normalizePersistedAutomationWorkspaceScope, +} from '../automations/workspace-scope.js'; +import type { WorkspaceDirectoryFetchResult } from '../collab/vela-workspace-context.js'; const LONG_MEDIA_PROXY_TIMEOUT_MS = 10 * 60 * 1000; @@ -26,7 +38,11 @@ const LONG_MEDIA_PROXY_TIMEOUT_MS = 10 * 60 * 1000; const AIHUBMIX_CATALOG_TTL_MS = 5 * 60 * 1000; const aihubmixCatalogCache = new Map<string, { at: number; models: Array<{ id: string; label: string }> }>(); -export interface RegisterMediaRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'ids' | 'auth' | 'media' | 'appConfig' | 'orbit' | 'nativeDialogs' | 'projectStore' | 'projectFiles' | 'conversations' | 'research'> {} +export interface RegisterMediaRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'ids' | 'auth' | 'media' | 'appConfig' | 'orbit' | 'nativeDialogs' | 'projectStore' | 'projectFiles' | 'conversations' | 'research'> { + fetchWorkspaceDirectory?: () => Promise<WorkspaceDirectoryFetchResult>; + authorizeProjectRequest: AuthorizeProjectRequest; + authorizeProjectToolRequest: AuthorizeProjectToolRequest; +} export type LegacyMediaRouteGrantDecision = | { ok: true; grant: ToolTokenGrant | null } @@ -487,14 +503,57 @@ export function registerMediaRoutes(app: Express, ctx: RegisterMediaRoutesDeps) return res.status(403).json({ error: 'cross-origin request rejected' }); } try { + const currentConfig = await readAppConfig(RUNTIME_DATA_DIR); + if ( + req.body?.orbit + && typeof req.body.orbit === 'object' + && Object.hasOwn(req.body.orbit, 'workspaceScope') + && JSON.stringify(req.body.orbit) !== JSON.stringify(currentConfig.orbit) + ) { + const scope = normalizePersistedAutomationWorkspaceScope( + req.body.orbit.workspaceScope, + ); + if (req.body.orbit.workspaceScope !== null && !scope) { + return res.status(400).json({ + error: 'Orbit Workspace scope must contain workspaceId and workspaceMemberId', + code: 'WORKSPACE_CONTEXT_INCOMPLETE', + }); + } + if (scope) { + const claimedWorkspaceId = String(req.get('x-od-workspace-id') ?? '').trim(); + const claimedMemberId = String(req.get('x-od-workspace-member-id') ?? '').trim(); + if ( + claimedWorkspaceId !== scope.workspaceId + || claimedMemberId !== scope.workspaceMemberId + ) { + return res.status(400).json({ + error: 'Orbit Workspace scope must match the explicit request identity', + code: 'WORKSPACE_CONTEXT_INCOMPLETE', + }); + } + await authorizePersistedAutomationWorkspaceScope( + scope, + ctx.fetchWorkspaceDirectory, + ); + } + } const config = await writeAppConfig(RUNTIME_DATA_DIR, req.body); orbitService.configure(config.orbit); onAppConfigWritten?.(config); res.json({ config }); } catch (err: any) { + const status = err?.code === 'WORKSPACE_AUTHORITY_UNAVAILABLE' + ? 503 + : err?.code === 'WORKSPACE_ACCESS_DENIED' + ? 403 + : 500; res - .status(500) - .json({ error: String(err && err.message ? err.message : err) }); + .status(status) + .json({ + error: String(err && err.message ? err.message : err), + ...(err?.code ? { code: err.code } : {}), + ...(err?.retryable ? { retryable: true } : {}), + }); } }); @@ -571,9 +630,18 @@ export function registerMediaRoutes(app: Express, ctx: RegisterMediaRoutesDeps) const locale = typeof req.body?.locale === 'string' ? req.body.locale : null; res.json(await orbitService.start('manual', { locale })); } catch (err: any) { + const status = err?.code === 'WORKSPACE_AUTHORITY_UNAVAILABLE' + ? 503 + : err?.code === 'WORKSPACE_ACCESS_DENIED' + ? 403 + : 500; res - .status(500) - .json({ error: String(err && err.message ? err.message : err) }); + .status(status) + .json({ + error: String(err && err.message ? err.message : err), + ...(err?.code ? { code: err.code } : {}), + ...(err?.retryable ? { retryable: true } : {}), + }); } }); @@ -625,6 +693,16 @@ export function registerMediaRoutes(app: Express, ctx: RegisterMediaRoutesDeps) } try { + const project = getProject(db, req.params.id); + if (!project) { + return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + } + if (!await ctx.authorizeProjectRequest( + req, + res, + project.id, + { mode: 'write', capability: 'writeFiles' }, + )) return; const grant = optionalToolGrantFromRequest(req, { operation: 'media:generate' }); const grantDecision = resolveLegacyMediaRouteGrant({ grant, @@ -655,6 +733,11 @@ export function registerMediaRoutes(app: Express, ctx: RegisterMediaRoutesDeps) const grant = authorizeToolRequest(req, res, 'media:generate'); if (!grant) return; try { + if (!await ctx.authorizeProjectToolRequest( + res, + grant.projectId, + { mode: 'write', capability: 'writeFiles' }, + )) return; await handleGenerate(req, res, { projectId: grant.projectId, grant }); } catch (err: any) { const status = typeof err?.status === 'number' ? err.status : 400; @@ -711,9 +794,49 @@ export function registerMediaRoutes(app: Express, ctx: RegisterMediaRoutesDeps) if (!isLocalSameOrigin(req, getResolvedPort())) { return res.status(403).json({ error: 'cross-origin request rejected' }); } + const authorizationHeader = req.get('authorization'); + // Once a caller chooses the tool-token lane, invalid, expired, or + // under-scoped credentials must not downgrade to project authorization. + const toolGrant = typeof authorizationHeader === 'string' + ? authorizeToolRequest( + req, + res, + 'media:generate', + { endpoint: MEDIA_TASK_WAIT_TOOL_ENDPOINT }, + ) + : null; + if (typeof authorizationHeader === 'string' && !toolGrant) return; + if ( + toolGrant + && !await ctx.authorizeProjectToolRequest( + res, + toolGrant.projectId, + { mode: 'read' }, + ) + ) return; + + // Token callers must prove fresh project authority before task lookup so + // a revoked member or an authority outage cannot probe task existence. const taskId = req.params.id; const task = getLiveMediaTask(taskId); if (!task) return res.status(404).json({ error: 'task not found' }); + if (toolGrant) { + if (requestProjectOverride(task.projectId, toolGrant.projectId)) { + return sendApiError( + res, + 403, + 'FORBIDDEN', + 'media task belongs to a different project', + ); + } + } else if (!await ctx.authorizeProjectRequest( + req, + res, + task.projectId, + { mode: 'read' }, + )) { + return; + } const since = Number.isFinite(req.body?.since) ? Number(req.body.since) : 0; const requestedTimeout = Number.isFinite(req.body?.timeoutMs) @@ -748,11 +871,15 @@ export function registerMediaRoutes(app: Express, ctx: RegisterMediaRoutesDeps) res.on('close', wake); }); - app.get('/api/projects/:id/media/tasks', (req, res) => { + app.get('/api/projects/:id/media/tasks', async (req, res) => { if (!isLocalSameOrigin(req, getResolvedPort())) { return res.status(403).json({ error: 'cross-origin request rejected' }); } const projectId = req.params.id; + if (!getProject(db, projectId)) { + return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + } + if (!await ctx.authorizeProjectRequest(req, res, projectId, { mode: 'read' })) return; const includeDone = req.query.includeDone === '1' || req.query.includeDone === 'true'; const tasks = listMediaTasksByProject(db, projectId, { diff --git a/apps/daemon/src/routes/plugins/assets.ts b/apps/daemon/src/routes/plugins/assets.ts index 260fd38ca5e..a9e5bf06d2d 100644 --- a/apps/daemon/src/routes/plugins/assets.ts +++ b/apps/daemon/src/routes/plugins/assets.ts @@ -1,9 +1,20 @@ import type { Express, Request, Response } from 'express'; import type * as BetterSqlite3 from 'better-sqlite3'; import path from 'node:path'; +import type { WorkspaceCollabContext } from '@open-design/contracts'; +import { + resolveOptionalWorkspaceRequestAuthority, + type VerifyWorkspaceRequestAuthority, +} from '../../collab/workspace-resource-mutation.js'; export interface RegisterPluginAssetRoutesDeps { db: PluginDbLike; + verifyWorkspaceRequestAuthority?: VerifyWorkspaceRequestAuthority; + getWorkspacePlugin?: ( + db: PluginDbLike, + id: string, + workspaceId: string | null, + ) => InstalledPluginLike | null | Promise<InstalledPluginLike | null>; pluginAssetCache: { get(url: string): Promise<{ buf: Buffer; contentType: string }> }; AssetCacheError: new (...args: unknown[]) => Error & { status: number }; assetCacheRewriteUrl: (url: string) => string; @@ -38,11 +49,84 @@ interface InstalledPluginLike { export function registerPluginAssetRoutes(app: Express, deps: RegisterPluginAssetRoutesDeps): void { const { db, pluginAssetCache, AssetCacheError, assetCacheRewriteUrl, isCacheableExternalUrl, assembleExample } = deps; const routeParam = (value: string | string[] | undefined): string => Array.isArray(value) ? value[0] ?? '' : value ?? ''; + const requestWithNavigationScope = (req: Request): Request | 'conflict' => { + const workspaceId = typeof req.query.workspaceId === 'string' + ? req.query.workspaceId.trim() + : ''; + const workspaceMemberId = typeof req.query.workspaceMemberId === 'string' + ? req.query.workspaceMemberId.trim() + : ''; + if (!workspaceId && !workspaceMemberId) return req; + const headerWorkspaceId = req.get('x-od-workspace-id')?.trim() ?? ''; + const headerWorkspaceMemberId = + req.get('x-od-workspace-member-id')?.trim() ?? ''; + if ( + (headerWorkspaceId || headerWorkspaceMemberId) + && ( + headerWorkspaceId !== workspaceId + || headerWorkspaceMemberId !== workspaceMemberId + ) + ) { + return 'conflict'; + } + return { + get(name: string) { + const normalized = name.toLowerCase(); + if (normalized === 'x-od-workspace-id') return workspaceId || undefined; + if (normalized === 'x-od-workspace-member-id') { + return workspaceMemberId || undefined; + } + return req.get(name); + }, + } as Request; + }; + const resolveWorkspaceAuthority = async ( + req: Request, + res: Response, + ): Promise<WorkspaceCollabContext | null | undefined> => { + const scopedRequest = requestWithNavigationScope(req); + if (scopedRequest === 'conflict') { + res.status(400).json({ + error: 'WORKSPACE_CONTEXT_CONFLICT', + message: 'workspace header and navigation scope must match', + }); + return undefined; + } + const authority = await resolveOptionalWorkspaceRequestAuthority( + scopedRequest, + deps.verifyWorkspaceRequestAuthority, + ); + if (!authority.ok) { + res.status(authority.status).json({ + error: authority.code, + message: authority.message, + ...(authority.retryable ? { retryable: true } : {}), + }); + return undefined; + } + return authority.context; + }; + const resolvePlugin = async ( + id: string, + authority: WorkspaceCollabContext | null, + ): Promise<InstalledPluginLike | null> => { + if (deps.getWorkspacePlugin) { + return deps.getWorkspacePlugin(db, id, authority?.workspaceId ?? null); + } + const { getInstalledPlugin } = await import('../../plugins/index.js'); + return getInstalledPlugin(db, id) as InstalledPluginLike | null; + }; + const navigationScopeQuery = ( + authority: WorkspaceCollabContext | null, + ): string => authority + ? `?workspaceId=${encodeURIComponent(authority.workspaceId)}&workspaceMemberId=${encodeURIComponent(authority.workspaceMemberId)}` + : ''; async function servePluginSandboxedHtml(req: Request, res: Response, pickCandidates: (plugin: InstalledPluginLike) => Promise<string[]> | string[]) { try { - const { getInstalledPlugin } = await import('../../plugins/index.js'); - const plugin = getInstalledPlugin(db, routeParam(req.params.id)) as InstalledPluginLike | null; + const authority = await resolveWorkspaceAuthority(req, res); + if (authority === undefined) return; + const plugin = await resolvePlugin(routeParam(req.params.id), authority); if (!plugin) return res.status(404).json({ error: 'plugin not found' }); const candidates = (await pickCandidates(plugin)).filter((p): p is string => typeof p === 'string' && p.length > 0); const fsp = await import('node:fs/promises'); @@ -108,10 +192,18 @@ export function registerPluginAssetRoutes(app: Express, deps: RegisterPluginAsse res.setHeader('Content-Security-Policy', "default-src 'none'; img-src 'self' data: blob:; media-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'none'; frame-ancestors 'self'"); res.setHeader('X-Content-Type-Options', 'nosniff'); const ext = path.extname(contentPath).toLowerCase(); - const ct = ext === '.html' ? 'text/html; charset=utf-8' : ext === '.js' ? 'application/javascript; charset=utf-8' : ext === '.css' ? 'text/css; charset=utf-8' : ext === '.json' ? 'application/json; charset=utf-8' : ext === '.svg' ? 'image/svg+xml' : ext === '.png' ? 'image/png' : ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : 'application/octet-stream'; + const ct = ext === '.html' ? 'text/html; charset=utf-8' : ext === '.js' ? 'application/javascript; charset=utf-8' : ext === '.css' ? 'text/css; charset=utf-8' : ext === '.json' ? 'application/json; charset=utf-8' : ext === '.md' || ext === '.markdown' ? 'text/markdown; charset=utf-8' : ext === '.svg' ? 'image/svg+xml' : ext === '.png' ? 'image/png' : ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : 'application/octet-stream'; res.setHeader('Content-Type', ct); if (ext === '.html' && typeof contentRel === 'string') { - buf = Buffer.from(rewritePluginAssetUrls(buf.toString('utf8'), routeParam(req.params.id), path.posix.dirname(contentRel.replace(/\\/g, '/'))), 'utf8'); + buf = Buffer.from( + rewritePluginAssetUrls( + buf.toString('utf8'), + routeParam(req.params.id), + path.posix.dirname(contentRel.replace(/\\/g, '/')), + navigationScopeQuery(authority), + ), + 'utf8', + ); } res.send(buf); } catch (err) { @@ -132,8 +224,23 @@ export function registerPluginAssetRoutes(app: Express, deps: RegisterPluginAsse return /\.html?$/i.test(pathOnly) ? pathOnly : null; } - function rewritePluginAssetUrls(html: string, pluginId: string, baseDir: string): string { + function rewritePluginAssetUrls( + html: string, + pluginId: string, + baseDir: string, + workspaceQuery = '', + ): string { if (!html) return html; + const scopeAssetSuffix = (suffix: string): string => { + if (!workspaceQuery) return suffix; + const scope = workspaceQuery.slice(1); + if (!suffix) return workspaceQuery; + if (suffix.startsWith('#')) return `${workspaceQuery}${suffix}`; + const hashAt = suffix.indexOf('#'); + return hashAt === -1 + ? `${suffix}&${scope}` + : `${suffix.slice(0, hashAt)}&${scope}${suffix.slice(hashAt)}`; + }; const safeBase = baseDir === '.' ? '' : baseDir; const withAttrs = html.replace(/(\s(?:src|href|poster)\s*=\s*)(['"])([^'"]+)(\2)/gi, (match, attr, quote, rawValue, closeQuote) => { const value = String(rawValue).trim(); @@ -146,7 +253,7 @@ export function registerPluginAssetRoutes(app: Express, deps: RegisterPluginAsse const suffix = splitAt === -1 ? '' : value.slice(splitAt); const normalized = path.posix.normalize(path.posix.join(safeBase, rel)); if (normalized === '.' || normalized === '..' || normalized.startsWith('../') || path.posix.isAbsolute(normalized)) return match; - return `${attr}${quote}/api/plugins/${encodeURIComponent(pluginId)}/asset/${normalized}${suffix}${closeQuote}`; + return `${attr}${quote}/api/plugins/${encodeURIComponent(pluginId)}/asset/${normalized}${scopeAssetSuffix(suffix)}${closeQuote}`; }); const withQuoted = withAttrs.replace(/(['"])(https?:\/\/[^'"]+)\1/g, (match, quote, rawValue) => { const value = String(rawValue).trim(); @@ -225,8 +332,9 @@ export function registerPluginAssetRoutes(app: Express, deps: RegisterPluginAsse }); app.get('/api/plugins/:id/asset/*splat', async (req, res) => { try { - const { getInstalledPlugin } = await import('../../plugins/index.js'); - const plugin = getInstalledPlugin(db, routeParam(req.params.id)) as InstalledPluginLike | null; + const authority = await resolveWorkspaceAuthority(req, res); + if (authority === undefined) return; + const plugin = await resolvePlugin(routeParam(req.params.id), authority); if (!plugin) return res.status(404).json({ error: 'plugin not found' }); const splatParam = req.params.splat; const relpath = Array.isArray(splatParam) ? splatParam.join('/') : String(splatParam ?? ''); @@ -262,7 +370,7 @@ export function registerPluginAssetRoutes(app: Express, deps: RegisterPluginAsse res.setHeader('Content-Security-Policy', "default-src 'none'; img-src 'self' data: blob:; media-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'none'; frame-ancestors 'self'"); res.setHeader('X-Content-Type-Options', 'nosniff'); const ext = path.extname(resolved).toLowerCase(); - const ct = ext === '.html' ? 'text/html; charset=utf-8' : ext === '.js' ? 'application/javascript; charset=utf-8' : ext === '.css' ? 'text/css; charset=utf-8' : ext === '.json' ? 'application/json; charset=utf-8' : ext === '.svg' ? 'image/svg+xml' : ext === '.png' ? 'image/png' : ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : 'application/octet-stream'; + const ct = ext === '.html' ? 'text/html; charset=utf-8' : ext === '.js' ? 'application/javascript; charset=utf-8' : ext === '.css' ? 'text/css; charset=utf-8' : ext === '.json' ? 'application/json; charset=utf-8' : ext === '.md' || ext === '.markdown' ? 'text/markdown; charset=utf-8' : ext === '.svg' ? 'image/svg+xml' : ext === '.png' ? 'image/png' : ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : 'application/octet-stream'; res.setHeader('Content-Type', ct); res.send(buf); } catch (err) { diff --git a/apps/daemon/src/routes/plugins/index.ts b/apps/daemon/src/routes/plugins/index.ts index a2b476eae0e..78450a51842 100644 --- a/apps/daemon/src/routes/plugins/index.ts +++ b/apps/daemon/src/routes/plugins/index.ts @@ -5,12 +5,30 @@ import type { PluginDuplicateProjectResponse, Project, ProjectMetadata, + WorkspaceCollabContext, } from '@open-design/contracts'; +import { TeamResourceCopyForbiddenError } from '@open-design/contracts'; import { duplicatePluginExampleIntoProject, PluginDuplicateProjectError, } from '../../plugins/duplicate-project.js'; +import { + enforceTeamResourceCopyAllowed, + type TeamResourceStateProvider, +} from '../../collab/team-resource-state.js'; +import { + enforceVerifiedWorkspaceResourceMutation, + resolveOptionalWorkspaceRequestAuthority, + type VerifyWorkspaceRequestAuthority, +} from '../../collab/workspace-resource-mutation.js'; +import { + authorizeCreatedProjectWorkspace, + bindCreatedProjectToWorkspace, + sendCreatedProjectWorkspaceError, +} from '../../collab/created-project-workspace.js'; +import type { WorkspaceDirectoryFetchResult } from '../../collab/vela-workspace-context.js'; import type { PluginShareAction } from '../../services/plugin-share-tasks.js'; +import type { AuthorizeProjectRequest } from '../../collab/project-request-authority.js'; export interface RegisterPluginEventRoutesDeps { http: { requireLocalDaemonRequest: RequestHandler }; @@ -26,6 +44,7 @@ interface SqliteDbLike { get(...params: unknown[]): unknown; run(...params: unknown[]): unknown; }; + transaction<T>(run: () => T): () => T; } interface InstalledPluginLike { @@ -45,6 +64,15 @@ interface AppliedPluginSnapshotLike { [key: string]: unknown; } +// The narrow slice of a `workspace_resources` row the mutation gate needs — +// see collab/workspace-resource-mutation.ts's `WorkspaceResourceAccessInput`, +// which this mirrors so `enforceWorkspaceResourceMutation` accepts it as-is. +interface WorkspaceResourceBindingRow { + visibility?: string | null; + resourceState?: string | null; + createdByWorkspaceMemberId?: string | null; +} + interface MissingInputErrorLike extends Error { fields: string[]; } @@ -60,6 +88,7 @@ interface PluginApplyResult { } interface PluginShareTaskLike { + projectId: string; status: 'queued' | 'running' | 'done' | 'failed'; progress: string[]; waiters: Set<() => void>; @@ -85,7 +114,12 @@ interface PluginRouteHelpers { assembleExample(templateHtml: string, slidesHtml: string, title: string): string; sendMulterError(res: Response, err: unknown): unknown; decodeMultipartFilename(name: string): string; - installOrUpgradePlugin(req: Request, res: Response, mode: 'install' | 'upgrade'): Promise<unknown>; + installOrUpgradePlugin( + req: Request, + res: Response, + mode: 'install' | 'upgrade', + authority: WorkspaceCollabContext | null, + ): Promise<unknown>; loadPluginRegistryView(): Promise<unknown>; buildConnectorProbe(service: unknown): unknown; handleShareProject(req: Request, res: Response): Promise<unknown>; @@ -105,20 +139,62 @@ interface PluginRouteHelpers { export interface RegisterPluginRoutesDeps { db: SqliteDbLike; + authorizeProjectRequest: AuthorizeProjectRequest; + /** Team-resource copy red-line (D3). When present, a frozen team plugin cannot + * be duplicated into a personal project. Omit to skip the guard (no-op). */ + teamResources?: TeamResourceStateProvider; paths: { PROJECTS_DIR: string; PLUGIN_REGISTRY_ROOTS: string[]; PLUGIN_LOCKFILE_PATH: string }; ids: { randomId(): string }; projectStore: { insertProject(db: SqliteDbLike, project: unknown): Project | null; getProject(db: SqliteDbLike, id: string): Project | null; + ensureWorkspaceProject(db: SqliteDbLike, input: unknown): unknown; dbDeleteProject(db: SqliteDbLike, id: string): unknown; removeProjectDir(projectsRoot: string, projectId: string): Promise<unknown>; }; + fetchProjectCreationWorkspaceDirectory?: () => Promise<WorkspaceDirectoryFetchResult>; + verifyWorkspaceRequestAuthority?: VerifyWorkspaceRequestAuthority; conversations: { insertConversation(db: SqliteDbLike, conversation: unknown): unknown; }; + /** + * Read access to the generic `workspace_resources` binding table (db.ts), + * pre-bound to no particular resource type — routes below pass `'plugin'` + * explicitly so a future skill/design-system route can reuse the exact + * same deps shape. Optional so callers that never reach the uninstall + * route (`registerProjectPluginRoutes`, existing narrow-scope tests) don't + * have to wire it; `registerPluginRoutes`'s uninstall handler treats an + * absent value as "no gate" rather than crashing. + */ + workspaceResources?: { + getWorkspaceResource: ( + db: SqliteDbLike, + resourceType: string, + workspaceId: string, + resourceId: string, + ) => WorkspaceResourceBindingRow | null | undefined; + getWorkspaceResourceByResourceId: ( + db: SqliteDbLike, + resourceType: string, + resourceId: string, + ) => WorkspaceResourceBindingRow | null | undefined; + workspaceTeamPluginBindingAllowsRead?: ( + db: SqliteDbLike, + workspaceId: string, + pluginId: string, + ) => boolean; + }; plugins: { - listInstalledPlugins: (db: SqliteDbLike) => InstalledPluginLike[]; + listInstalledPlugins: ( + db: SqliteDbLike, + workspaceId?: string | null, + ) => InstalledPluginLike[] | Promise<InstalledPluginLike[]>; getInstalledPlugin: (db: SqliteDbLike, id: string) => InstalledPluginLike | null; + getWorkspacePlugin?: ( + db: SqliteDbLike, + id: string, + workspaceId: string | null, + ) => InstalledPluginLike | null | Promise<InstalledPluginLike | null>; installPlugin: (db: SqliteDbLike, args: unknown) => AsyncIterable<unknown>; isSafePluginId: (id: string) => boolean; uninstallPlugin: (db: SqliteDbLike, id: string, roots: string[]) => Promise<{ ok: boolean; removedFolder?: boolean; warning?: string }>; @@ -170,25 +246,180 @@ export function registerPluginEventRoutes(app: Express, deps: RegisterPluginEven } export function registerPluginRoutes(app: Express, deps: RegisterPluginRoutesDeps): void { - const { db, paths, ids, projectStore, conversations, plugins, helpers } = deps; - app.get('/api/plugins', async (_req, res) => { try { res.json({ plugins: helpers.applyBakedPreviews(plugins.listInstalledPlugins(db), helpers.PLUGIN_PREVIEWS_DIR) }); } catch (err) { res.status(500).json({ error: String(err) }); } }); - app.get('/api/plugins/:id', async (req, res) => { try { const plugin = plugins.getInstalledPlugin(db, req.params.id); if (!plugin) return res.status(404).json({ error: 'plugin not found' }); res.json(plugin); } catch (err) { res.status(500).json({ error: String(err) }); } }); + const { db, paths, ids, projectStore, conversations, plugins, helpers, teamResources, workspaceResources } = deps; + const resolveWorkspaceAuthority = async ( + req: Request, + res: Response, + ): Promise<WorkspaceCollabContext | null | undefined> => { + const authority = await resolveOptionalWorkspaceRequestAuthority( + req, + deps.verifyWorkspaceRequestAuthority, + ); + if (!authority.ok) { + helpers.sendApiError( + res, + authority.status, + authority.code, + authority.message, + ); + return undefined; + } + return authority.context; + }; + const resolveRequestPlugin = async ( + id: string, + authority: WorkspaceCollabContext | null, + ) => { + const workspaceId = authority?.workspaceId ?? null; + return plugins.getWorkspacePlugin + ? plugins.getWorkspacePlugin(db, id, workspaceId) + : plugins.getInstalledPlugin(db, id); + }; + app.get('/api/plugins', async (req, res) => { try { const authority = await resolveWorkspaceAuthority(req, res); if (authority === undefined) return; const visible = await plugins.listInstalledPlugins(db, authority?.workspaceId ?? null); res.json({ plugins: helpers.applyBakedPreviews(visible, helpers.PLUGIN_PREVIEWS_DIR) }); } catch (err) { res.status(500).json({ error: String(err) }); } }); + app.get('/api/plugins/:id', async (req, res) => { try { const authority = await resolveWorkspaceAuthority(req, res); if (authority === undefined) return; const plugin = await resolveRequestPlugin(req.params.id, authority); if (!plugin) return res.status(404).json({ error: 'plugin not found' }); res.json(plugin); } catch (err) { res.status(500).json({ error: String(err) }); } }); app.post('/api/plugins/upload-zip', (req, res) => helpers.pluginUpload.single('file')(req, res, async (err: unknown) => { if (err) return helpers.sendMulterError(res, err); try { const file = req.file; if (!file?.buffer) return res.status(400).json({ error: 'file is required' }); const result = await helpers.pluginInstallation.stageUploadedPluginZip(file.buffer, `upload:zip:${helpers.decodeMultipartFilename(file.originalname || 'plugin.zip')}`); res.status((result as { ok?: boolean }).ok ? 200 : 400).json(result); } catch (uploadErr: unknown) { res.status(400).json({ ok: false, warnings: [], message: uploadErr instanceof Error ? uploadErr.message : String(uploadErr), log: [] }); } })); app.post('/api/plugins/upload-folder', (req, res) => helpers.pluginUpload.array('files', 500)(req, res, async (err: unknown) => { if (err) return helpers.sendMulterError(res, err); try { const files = Array.isArray(req.files) ? req.files as Array<{ buffer: Buffer; originalname: string }> : []; if (files.length === 0) return res.status(400).json({ error: 'files are required' }); const result = await helpers.pluginInstallation.stageUploadedPluginFolder(files, req.body?.paths); res.status((result as { ok?: boolean } | null)?.ok ? 200 : 400).json(result); } catch (uploadErr: unknown) { res.status(400).json({ ok: false, warnings: [], message: uploadErr instanceof Error ? uploadErr.message : String(uploadErr), log: [] }); } })); - app.post('/api/plugins/install', async (req, res) => helpers.installOrUpgradePlugin(req, res, 'install')); - app.post('/api/plugins/:id/uninstall', async (req, res) => { try { if (!plugins.isSafePluginId(req.params.id)) return res.status(400).json({ error: 'invalid plugin id' }); const result = await plugins.uninstallPlugin(db, req.params.id, paths.PLUGIN_REGISTRY_ROOTS); if (!result.ok && !result.removedFolder) return res.status(404).json({ error: 'plugin not found', warning: result.warning }); res.json(result); } catch (err) { res.status(500).json({ error: String(err) }); } }); - app.post('/api/plugins/:id/upgrade', async (req, res) => helpers.installOrUpgradePlugin(req, res, 'upgrade')); - app.post('/api/plugins/:id/apply', async (req, res) => { try { const plugin = plugins.getInstalledPlugin(db, req.params.id); if (!plugin) return res.status(404).json({ error: 'plugin not found' }); const body = req.body && typeof req.body === 'object' ? req.body as Record<string, unknown> : {}; const inputs = body.inputs && typeof body.inputs === 'object' ? body.inputs : {}; const grantCaps = Array.isArray(body.grantCaps) ? body.grantCaps.filter((c: unknown): c is string => typeof c === 'string') : []; const locale = typeof body.locale === 'string' ? body.locale : undefined; const registry = await helpers.loadPluginRegistryView(); const connectorProbe = helpers.buildConnectorProbe(helpers.connectorService); const computed = plugins.applyPlugin({ plugin, inputs, registry, locale, connectorProbe }); if (grantCaps.length > 0) { const merged = new Set([...computed.result.capabilitiesGranted, ...grantCaps]); computed.result.capabilitiesGranted = Array.from(merged); computed.result.appliedPlugin.capabilitiesGranted = Array.from(merged); } res.json({ ok: true, ...computed.result, warnings: computed.warnings, manifestSourceDigest: computed.manifestSourceDigest }); } catch (err: unknown) { if (err instanceof plugins.MissingInputError) return res.status(422).json({ error: 'missing_inputs', fields: err.fields }); res.status(500).json({ error: String(err) }); } }); + app.post('/api/plugins/install', async (req, res) => { + const authority = await resolveWorkspaceAuthority(req, res); + if (authority === undefined) return; + return helpers.installOrUpgradePlugin(req, res, 'install', authority); + }); + // This route used to carry NO permission check at all: any caller (any + // workspace, any role) could uninstall any plugin. Now gated the same way + // project mutations are, via the shared + // `enforceWorkspaceResourceMutation` (collab/workspace-resource-mutation.ts). + // + // The gate only applies when the plugin has an actual `workspace_resources` + // binding row (i.e. it was installed through the workspace-aware + // `/api/plugins/install` after this shipped). A plugin installed BEFORE + // this round — bundled or user-installed — has no binding row at all; + // per the design's "no retroactive tagging" rule (same one design-systems' + // `designSystemVisibleFromWorkspace` already ships), an unbound resource + // stays outside the isolation regime rather than becoming permanently + // un-uninstallable the moment a caller happens to carry workspace headers. + app.post('/api/plugins/:id/uninstall', async (req, res) => { + try { + if (!plugins.isSafePluginId(req.params.id)) return res.status(400).json({ error: 'invalid plugin id' }); + const authority = await resolveWorkspaceAuthority(req, res); + if (authority === undefined) return; + const requestedPlugin = await resolveRequestPlugin(req.params.id, authority); + if ( + typeof requestedPlugin?.source === 'string' && + requestedPlugin.source.startsWith('team:plugin:') + ) { + return res.status(403).json({ error: 'WORKSPACE_RESOURCE_MANAGE_DENIED' }); + } + const binding = workspaceResources?.getWorkspaceResourceByResourceId(db, 'plugin', req.params.id); + if (binding && workspaceResources && !await enforceVerifiedWorkspaceResourceMutation( + 'plugin', + req, + res, + helpers.sendApiError, + (dbArg, workspaceId, resourceId) => workspaceResources.getWorkspaceResource(dbArg as SqliteDbLike, 'plugin', workspaceId, resourceId), + (dbArg, resourceId) => workspaceResources.getWorkspaceResourceByResourceId(dbArg as SqliteDbLike, 'plugin', resourceId), + db, + req.params.id, + 'delete', + deps.verifyWorkspaceRequestAuthority, + )) return; + const result = await plugins.uninstallPlugin(db, req.params.id, paths.PLUGIN_REGISTRY_ROOTS); if (!result.ok && !result.removedFolder) return res.status(404).json({ error: 'plugin not found', warning: result.warning }); res.json(result); + } catch (err) { res.status(500).json({ error: String(err) }); } + }); + app.post('/api/plugins/:id/upgrade', async (req, res) => { + const authority = await resolveWorkspaceAuthority(req, res); + if (authority === undefined) return; + const binding = workspaceResources?.getWorkspaceResourceByResourceId(db, 'plugin', req.params.id); + if (binding && workspaceResources && !await enforceVerifiedWorkspaceResourceMutation( + 'plugin', + req, + res, + helpers.sendApiError, + (dbArg, workspaceId, resourceId) => workspaceResources.getWorkspaceResource(dbArg as SqliteDbLike, 'plugin', workspaceId, resourceId), + (dbArg, resourceId) => workspaceResources.getWorkspaceResourceByResourceId(dbArg as SqliteDbLike, 'plugin', resourceId), + db, + req.params.id, + 'writeFiles', + deps.verifyWorkspaceRequestAuthority, + )) return; + return helpers.installOrUpgradePlugin(req, res, 'upgrade', authority); + }); + app.post('/api/plugins/:id/apply', async (req, res) => { + try { + const authority = await resolveWorkspaceAuthority(req, res); + if (authority === undefined) return; + const plugin = await resolveRequestPlugin(req.params.id, authority); + if (!plugin) return res.status(404).json({ error: 'plugin not found' }); + const body = req.body && typeof req.body === 'object' + ? req.body as Record<string, unknown> + : {}; + const inputs = body.inputs && typeof body.inputs === 'object' ? body.inputs : {}; + const grantCaps = Array.isArray(body.grantCaps) + ? body.grantCaps.filter((c: unknown): c is string => typeof c === 'string') + : []; + const locale = typeof body.locale === 'string' ? body.locale : undefined; + const registry = await helpers.loadPluginRegistryView(); + const exactWorkspaceId = authority?.workspaceId?.trim(); + if ( + typeof plugin.source === 'string' && + plugin.source.startsWith('team:plugin:') && + ( + !exactWorkspaceId || + !workspaceResources?.workspaceTeamPluginBindingAllowsRead || + !workspaceResources.workspaceTeamPluginBindingAllowsRead( + db, + exactWorkspaceId, + req.params.id, + ) + ) + ) { + return res.status(404).json({ error: 'plugin not found' }); + } + const connectorProbe = helpers.buildConnectorProbe(helpers.connectorService); + const computed = plugins.applyPlugin({ plugin, inputs, registry, locale, connectorProbe }); + if (grantCaps.length > 0) { + const merged = new Set([...computed.result.capabilitiesGranted, ...grantCaps]); + computed.result.capabilitiesGranted = Array.from(merged); + computed.result.appliedPlugin.capabilitiesGranted = Array.from(merged); + } + res.json({ + ok: true, + ...computed.result, + warnings: computed.warnings, + manifestSourceDigest: computed.manifestSourceDigest, + }); + } catch (err: unknown) { + if (err instanceof plugins.MissingInputError) { + return res.status(422).json({ error: 'missing_inputs', fields: err.fields }); + } + res.status(500).json({ error: String(err) }); + } + }); app.post('/api/plugins/:id/duplicate-project', helpers.requireLocalDaemonRequest, async (req, res) => { let cleanupProjectId: string | null = null; let insertedProject = false; try { const pluginId = Array.isArray(req.params.id) ? req.params.id[0] ?? '' : req.params.id ?? ''; - const plugin = plugins.getInstalledPlugin(db, pluginId); + const authority = await resolveWorkspaceAuthority(req, res); + if (authority === undefined) return; + const plugin = await resolveRequestPlugin(pluginId, authority); if (!plugin) return res.status(404).json({ error: { code: 'plugin-not-found', message: 'plugin not found' } }); if (typeof plugin.id !== 'string' || typeof plugin.fsPath !== 'string') { return res.status(422).json({ error: { code: 'plugin-not-duplicable', message: 'plugin record is missing a filesystem source' } }); } + // AC-9 copy red-line (D3): a frozen team plugin cannot be duplicated into a + // personal project. Runs before any project is created (nothing to clean up + // if it throws). No-op until the resource-hub reports this plugin as a + // frozen team resource. + if (teamResources) { + await enforceTeamResourceCopyAllowed(teamResources, { kind: 'plugin', resourceId: plugin.id }); + } + const createWorkspace = await authorizeCreatedProjectWorkspace( + req, + deps.fetchProjectCreationWorkspaceDirectory, + ); + if (!createWorkspace.ok) { + return sendCreatedProjectWorkspaceError(res, createWorkspace); + } const body = req.body && typeof req.body === 'object' ? req.body as PluginDuplicateProjectRequest : {}; @@ -215,24 +446,33 @@ export function registerPluginRoutes(app: Express, deps: RegisterPluginRoutesDep }); metadata.duplicatedFromPluginEntry = duplicate.sourceEntry; metadata.entryFile = duplicate.relPath; - const project = projectStore.insertProject(db, { - id: projectId, - name: projectName, - skillId: null, - designSystemId: null, - pendingPrompt: null, - metadata, - createdAt: now, - updatedAt: now, - }); - insertedProject = true; - conversations.insertConversation(db, { - id: conversationId, - projectId, - title: null, - createdAt: now, - updatedAt: now, - }); + const project = db.transaction(() => { + const createdProject = projectStore.insertProject(db, { + id: projectId, + name: projectName, + skillId: null, + designSystemId: null, + pendingPrompt: null, + metadata, + createdAt: now, + updatedAt: now, + }); + insertedProject = true; + conversations.insertConversation(db, { + id: conversationId, + projectId, + title: null, + createdAt: now, + updatedAt: now, + }); + bindCreatedProjectToWorkspace( + (input) => projectStore.ensureWorkspaceProject(db, input), + createWorkspace.context, + projectId, + now, + ); + return createdProject; + })(); const loadedProject = projectStore.getProject(db, projectId) ?? project; if (!loadedProject) { throw new PluginDuplicateProjectError( @@ -256,8 +496,17 @@ export function registerPluginRoutes(app: Express, deps: RegisterPluginRoutesDep res.status(201).json(response); } catch (err: unknown) { if (cleanupProjectId) { - if (insertedProject) projectStore.dbDeleteProject(db, cleanupProjectId); - await projectStore.removeProjectDir(paths.PROJECTS_DIR, cleanupProjectId).catch(() => {}); + try { + if (insertedProject) projectStore.dbDeleteProject(db, cleanupProjectId); + } catch { + // The transaction normally rolled the rows back already. A failed + // compensating DELETE must never strand the managed filesystem copy. + } finally { + await projectStore.removeProjectDir(paths.PROJECTS_DIR, cleanupProjectId).catch(() => {}); + } + } + if (err instanceof TeamResourceCopyForbiddenError) { + return res.status(403).json({ error: { code: err.code, message: err.message } }); } if (err instanceof PluginDuplicateProjectError) { return res.status(err.status).json({ error: { code: err.code, message: err.message } }); @@ -266,31 +515,107 @@ export function registerPluginRoutes(app: Express, deps: RegisterPluginRoutesDep } }); app.post('/api/plugins/:id/share-project', async (req, res) => helpers.handleShareProject(req, res)); - app.post('/api/plugins/:id/doctor', async (req, res) => { try { const plugin = plugins.getInstalledPlugin(db, req.params.id); if (!plugin) return res.status(404).json({ error: 'plugin not found' }); const registry = await helpers.loadPluginRegistryView(); const connectorProbe = helpers.buildConnectorProbe(helpers.connectorService); res.json(plugins.doctorPlugin(plugin, registry, { connectorProbe })); } catch (err) { res.status(500).json({ error: String(err) }); } }); + app.post('/api/plugins/:id/doctor', async (req, res) => { try { const authority = await resolveWorkspaceAuthority(req, res); if (authority === undefined) return; const plugin = await resolveRequestPlugin(req.params.id, authority); if (!plugin) return res.status(404).json({ error: 'plugin not found' }); const registry = await helpers.loadPluginRegistryView(); const connectorProbe = helpers.buildConnectorProbe(helpers.connectorService); res.json(plugins.doctorPlugin(plugin, registry, { connectorProbe })); } catch (err) { res.status(500).json({ error: String(err) }); } }); app.post('/api/plugins/:id/trust', async (req, res) => helpers.handlePluginTrust(req, res)); app.get('/api/plugins/stats', async (_req, res) => helpers.handlePluginStats(res)); app.get('/api/applied-plugins/:snapshotId', (req, res) => { try { const snap = plugins.getSnapshot(db, req.params.snapshotId); if (!snap) return res.status(404).json({ error: 'snapshot not found' }); res.json(snap); } catch (err) { res.status(500).json({ error: String(err) }); } }); app.get('/api/applied-plugins/:snapshotId/canon', (req, res) => { try { const snap = plugins.getSnapshot(db, req.params.snapshotId); if (!snap) return res.status(404).json({ error: 'snapshot not found' }); const block = plugins.pluginPromptBlock(snap); const accepts = String(req.headers.accept ?? '').toLowerCase(); if (accepts.includes('text/plain')) { res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.send(block); return; } res.json({ snapshotId: snap.snapshotId, pluginId: snap.pluginId, block }); } catch (err) { res.status(500).json({ error: String(err) }); } }); app.get('/api/applied-plugins', (_req, res) => { try { const rows = db.prepare(`SELECT id FROM applied_plugin_snapshots ORDER BY applied_at DESC LIMIT 500`).all() as SqliteRowId[]; res.json({ snapshots: rows.map((r) => plugins.getSnapshot(db, r.id)).filter((x): x is AppliedPluginSnapshotLike => x !== null) }); } catch (err) { res.status(500).json({ error: String(err) }); } }); - app.get('/api/projects/:projectId/applied-plugins', (req, res) => { try { const rows = db.prepare(`SELECT id FROM applied_plugin_snapshots WHERE project_id = ? ORDER BY applied_at DESC`).all(req.params.projectId) as SqliteRowId[]; res.json({ snapshots: rows.map((r) => plugins.getSnapshot(db, r.id)).filter((x): x is AppliedPluginSnapshotLike => x !== null) }); } catch (err) { res.status(500).json({ error: String(err) }); } }); + app.get('/api/projects/:projectId/applied-plugins', async (req, res) => { + try { + if (!await deps.authorizeProjectRequest( + req, + res, + req.params.projectId, + { mode: 'read' }, + )) return; + const rows = db.prepare( + `SELECT id FROM applied_plugin_snapshots WHERE project_id = ? ORDER BY applied_at DESC`, + ).all(req.params.projectId) as SqliteRowId[]; + res.json({ + snapshots: rows + .map((row) => plugins.getSnapshot(db, row.id)) + .filter((snapshot): snapshot is AppliedPluginSnapshotLike => snapshot !== null), + }); + } catch (err) { + res.status(500).json({ error: String(err) }); + } + }); app.post('/api/applied-plugins/export', helpers.requireLocalDaemonRequest, async (req, res) => helpers.handleAppliedPluginExport(req, res)); app.post('/api/applied-plugins/prune', async (req, res) => { try { const body = req.body && typeof req.body === 'object' ? req.body : {}; const before = typeof body.before === 'number' ? body.before : undefined; const result = plugins.pruneExpiredSnapshots(db, before ? { before } : {}); if (result.removed > 0) { try { const { recordPluginEvent } = await import('../../plugins/events.js'); recordPluginEvent({ kind: 'plugin.snapshot-pruned', pluginId: '', details: { removed: result.removed, ...(before ? { before } : {}) } }); } catch {} } res.json({ ok: true, removed: result.removed, ids: result.ids }); } catch (err) { res.status(500).json({ error: String(err) }); } }); } export function registerProjectPluginRoutes(app: Express, deps: RegisterPluginRoutesDeps): void { const { db, paths, plugins, helpers } = deps; - app.post('/api/projects/:id/plugins/install-folder', async (req, res) => helpers.handleProjectInstallFolder(req, res)); - app.post('/api/projects/:id/plugins/publish-github', async (req, res) => helpers.handleProjectPluginCli(req, res, 'publish-github')); - app.get('/api/projects/:id/plugin-candidates', (req, res) => { try { const project = helpers.getProject(db, req.params.id); if (!project) return helpers.sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); const includeDismissed = req.query.includeDismissed === 'true'; res.json({ candidates: plugins.listSkillPluginCandidates(db, req.params.id, includeDismissed) }); } catch (err: unknown) { res.status(400).json({ error: err instanceof Error ? err.message : String(err) }); } }); - app.post('/api/projects/:id/plugin-candidates/:candidateId/dismiss', (req, res) => { if (!helpers.isLocalSameOrigin(req, helpers.resolvedPortRef.current)) return res.status(403).json({ error: 'cross-origin request rejected' }); const candidate = plugins.dismissSkillPluginCandidate(db, req.params.id, req.params.candidateId); if (!candidate) return helpers.sendApiError(res, 404, 'NOT_FOUND', 'plugin candidate not found'); if (candidate.assistantMessageId) db.prepare(`DELETE FROM messages WHERE id = ?`).run(candidate.assistantMessageId); res.json({ ok: true, candidate }); }); - app.post('/api/projects/:id/plugin-candidates/:candidateId/draft', async (req, res) => helpers.handleCandidateDraft(req, res)); - app.post('/api/projects/:id/plugin-candidates/:candidateId/share-tasks', async (req, res) => helpers.handleCandidateShareTask(req, res)); - app.post('/api/projects/:id/plugins/contribute-open-design', async (req, res) => helpers.handleProjectPluginCli(req, res, 'contribute-open-design')); - app.post('/api/projects/:id/plugins/share-tasks', async (req, res) => helpers.handleProjectShareTask(req, res)); - app.post('/api/plugins/share-tasks/:id/wait', (req, res) => { + const authorizeWrite = (req: Request, res: Response, projectId: string) => + deps.authorizeProjectRequest( + req, + res, + projectId, + { mode: 'write', capability: 'writeFiles' }, + ); + app.post('/api/projects/:id/plugins/install-folder', async (req, res) => { + if (!await authorizeWrite(req, res, req.params.id)) return; + return helpers.handleProjectInstallFolder(req, res); + }); + app.post('/api/projects/:id/plugins/publish-github', async (req, res) => { + if (!await authorizeWrite(req, res, req.params.id)) return; + return helpers.handleProjectPluginCli(req, res, 'publish-github'); + }); + app.get('/api/projects/:id/plugin-candidates', async (req, res) => { + try { + const project = helpers.getProject(db, req.params.id); + if (!project) { + return helpers.sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + } + if (!await deps.authorizeProjectRequest(req, res, req.params.id, { mode: 'read' })) return; + const includeDismissed = req.query.includeDismissed === 'true'; + res.json({ + candidates: plugins.listSkillPluginCandidates(db, req.params.id, includeDismissed), + }); + } catch (err: unknown) { + res.status(400).json({ error: err instanceof Error ? err.message : String(err) }); + } + }); + app.post('/api/projects/:id/plugin-candidates/:candidateId/dismiss', async (req, res) => { + if (!helpers.isLocalSameOrigin(req, helpers.resolvedPortRef.current)) { + return res.status(403).json({ error: 'cross-origin request rejected' }); + } + if (!await authorizeWrite(req, res, req.params.id)) return; + const candidate = plugins.dismissSkillPluginCandidate( + db, + req.params.id, + req.params.candidateId, + ); + if (!candidate) { + return helpers.sendApiError(res, 404, 'NOT_FOUND', 'plugin candidate not found'); + } + if (candidate.assistantMessageId) { + db.prepare(`DELETE FROM messages WHERE id = ?`).run(candidate.assistantMessageId); + } + res.json({ ok: true, candidate }); + }); + app.post('/api/projects/:id/plugin-candidates/:candidateId/draft', async (req, res) => { + if (!await authorizeWrite(req, res, req.params.id)) return; + return helpers.handleCandidateDraft(req, res); + }); + app.post('/api/projects/:id/plugin-candidates/:candidateId/share-tasks', async (req, res) => { + if (!await authorizeWrite(req, res, req.params.id)) return; + return helpers.handleCandidateShareTask(req, res); + }); + app.post('/api/projects/:id/plugins/contribute-open-design', async (req, res) => { + if (!await authorizeWrite(req, res, req.params.id)) return; + return helpers.handleProjectPluginCli(req, res, 'contribute-open-design'); + }); + app.post('/api/projects/:id/plugins/share-tasks', async (req, res) => { + if (!await authorizeWrite(req, res, req.params.id)) return; + return helpers.handleProjectShareTask(req, res); + }); + app.post('/api/plugins/share-tasks/:id/wait', async (req, res) => { if (!helpers.isLocalSameOrigin(req, helpers.resolvedPortRef.current)) return res.status(403).json({ error: 'cross-origin request rejected' }); const task = helpers.pluginShareTaskStore.get(req.params.id); if (!task) return res.status(404).json({ error: 'task not found' }); + if (!await deps.authorizeProjectRequest(req, res, task.projectId, { mode: 'read' })) return; const since = Number.isFinite(req.body?.since) ? Number(req.body.since) : 0; const requestedTimeout = Number.isFinite(req.body?.timeoutMs) ? Number(req.body.timeoutMs) : 25_000; const timeoutMs = Math.min(Math.max(requestedTimeout, 0), 25_000); diff --git a/apps/daemon/src/routes/project/comments.ts b/apps/daemon/src/routes/project/comments.ts index 53e80bf9329..4d6b202e87b 100644 --- a/apps/daemon/src/routes/project/comments.ts +++ b/apps/daemon/src/routes/project/comments.ts @@ -1,44 +1,410 @@ -import type { Express } from 'express'; +import type { Express, Request } from 'express'; +import type { + PreviewComment, + WorkspaceCollabContext, +} from '@open-design/contracts'; import type { RouteDeps } from '../../server-context.js'; +import type { BoundWorkspaceResourceMutationGate } from '../../collab/workspace-resource-mutation.js'; -export interface RegisterProjectCommentRoutesDeps extends RouteDeps<'db' | 'projectStore' | 'conversations'> {} +export type ProjectCommentWorkspaceContextResolution = + | { ok: true; context: WorkspaceCollabContext | null } + | { + ok: false; + status: 400 | 403 | 503; + code: string; + message: string; + retryable?: true; + }; + +export interface RegisterProjectCommentRoutesDeps extends RouteDeps<'db' | 'projectStore' | 'conversations'> { + /** + * Gate POST (create/edit)/PATCH status/DELETE on the caller's WORKSPACE + * identity, before the author-identity logic below ever runs (spec 04 §10 + * fix #4/#6 — recvqbklNGDqYY: a comment had zero `enforceWorkspace*` + * coverage, the one fully-unguarded write path among the four resource + * types). A comment has no workspace binding of its own, so this borrows + * the PARENT PROJECT's binding via `getWorkspaceProject`/ + * `getWorkspaceProjectByProjectId` (both already available on + * `ctx.projectStore`) — the same instance `routes/project/index.ts` built + * for its own project routes (cross-check against the daemon's own + * last-known membership included), threaded down through + * `registerProjectConversationRoutes` rather than re-derived here. + * + * Optional, and a no-op when omitted, so fixtures that only exercise + * comment CRUD semantics (most of this file's existing tests, which use + * plain non-workspace-bound projects) keep compiling and behaving exactly + * as before — an unbound project's comments were never gated either way, + * since `enforceWorkspaceResourceMutation` itself passes a `row === null` + * lookup straight through regardless of ctx. + */ + enforceWorkspaceProjectMutation?: BoundWorkspaceResourceMutationGate; + /** Paired with `enforceWorkspaceProjectMutation` above — see that field. */ + sendApiError?: (res: any, status: number, code: string, message: string) => unknown; + /** + * Resolve and authorize the PERSISTED project's Workspace scope. Production + * wiring verifies request headers against the membership directory and then + * checks that the resulting workspace id matches the project's binding. + * Directory failure is returned as a typed error and must fail closed before + * any local mutation or relay call. + */ + resolveWorkspaceContext?: ( + req: Request, + projectId: string, + ) => Promise<ProjectCommentWorkspaceContextResolution>; + /** + * Bounded successful authority lease for pure comment-list reads. Production + * uses the same cached verifier as other project GET routes. Comment + * mutations continue to use `resolveWorkspaceContext` above, which is fresh + * and fail-closed on every write. + */ + resolveReadWorkspaceContext?: ( + req: Request, + projectId: string, + ) => Promise<ProjectCommentWorkspaceContextResolution>; + /** + * Resolve the CURRENT caller's workspaceMemberId from the request identity + * (workspace context). Server-authoritative — used both to stamp the author on + * a new/edited comment and to gate status/delete on the caller's identity. + * Optional: off-team it returns undefined and comments are stored without an + * author and no permission gating applies. + */ + resolveAuthorMemberId?: (authorization: string | undefined) => Promise<string | undefined>; + /** + * Resolve a shared project's OWNER workspaceMemberId (server-authoritative, + * from the team hub). Used to let the project owner delete / send-to-agent on + * another member's comment. Null off-team / when the project is not shared. + */ + resolveProjectOwnerMemberId?: ( + projectId: string, + context?: WorkspaceCollabContext | null, + ) => Promise<string | null>; + /** + * Server-authoritative shared-project test. Legacy comments without an + * author remain mutable in personal/unshared projects, but in a shared + * project they are owner-only. Resolution failure must deny rather than + * degrading open. + */ + isSharedProject?: ( + projectId: string, + context?: WorkspaceCollabContext | null, + ) => Promise<boolean>; + /** + * Whether this project should still sync comment mutations to the team relay. + * Local comments are allowed to save regardless; this gate only prevents stale + * pulled copies from continuing to publish into a project after it leaves the + * team catalog. + */ + shouldSyncProjectComments?: ( + authorization: string | undefined, + projectId: string, + context?: WorkspaceCollabContext | null, + ) => Promise<boolean>; + /** + * Fired after a comment is created OR edited (body upsert), so the collab-cloud + * service can push it to the cross-daemon relay (best-effort — a push failure + * must not fail the local save). No-op off-team / when the collab cloud is + * unconfigured. + */ + onCommentCreated?: ( + comment: PreviewComment, + context: WorkspaceCollabContext | null, + ) => void; + /** + * Fired after a comment's status changes (the send-to-agent lifecycle), so the + * new status propagates to other members. Best-effort. + */ + onCommentUpdated?: ( + comment: PreviewComment, + context: WorkspaceCollabContext | null, + ) => void; + /** + * Fired after a comment is deleted, with the comment as it last existed, so a + * tombstone can be pushed to the relay. Best-effort. + */ + onCommentDeleted?: ( + comment: PreviewComment, + context: WorkspaceCollabContext | null, + ) => void; + /** + * Fired when the comment list is read. The hub push channel marks closed + * projects comment-dirty instead of pulling eagerly; the first read after + * opening consumes that mark and triggers an immediate cloud pull, so an + * opened project catches up NOW instead of on the next poll tick. + */ + onCommentsRead?: ( + projectId: string, + context: WorkspaceCollabContext | null, + resolveFreshWorkspaceContext: () => Promise<ProjectCommentWorkspaceContextResolution>, + ) => void; +} export function registerProjectCommentRoutes(app: Express, ctx: RegisterProjectCommentRoutesDeps): void { const { db } = ctx; - const { updateProject } = ctx.projectStore; + const { updateProject, getWorkspaceProject, getWorkspaceProjectByProjectId } = ctx.projectStore; const { getConversation, listPreviewComments, upsertPreviewComment, + getPreviewComment, updatePreviewCommentStatus, + updatePreviewCommentAnchor, deletePreviewComment, + reorderPreviewComment, } = ctx.conversations; + /** + * Workspace-identity gate for a comment mutation, borrowing the PARENT + * PROJECT's binding (see `enforceWorkspaceProjectMutation` on + * `RegisterProjectCommentRoutesDeps` above). Writes the 401/403 response + * itself and returns false when denied — callers return immediately on + * `false` without running their own author-identity logic. A no-op (always + * allows) when the gate wasn't wired up, matching how an unbound project's + * comments behaved before this fix existed either way. + */ + async function enforceCommentWorkspaceMutation( + req: Request, + res: any, + projectId: string, + ): Promise<boolean> { + if (!ctx.enforceWorkspaceProjectMutation || !ctx.sendApiError) return true; + return ctx.enforceWorkspaceProjectMutation( + req, + res, + ctx.sendApiError, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + db, + projectId, + // NOT `writeFiles`: a comment is not an artifact edit. Sharing a + // project into the team grants every active member comment standing + // (the read-only banner promises "view and comment"), so this gate + // checks the wider `comment` capability; author-level rules + // (`callerMayMutate` below) still restrict status/delete per comment. + 'comment', + ); + } + + async function resolveRequestWorkspaceContext( + req: Request, + projectId: string, + ): Promise<ProjectCommentWorkspaceContextResolution> { + if (!ctx.resolveWorkspaceContext) return { ok: true, context: null }; + try { + return await ctx.resolveWorkspaceContext(req, projectId); + } catch { + return { + ok: false, + status: 503, + code: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + message: 'workspace membership authority is temporarily unavailable', + retryable: true, + }; + } + } + + async function resolveReadRequestWorkspaceContext( + req: Request, + projectId: string, + ): Promise<ProjectCommentWorkspaceContextResolution> { + if (!ctx.resolveReadWorkspaceContext) { + return resolveRequestWorkspaceContext(req, projectId); + } + try { + return await ctx.resolveReadWorkspaceContext(req, projectId); + } catch { + return { + ok: false, + status: 503, + code: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + message: 'workspace membership authority is temporarily unavailable', + retryable: true, + }; + } + } + + function sendWorkspaceResolutionError( + res: any, + resolution: Extract<ProjectCommentWorkspaceContextResolution, { ok: false }>, + ): unknown { + if (ctx.sendApiError) { + return ctx.sendApiError( + res, + resolution.status, + resolution.code, + resolution.message, + ); + } + return res.status(resolution.status).json({ + error: resolution.code, + message: resolution.message, + ...(resolution.retryable ? { retryable: true } : {}), + }); + } + + /** The caller's workspaceMemberId, or undefined off-team / personal mode. */ + async function resolveCaller( + req: Request, + context: WorkspaceCollabContext | null, + ): Promise<string | undefined> { + if (ctx.resolveWorkspaceContext) { + return context?.workspaceMemberId || undefined; + } + if (!ctx.resolveAuthorMemberId) return undefined; + return ctx.resolveAuthorMemberId(req.headers.authorization); + } + + async function shouldSyncComments( + req: Request, + projectId: string, + context: WorkspaceCollabContext | null, + ): Promise<boolean> { + if (!ctx.shouldSyncProjectComments) return true; + try { + return await ctx.shouldSyncProjectComments( + req.headers.authorization, + projectId, + context, + ); + } catch { + return false; + } + } + + /** + * Server-authoritative permission gate for status change + delete. Both are + * allowed for the comment's author and the project owner (owner drives + * send-to-agent and may delete any comment). Degrades open only when the + * comment itself has no author (legacy/personal comments). Authored shared + * comments fail closed if the current caller cannot be resolved. + */ + async function callerMayMutate( + req: Request, + projectId: string, + comment: PreviewComment, + context: WorkspaceCollabContext | null, + ): Promise<boolean> { + const author = comment.authorMemberId; + if (!author) { + if (!ctx.isSharedProject) return true; + let shared: boolean; + try { + shared = await ctx.isSharedProject(projectId, context); + } catch { + return false; + } + if (!shared) return true; + } + let me: string | undefined; + try { + me = await resolveCaller(req, context); + } catch { + return false; + } + if (!me) return false; + if (author && me === author) return true; + if (ctx.resolveProjectOwnerMemberId) { + try { + const owner = await ctx.resolveProjectOwnerMemberId(projectId, context); + if (owner && owner === me) return true; + } catch { + return false; + } + } + return false; + } + // ---- Preview comments ---------------------------------------------------- - app.get('/api/projects/:id/conversations/:cid/comments', (req, res) => { + app.get('/api/projects/:id/conversations/:cid/comments', async (req, res) => { const conv = getConversation(db, req.params.cid); if (!conv || conv.projectId !== req.params.id) { return res.status(404).json({ error: 'conversation not found' }); } + const workspaceResolution = await resolveReadRequestWorkspaceContext( + req, + req.params.id, + ); + if (!workspaceResolution.ok) { + return sendWorkspaceResolutionError(res, workspaceResolution); + } + ctx.onCommentsRead?.( + req.params.id, + workspaceResolution.context, + () => resolveRequestWorkspaceContext(req, req.params.id), + ); res.json({ comments: listPreviewComments(db, req.params.id, req.params.cid), }); }); - app.post('/api/projects/:id/conversations/:cid/comments', (req, res) => { + app.post('/api/projects/:id/conversations/:cid/comments', async (req, res) => { const conv = getConversation(db, req.params.cid); if (!conv || conv.projectId !== req.params.id) { return res.status(404).json({ error: 'conversation not found' }); } + if (!await enforceCommentWorkspaceMutation(req, res, req.params.id)) return; + const workspaceResolution = await resolveRequestWorkspaceContext( + req, + req.params.id, + ); + if (!workspaceResolution.ok) { + return sendWorkspaceResolutionError(res, workspaceResolution); + } + const workspaceContext = workspaceResolution.context; try { - const comment = upsertPreviewComment( - db, + // Server-authoritative author: stamp the current member id so the stored + // (and pushed) comment carries who wrote it, rather than trusting the body. + // New comments do not use a natural element key; editing requires an id + // and is author-only. + const body = { ...(req.body || {}) }; + const authorMemberId = await resolveCaller(req, workspaceContext); + const requestedId = typeof body.id === 'string' && body.id.trim() ? body.id.trim() : ''; + if (requestedId) { + const existing = getPreviewComment( + db, + req.params.id, + req.params.cid, + requestedId, + ) as PreviewComment | null; + if (!existing) { + return res.status(404).json({ error: 'comment not found' }); + } + const existingAuthor = existing.authorMemberId ?? null; + if (existingAuthor) { + if (!authorMemberId || existingAuthor !== authorMemberId) { + return res.status(403).json({ error: 'not permitted' }); + } + body.authorMemberId = existingAuthor; + } else if (authorMemberId) { + body.authorMemberId = authorMemberId; + } + } else if (authorMemberId) { + body.authorMemberId = authorMemberId; + } + // Resolved BEFORE the upsert (not just before the push below) so a + // genuinely new comment's pin_seq starts unconfirmed on a team-shared + // project — see UpsertPreviewCommentOptions in db.ts. Ignored on the + // edit branch, so computing it here for an edit-via-POST is harmless. + const syncEnabled = await shouldSyncComments( + req, req.params.id, - req.params.cid, - req.body || {}, + workspaceContext, ); + const comment = upsertPreviewComment(db, req.params.id, req.params.cid, body, { + pinPendingCloudConfirm: syncEnabled, + }); updateProject(db, req.params.id, {}); + // Best-effort cross-daemon push; never fails the local save. + if (comment && syncEnabled) { + try { + ctx.onCommentCreated?.( + comment as unknown as PreviewComment, + workspaceContext, + ); + } catch { + /* push is best-effort */ + } + } res.json({ comment }); } catch (err: any) { res.status(400).json({ error: String(err?.message || err) }); @@ -47,12 +413,38 @@ export function registerProjectCommentRoutes(app: Express, ctx: RegisterProjectC app.patch( '/api/projects/:id/conversations/:cid/comments/:commentId', - (req, res) => { + async (req, res) => { const conv = getConversation(db, req.params.cid); if (!conv || conv.projectId !== req.params.id) { return res.status(404).json({ error: 'conversation not found' }); } + if (!await enforceCommentWorkspaceMutation(req, res, req.params.id)) return; + const workspaceResolution = await resolveRequestWorkspaceContext( + req, + req.params.id, + ); + if (!workspaceResolution.ok) { + return sendWorkspaceResolutionError(res, workspaceResolution); + } + const workspaceContext = workspaceResolution.context; try { + const existing = getPreviewComment( + db, + req.params.id, + req.params.cid, + req.params.commentId, + ) as PreviewComment | null; + if (!existing) return res.status(404).json({ error: 'comment not found' }); + // Status change is the send-to-agent lifecycle: allowed for the author + // and the project owner, blocked for other members. + if (!(await callerMayMutate( + req, + req.params.id, + existing, + workspaceContext, + ))) { + return res.status(403).json({ error: 'not permitted' }); + } const comment = updatePreviewCommentStatus( db, req.params.id, @@ -63,6 +455,90 @@ export function registerProjectCommentRoutes(app: Express, ctx: RegisterProjectC if (!comment) return res.status(404).json({ error: 'comment not found' }); updateProject(db, req.params.id, {}); + if (await shouldSyncComments(req, req.params.id, workspaceContext)) { + try { + ctx.onCommentUpdated?.( + comment as unknown as PreviewComment, + workspaceContext, + ); + } catch { + /* push is best-effort */ + } + } + res.json({ comment }); + } catch (err: any) { + res.status(400).json({ error: String(err?.message || err) }); + } + }, + ); + + app.patch( + '/api/projects/:id/conversations/:cid/comments/:commentId/anchor', + async (req, res) => { + const conv = getConversation(db, req.params.cid); + if (!conv || conv.projectId !== req.params.id) { + return res.status(404).json({ error: 'conversation not found' }); + } + const workspaceResolution = await resolveRequestWorkspaceContext( + req, + req.params.id, + ); + if (!workspaceResolution.ok) { + return sendWorkspaceResolutionError(res, workspaceResolution); + } + try { + // Drift-ladder write-back: the client resolves anchor state each render + // and reports it here. This is a per-daemon DERIVED read-back (each + // daemon anchors against its own content), not a user edit or a synced + // field — so it is neither permission-gated nor pushed to the relay, and + // it does not bump updated_at. + const comment = updatePreviewCommentAnchor( + db, + req.params.id, + req.params.cid, + req.params.commentId, + req.body || {}, + ); + if (!comment) return res.status(404).json({ error: 'comment not found' }); + res.json({ comment }); + } catch (err: any) { + res.status(400).json({ error: String(err?.message || err) }); + } + }, + ); + + app.patch( + '/api/projects/:id/conversations/:cid/comments/:commentId/reorder', + async (req, res) => { + const conv = getConversation(db, req.params.cid); + if (!conv || conv.projectId !== req.params.id) { + return res.status(404).json({ error: 'conversation not found' }); + } + const workspaceResolution = await resolveRequestWorkspaceContext( + req, + req.params.id, + ); + if (!workspaceResolution.ok) { + return sendWorkspaceResolutionError(res, workspaceResolution); + } + const sortKey = Number(req.body?.sortKey); + if (!Number.isFinite(sortKey)) { + return res.status(400).json({ error: 'sortKey must be a finite number' }); + } + try { + // Sidebar display order is a per-daemon viewing preference, not a + // content edit: unlike status change/delete, it is not gated on + // authorship (any member may reorder their OWN view of a shared + // project's comments), does not bump updated_at, and is never pushed + // to the collab-cloud relay — see PreviewComment.sortKey. + const comment = reorderPreviewComment( + db, + req.params.id, + req.params.cid, + req.params.commentId, + sortKey, + ); + if (!comment) return res.status(404).json({ error: 'comment not found' }); res.json({ comment }); } catch (err: any) { res.status(400).json({ error: String(err?.message || err) }); @@ -72,11 +548,37 @@ export function registerProjectCommentRoutes(app: Express, ctx: RegisterProjectC app.delete( '/api/projects/:id/conversations/:cid/comments/:commentId', - (req, res) => { + async (req, res) => { const conv = getConversation(db, req.params.cid); if (!conv || conv.projectId !== req.params.id) { return res.status(404).json({ error: 'conversation not found' }); } + if (!await enforceCommentWorkspaceMutation(req, res, req.params.id)) return; + const workspaceResolution = await resolveRequestWorkspaceContext( + req, + req.params.id, + ); + if (!workspaceResolution.ok) { + return sendWorkspaceResolutionError(res, workspaceResolution); + } + const workspaceContext = workspaceResolution.context; + // Load before deleting so we can gate on the author and build the tombstone. + const existing = getPreviewComment( + db, + req.params.id, + req.params.cid, + req.params.commentId, + ) as PreviewComment | null; + if (!existing) return res.status(404).json({ error: 'comment not found' }); + // Delete is allowed for the comment's author and the project owner. + if (!(await callerMayMutate( + req, + req.params.id, + existing, + workspaceContext, + ))) { + return res.status(403).json({ error: 'not permitted' }); + } const ok = deletePreviewComment( db, req.params.id, @@ -85,6 +587,13 @@ export function registerProjectCommentRoutes(app: Express, ctx: RegisterProjectC ); if (!ok) return res.status(404).json({ error: 'comment not found' }); updateProject(db, req.params.id, {}); + if (await shouldSyncComments(req, req.params.id, workspaceContext)) { + try { + ctx.onCommentDeleted?.(existing, workspaceContext); + } catch { + /* push is best-effort */ + } + } res.json({ ok: true }); }, ); diff --git a/apps/daemon/src/routes/project/conversations.ts b/apps/daemon/src/routes/project/conversations.ts index 6954776c6d8..31e03629a89 100644 --- a/apps/daemon/src/routes/project/conversations.ts +++ b/apps/daemon/src/routes/project/conversations.ts @@ -3,10 +3,31 @@ import { type ChatSessionMode } from '@open-design/contracts'; import { readAnalyticsContext } from '../../analytics.js'; import { backfillBrandExtractionTranscriptForProject } from '../../brands/index.js'; import type { RouteDeps } from '../../server-context.js'; +import type { BoundWorkspaceResourceMutationGate } from '../../collab/workspace-resource-mutation.js'; +import type { AuthorizeProjectRequest } from '../../collab/project-request-authority.js'; import { registerProjectCommentRoutes } from './comments.js'; import { cancelRunsOwnedBy } from './cancel-owned-runs.js'; -export interface RegisterProjectConversationRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'conversations' | 'ids' | 'telemetry' | 'appConfig' | 'agents'> {} +export interface RegisterProjectConversationRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'conversations' | 'ids' | 'telemetry' | 'appConfig' | 'agents'> { + /** + * Threaded straight through to `registerProjectCommentRoutes` — a comment + * has no workspace binding of its own, so it borrows its PARENT PROJECT's + * `enforceWorkspaceProjectMutation` gate (built once in + * `registerProjectRoutes`, complete with the last-known-membership + * cross-check) rather than re-deriving a weaker one here. See + * `RegisterProjectCommentRoutesDeps` in `./comments.js`. + */ + enforceWorkspaceProjectMutation?: BoundWorkspaceResourceMutationGate; + authorizeProjectRequest?: AuthorizeProjectRequest; + /** + * Passed alongside `enforceWorkspaceProjectMutation` above — the gate calls + * this to write the 401/403 response body when it denies a mutation. Kept + * as its own field (rather than requiring the full `http` dep bag) so + * fixtures that only exercise comment CRUD semantics, not workspace + * isolation, are not forced to stub unrelated HTTP helpers. + */ + sendApiError?: (res: any, status: number, code: string, message: string) => unknown; +} function normalizeChatSessionMode(value: unknown): ChatSessionMode { return value === 'chat' || value === 'plan' ? value : 'design'; @@ -33,20 +54,32 @@ export function registerProjectConversationRoutes(app: Express, ctx: RegisterPro const { BRANDS_DIR, PROJECTS_DIR } = ctx.paths; const { readAppConfig } = ctx.appConfig; const { getAgentDef } = ctx.agents; + // Production registration always injects the shared project authority gate. + // The fallback preserves narrow unit fixtures whose in-memory projects have + // no Workspace binding and do not construct the full server authority graph. + const authorizeProjectRequest: AuthorizeProjectRequest = + ctx.authorizeProjectRequest ?? (async () => true); // ---- Conversations -------------------------------------------------------- - app.get('/api/projects/:id/conversations', (req, res) => { + app.get('/api/projects/:id/conversations', async (req, res) => { if (!getProject(db, req.params.id)) { return res.status(404).json({ error: 'project not found' }); } + if (!await authorizeProjectRequest(req, res, req.params.id, { mode: 'read' })) return; res.json({ conversations: listConversations(db, req.params.id) }); }); - app.post('/api/projects/:id/conversations', (req, res) => { + app.post('/api/projects/:id/conversations', async (req, res) => { if (!getProject(db, req.params.id)) { return res.status(404).json({ error: 'project not found' }); } + if (!await authorizeProjectRequest( + req, + res, + req.params.id, + { mode: 'write', capability: 'writeFiles' }, + )) return; const { title, seedFromConversationId, forkAfterMessageId } = req.body || {}; const now = Date.now(); const hasExplicitSessionMode = Boolean( @@ -133,7 +166,13 @@ export function registerProjectConversationRoutes(app: Express, ctx: RegisterPro res.json({ conversation: conv }); }); - app.patch('/api/projects/:id/conversations/:cid', (req, res) => { + app.patch('/api/projects/:id/conversations/:cid', async (req, res) => { + if (!await authorizeProjectRequest( + req, + res, + req.params.id, + { mode: 'write', capability: 'writeFiles' }, + )) return; const conv = getConversation(db, req.params.cid); if (!conv || conv.projectId !== req.params.id) { return res.status(404).json({ error: 'not found' }); @@ -150,6 +189,12 @@ export function registerProjectConversationRoutes(app: Express, ctx: RegisterPro }); app.delete('/api/projects/:id/conversations/:cid', async (req, res) => { + if (!await authorizeProjectRequest( + req, + res, + req.params.id, + { mode: 'write', capability: 'writeFiles' }, + )) return; const conv = getConversation(db, req.params.cid); if (!conv || conv.projectId !== req.params.id) { return res.status(404).json({ error: 'not found' }); @@ -164,6 +209,7 @@ export function registerProjectConversationRoutes(app: Express, ctx: RegisterPro // ---- Messages ------------------------------------------------------------- app.get('/api/projects/:id/conversations/:cid/messages', async (req, res) => { + if (!await authorizeProjectRequest(req, res, req.params.id, { mode: 'read' })) return; const conv = getConversation(db, req.params.cid); if (!conv || conv.projectId !== req.params.id) { return res.status(404).json({ error: 'conversation not found' }); @@ -192,7 +238,13 @@ export function registerProjectConversationRoutes(app: Express, ctx: RegisterPro res.json({ messages: listMessages(db, req.params.cid) }); }); - app.put('/api/projects/:id/conversations/:cid/messages/:mid', (req, res) => { + app.put('/api/projects/:id/conversations/:cid/messages/:mid', async (req, res) => { + if (!await authorizeProjectRequest( + req, + res, + req.params.id, + { mode: 'write', capability: 'writeFiles' }, + )) return; const conv = getConversation(db, req.params.cid); if (!conv || conv.projectId !== req.params.id) { return res.status(404).json({ error: 'conversation not found' }); diff --git a/apps/daemon/src/routes/project/index.ts b/apps/daemon/src/routes/project/index.ts index f4182795b85..381be3768cc 100644 --- a/apps/daemon/src/routes/project/index.ts +++ b/apps/daemon/src/routes/project/index.ts @@ -1,17 +1,22 @@ import { createHash } from 'node:crypto'; import { rm } from 'node:fs/promises'; import path from 'node:path'; -import type { Express, Response } from 'express'; +import type { Express, Request, Response } from 'express'; import { defaultScenarioPluginIdForProjectMetadata, type ChatSessionMode, type PluginManifest, + type PreviewComment, + type ProjectDesignTokenSuggestionProp, + type ProjectDesignTokenSuggestionQuery, type ProjectFile, type ProjectFileTextPreviewResponse, type ProjectFileVersion, type ProjectFileVersionPromptSource, type ProjectFileVersionSource, type ProjectFileVersionWarning, + type ProjectSyncState, + type WorkspaceCollabContext, } from '@open-design/contracts'; import { readMeta as readBrandMeta } from '../../brands/store.js'; import { createProjectArtifactFile } from '../../artifacts/create.js'; @@ -32,7 +37,10 @@ import { linkUserDesignSystemProject, listDesignSystems, propagateWorkspaceProjectRename, + type DesignSystemSummary, + type UserDesignSystemInput, } from '../../design-systems/index.js'; +import { buildProjectDesignTokenSuggestions } from '../../project-design-token-suggestions.js'; import { FIRST_PARTY_ATOMS, buildConnectorProbe, @@ -44,6 +52,7 @@ import { connectorService } from '../../connectors/service.js'; import type { RouteDeps } from '../../server-context.js'; import { listSkills } from '../../skills.js'; import { isSafeId } from '../../projects.js'; +import { SYNC_KEEPS_UPDATED_AT } from '../../db.js'; import { BUILT_IN_PROJECT_LOCATION_ID, allProjectLocations, @@ -55,9 +64,283 @@ import { import { auditDesignSystemPackage } from '../../tools-connectors-cli.js'; import { parseOrchestratorWorkspace } from '../../workspace-contract.js'; import { registerProjectConversationRoutes } from './conversations.js'; +import type { ProjectCommentWorkspaceContextResolution } from './comments.js'; +import { + projectResourceIdFor, + velaProjectSyncStateToProject, + type VelaTeamProjectCatalogClient, + type VelaTeamProjectRecord, +} from '../../integrations/vela-team-projects.js'; +import type { ResourceHubPrincipal } from '../../collab/resource-principal.js'; +import { + refuseTeamShareScope, + type TeamShareScopeRefusal, + type WorkspaceTypeRegistry, +} from '../../collab/team-share-scope.js'; +import { + enforceVerifiedWorkspaceResourceMutation, + headerValue, + isWorkspaceResourceLocked as isWorkspaceLocked, + requestCanMutateVerifiedWorkspaceResource, + workspaceResourceAccess, + workspaceResourceContext as workspaceProjectContext, + workspaceResourceContextFromRequest as workspaceProjectContextFromRequest, + workspaceResourceContextFromVerified, + type VerifyWorkspaceRequestAuthority, + type WorkspaceResourceAccessInput, + type WorkspaceResourceContext, + type WorkspaceResourceMutationCapability, +} from '../../collab/workspace-resource-mutation.js'; +import { + resolveProjectWorkspaceScope, + resolveProjectWorkspaceScopeBootstrap, +} from '../../collab/project-workspace-scope.js'; +import { + createAuthorizeProjectRequest, + type AuthorizeProjectRequest, +} from '../../collab/project-request-authority.js'; +import { + authorizeCreatedProjectWorkspace, + bindCreatedProjectToWorkspace, + createCreatedProjectWorkspaceResolver, + CreatedProjectWorkspaceResolutionError, + sendCreatedProjectWorkspaceError, +} from '../../collab/created-project-workspace.js'; +import type { WorkspaceDirectoryFetchResult } from '../../collab/vela-workspace-context.js'; import { cancelRunsOwnedBy } from './cancel-owned-runs.js'; -export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'appConfig' | 'agents' | 'validation'> {} +export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'appConfig' | 'agents' | 'validation' | 'collabSync'> { + teamProjectCatalog?: VelaTeamProjectCatalogClient; + /** Authoritative verifier for every Workspace-bound project mutation. */ + verifyWorkspaceRequestAuthority?: VerifyWorkspaceRequestAuthority; + /** Shared fresh exact authority gate for all project data-plane routes. */ + authorizeProjectRequest?: AuthorizeProjectRequest; + /** Startup-hydrated O(1) quarantine lookup for stale Team mirrors. */ + isProjectRevoked?: (projectId: string) => boolean; + /** + * Authoritative signed-in membership directory. Project detail uses it to + * resolve the project's persisted workspace independently from any + * daemon-global active/current state. + */ + fetchWorkspaceDirectory?: () => Promise<WorkspaceDirectoryFetchResult>; + /** + * Production-only authority for project creation. Kept distinct from the + * read-side directory fetcher so local/dev and explicitly anonymous callers + * retain their existing behavior. + */ + fetchProjectCreationWorkspaceDirectory?: () => Promise<WorkspaceDirectoryFetchResult>; + /** + * Persist a design system and its Workspace ownership envelope from the + * exact directory-verified creation context. Production injects the shared + * design-system creation service; the optional shape preserves isolated + * route harnesses and headerless/local compatibility. + */ + createWorkspaceOwnedDesignSystem?: ( + root: string, + input: UserDesignSystemInput, + context: WorkspaceResourceContext | null, + ) => Promise<DesignSystemSummary>; + /** + * Collab-cloud comment seams, threaded to the nested preview-comment routes. + * `resolveAuthorMemberId` stamps the server-authoritative author AND identifies + * the caller for permission gating; `resolveProjectOwnerMemberId` resolves the + * shared project's owner so the owner may delete / drive status on any comment. + * `onCommentCreated`/`onCommentUpdated`/`onCommentDeleted` push the comment's + * lifecycle (create/edit, status change, tombstone) to the cross-daemon relay. + * All optional and no-op off-team / when the collab cloud is unconfigured. + */ + resolveAuthorMemberId?: (authorization: string | undefined) => Promise<string | undefined>; + resolveWorkspaceContext?: ( + req: Request, + projectId: string, + ) => Promise<ProjectCommentWorkspaceContextResolution>; + resolveReadWorkspaceContext?: ( + req: Request, + projectId: string, + ) => Promise<ProjectCommentWorkspaceContextResolution>; + resolveProjectOwnerMemberId?: ( + projectId: string, + context?: WorkspaceCollabContext | null, + ) => Promise<string | null>; + isSharedProject?: ( + projectId: string, + context?: WorkspaceCollabContext | null, + ) => Promise<boolean>; + shouldSyncProjectComments?: ( + authorization: string | undefined, + projectId: string, + context?: WorkspaceCollabContext | null, + ) => Promise<boolean>; + onCommentCreated?: ( + comment: PreviewComment, + context: WorkspaceCollabContext | null, + ) => void; + onCommentUpdated?: ( + comment: PreviewComment, + context: WorkspaceCollabContext | null, + ) => void; + onCommentDeleted?: ( + comment: PreviewComment, + context: WorkspaceCollabContext | null, + ) => void; + onCommentsRead?: ( + projectId: string, + context: WorkspaceCollabContext | null, + resolveFreshWorkspaceContext: () => Promise<ProjectCommentWorkspaceContextResolution>, + ) => void; + /** + * What the daemon has learned about each workspace's type, used to refuse a + * team share aimed at a personal workspace even when the caller's headers say + * otherwise. See `collab/team-share-scope.ts`. + */ + workspaceTypes?: Pick<WorkspaceTypeRegistry, 'isKnownPersonal'>; +} + +// `WorkspaceProjectContext`/`WorkspaceProjectMutationCapability`/ +// `WorkspaceProjectAccessInput` and the header-reading helpers used to be +// defined here, hard-coded to "project". They now live in +// `collab/workspace-resource-mutation.ts` as the resource-agnostic +// `WorkspaceResource*` shapes (imported above and aliased back to these +// project-flavored names) so plugin/skill/design-system callers share the +// exact same header-parsing and mutation-gate logic instead of forking it. +type WorkspaceProjectContext = WorkspaceResourceContext; +type WorkspaceProjectMutationCapability = WorkspaceResourceMutationCapability; +type WorkspaceProjectAccessInput = WorkspaceResourceAccessInput; + +/** + * Can a team share be RECORDED in the workspace this request is acting in? + * + * A team share must live in a team workspace — see `collab/team-share-scope.ts` + * for why a `visibility: 'team'` row pinned to a personal workspace is a + * permanently-broken address rather than a scope. Two independent witnesses can + * refuse it, and either alone is enough: the caller's own `x-od-workspace-type` + * claim (a client that says "personal" and asks for a team share has stated the + * contradiction itself), and the workspace directory the daemon has already read + * (which catches a caller whose headers are simply wrong). With neither, the + * request is allowed — this guard fires on positive evidence only, so it can + * never block a legitimate share in a workspace it has not learned about. + */ +function teamShareRefusalFor( + ctx: WorkspaceProjectContext, + workspaceTypes?: Pick<WorkspaceTypeRegistry, 'isKnownPersonal'> | null, +): TeamShareScopeRefusal | null { + return refuseTeamShareScope(ctx.workspaceId, { + assertedType: ctx.workspaceTypeAsserted, + ...(workspaceTypes ? { registry: workspaceTypes } : {}), + }); +} + +function projectAccess( + wp: WorkspaceProjectAccessInput, + ctx: WorkspaceProjectContext, + workspaceTypes?: Pick<WorkspaceTypeRegistry, 'isKnownPersonal'> | null, +) { + // frozen/selfCreated/privileged/canMutate/canShareLocal/disabledReason are + // the resource-agnostic part, computed once in + // collab/workspace-resource-mutation.ts so a fix there lands for plugin and + // skill too. Only the fields below (canMoveToTeam/canMoveToPersonal/ + // canOpen/canExport/canSendTo) are project-specific UX affordances. + const { + frozen, + selfCreated, + canMutate: privilegedOrCreatorCanMutate, + canShareLocal, + disabledReason: baseDisabledReason, + } = workspaceResourceAccess(wp, ctx); + // Team-shared projects are single-writer resources: Workspace governance + // may manage the Team, but only the member recorded as this project's + // creator may mutate or unshare it. Keep the read model aligned with the + // authoritative route gate; otherwise owner/admin callers are advertised + // actions that direct project routes reject, while the workspace move route + // (which consumes these flags) can still unshare someone else's project. + // Personal/unshared projects retain the existing privileged-or-creator rule. + const canMutate = + privilegedOrCreatorCanMutate + && (wp.visibility !== 'team' || selfCreated); + const disabledReason = + baseDisabledReason + ?? (!canMutate ? 'permission_denied' : undefined); + return { + canOpen: !frozen && ctx.memberStatus === 'active', + canRename: canMutate, + canDelete: canMutate, + canDuplicate: canMutate, + // Never offer a share the workspace cannot host: the affordance is the + // entry point that produced the impossible rows in the first place. + canMoveToTeam: + canShareLocal && + ctx.canShareProjects && + wp.visibility === 'personal' && + teamShareRefusalFor(ctx, workspaceTypes) === null, + canMoveToPersonal: canMutate && ctx.canShareProjects && wp.visibility === 'team', + canExport: !frozen && ctx.memberStatus === 'active', + canSendTo: !frozen && ctx.memberStatus === 'active', + canRestoreVersion: canMutate, + ...(disabledReason ? { disabledReason } : {}), + }; +} + +/** + * Build the project-flavored authoritative mutation gate. Every bound project + * requires an exact Workspace/member pair and a fresh directory-backed + * verifier result; request role/permission claims and daemon-global + * active/current/last-known state are never authority. The exported factory is + * shared with run/chat routes so all project mutations fail closed identically. + */ +/** + * The non-rejecting counterpart of `createEnforceWorkspaceProjectMutation`, + * for a read route that would otherwise write as a side effect. See + * `requestCanMutateVerifiedWorkspaceResource` for why a READ must answer this question + * without ever answering it with a 401/403. + */ +export function createWorkspaceProjectWriteAuthorityCheck( + verifyWorkspaceRequestAuthority?: VerifyWorkspaceRequestAuthority, +) { + return async function requestCanWriteWorkspaceProject( + req: any, + getWorkspaceProject: (db: unknown, workspaceId: string, projectId: string) => WorkspaceProjectAccessInput | null | undefined, + getWorkspaceProjectByProjectId: (db: unknown, projectId: string) => WorkspaceProjectAccessInput | null | undefined, + db: unknown, + projectId: string, + ): Promise<boolean> { + return requestCanMutateVerifiedWorkspaceResource( + req, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + db, + projectId, + verifyWorkspaceRequestAuthority, + ); + }; +} + +export function createEnforceWorkspaceProjectMutation( + verifyWorkspaceRequestAuthority?: VerifyWorkspaceRequestAuthority, +) { + return async function enforceWorkspaceProjectMutation( + req: any, + res: Response, + sendApiError: (res: Response, status: number, code: string, message: string) => unknown, + getWorkspaceProject: (db: unknown, workspaceId: string, projectId: string) => WorkspaceProjectAccessInput | null | undefined, + getWorkspaceProjectByProjectId: (db: unknown, projectId: string) => WorkspaceProjectAccessInput | null | undefined, + db: unknown, + projectId: string, + capability: WorkspaceProjectMutationCapability, + ): Promise<boolean> { + return enforceVerifiedWorkspaceResourceMutation( + 'project', + req, + res, + sendApiError, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + db, + projectId, + capability, + verifyWorkspaceRequestAuthority, + ); + }; +} function projectDetailResolvedDir( projectsRoot: string, @@ -111,6 +394,11 @@ const URL_PREVIEW_SCROLL_BRIDGE = `<script data-od-url-scroll-bridge> window.__odUrlScrollBridge = true; var pending = false; var contentSizePending = false; + var lastContentSizeRequest = null; + var contentSizeDocumentEpoch = ''; + try { + contentSizeDocumentEpoch = new URLSearchParams(window.location.search).get('odPreviewEpoch') || ''; + } catch (_) {} function scrollElement(){ return document.querySelector('.design-canvas') || document.scrollingElement || document.documentElement; } @@ -118,27 +406,44 @@ const URL_PREVIEW_SCROLL_BRIDGE = `<script data-od-url-scroll-bridge> var next = Number(value || 0); return Number.isFinite(next) ? next : 0; } - function measureContentWidth(){ + function measureContentSize(){ var root = document.documentElement; var body = document.body || root; if (!root) return null; - var values = [ + var scrollValues = [ root.scrollWidth, - body && body.scrollWidth, - root.offsetWidth, - body && body.offsetWidth, + body && body.scrollWidth + ]; + var clientValues = [ root.clientWidth, body && body.clientWidth ]; - var width = 0; - for (var i = 0; i < values.length; i += 1) { - var next = num(values[i]); - if (next > width) width = next; + var scrollWidth = 0; + var clientWidth = 0; + for (var i = 0; i < scrollValues.length; i += 1) { + var nextScroll = num(scrollValues[i]); + if (nextScroll > scrollWidth) scrollWidth = nextScroll; } - return width > 0 ? Math.ceil(width) : null; + for (var j = 0; j < clientValues.length; j += 1) { + var nextClient = num(clientValues[j]); + if (nextClient > clientWidth) clientWidth = nextClient; + } + return { + scrollWidth: scrollWidth > 0 ? Math.ceil(scrollWidth) : null, + clientWidth: clientWidth > 0 ? Math.ceil(clientWidth) : null + }; } function postContentSize(){ - window.parent.postMessage({ type: 'od:preview-content-size', width: measureContentWidth() }, '*'); + if (!lastContentSizeRequest) return; + var size = measureContentSize(); + window.parent.postMessage({ + type: 'od:preview-content-size', + measurementId: lastContentSizeRequest.measurementId, + generation: lastContentSizeRequest.generation, + documentEpoch: contentSizeDocumentEpoch, + scrollWidth: size && size.scrollWidth, + clientWidth: size && size.clientWidth + }, '*'); } function scheduleContentSize(){ if (contentSizePending) return; @@ -206,6 +511,11 @@ const URL_PREVIEW_SCROLL_BRIDGE = `<script data-od-url-scroll-bridge> return; } if (data.type === 'od:preview-content-size-request') { + if (typeof data.measurementId !== 'string' || typeof data.generation !== 'string') return; + lastContentSizeRequest = { + measurementId: data.measurementId, + generation: data.generation + }; scheduleContentSize(); } }); @@ -512,6 +822,100 @@ const URL_PREVIEW_SELECTION_BRIDGE = `<script data-od-url-selection-bridge> postStroke('od:pod-stroke'); }); } + // The host switches a plain URL preview to a bridge-enabled srcDoc when + // Manual Edit opens. Capture only mutable UI state so the second document + // can show the same app page without copying or evaluating artifact code. + function runtimeStateAttributeAllowed(name){ + return name === 'class' || + name === 'style' || + name === 'hidden' || + name === 'open' || + name.indexOf('aria-') === 0 || + (name.indexOf('data-') === 0 && name.indexOf('data-od-') !== 0); + } + function runtimeStateAttributes(el){ + var attrs = Object.create(null); + if (!el || !el.attributes) return attrs; + for (var i = 0; i < el.attributes.length; i++) { + var attr = el.attributes[i]; + if (!attr || !runtimeStateAttributeAllowed(attr.name)) continue; + attrs[attr.name] = String(attr.value || ''); + } + return attrs; + } + function runtimeStatePath(el){ + var path = []; + var node = el; + while (node && node !== document.body) { + var parent = node.parentElement; + if (!parent) return null; + var index = Array.prototype.indexOf.call(parent.children, node); + if (index < 0) return null; + path.unshift(index); + node = parent; + } + return node === document.body ? path : null; + } + function captureRuntimeState(){ + var entries = []; + var roots = []; + var rootHtmlLength = 0; + var runtimeRoots = document.body + ? document.body.querySelectorAll('#app, #root, [data-reactroot]') + : []; + for (var rootIndex = 0; rootIndex < runtimeRoots.length && roots.length < 64; rootIndex++) { + var root = runtimeRoots[rootIndex]; + var rootTag = String(root.tagName || '').toLowerCase(); + var rootPath = runtimeStatePath(root); + if (!rootPath) continue; + var rootHtml = String(root.innerHTML || ''); + if (rootHtmlLength + rootHtml.length > 2097152) break; + var rootEntry = { + path: rootPath, + tag: rootTag, + html: rootHtml + }; + if (root.id) rootEntry.id = String(root.id); + var rootOdId = root.getAttribute && root.getAttribute('data-od-id'); + if (rootOdId) rootEntry.odId = String(rootOdId); + roots.push(rootEntry); + rootHtmlLength += rootHtml.length; + } + var nodes = document.body ? document.body.querySelectorAll('*') : []; + var count = Math.min(nodes.length, 3500); + for (var i = 0; i < count; i++) { + var el = nodes[i]; + var path = runtimeStatePath(el); + if (!path) continue; + var entry = { + path: path, + tag: String(el.tagName || '').toLowerCase(), + attrs: runtimeStateAttributes(el) + }; + if (el.id) entry.id = String(el.id); + var odId = el.getAttribute && el.getAttribute('data-od-id'); + if (odId) entry.odId = String(odId); + var tag = entry.tag; + if (tag === 'input' || tag === 'textarea' || tag === 'select') { + entry.value = String(el.value == null ? '' : el.value); + } + if (tag === 'input' && (el.type === 'checkbox' || el.type === 'radio')) { + entry.checked = !!el.checked; + } + if (tag === 'select') entry.selectedIndex = Number(el.selectedIndex); + if (el.scrollLeft) entry.scrollLeft = Number(el.scrollLeft); + if (el.scrollTop) entry.scrollTop = Number(el.scrollTop); + entries.push(entry); + } + return { + version: 1, + hash: String(window.location.hash || ''), + roots: roots, + htmlAttrs: runtimeStateAttributes(document.documentElement), + bodyAttrs: runtimeStateAttributes(document.body), + entries: entries + }; + } window.addEventListener('message', function(ev){ var data = ev && ev.data; if (!data || !data.type) return; @@ -519,6 +923,14 @@ const URL_PREVIEW_SELECTION_BRIDGE = `<script data-od-url-selection-bridge> window.parent.postMessage({ type: 'od:url-selection-bridge-ready' }, '*'); return; } + if (data.type === 'od:preview-runtime-state-capture' && data.id) { + window.parent.postMessage({ + type: 'od:preview-runtime-state-captured', + id: String(data.id), + state: captureRuntimeState() + }, '*'); + return; + } if (data.type === 'od:comment-mode') { commentEnabled = !!data.enabled; mode = data.mode === 'pod' ? 'pod' : 'picker'; @@ -1217,14 +1629,826 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe const { sendApiError, createSseResponse } = ctx.http; const { DESIGN_SYSTEMS_DIR, PROJECTS_DIR, SKILLS_DIR, BRANDS_DIR, USER_DESIGN_SYSTEMS_DIR } = ctx.paths; const { readAppConfig, writeAppConfig } = ctx.appConfig; - const { insertProject, validateLinkedDirs, getProject, updateProject, dbDeleteProject, removeProjectDir } = ctx.projectStore; + const { + insertProject, + validateLinkedDirs, + getProject, + updateProject, + dbDeleteProject, + removeProjectDir, + stageProjectDirsForDelete, + ensureWorkspaceProject, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + listWorkspaceProjects, + updateWorkspaceProject, + rebindWorkspaceProject, + deleteWorkspaceProject, + countWorkspaceProjectRefs, + } = ctx.projectStore; const { writeProjectFile, readProjectFile, ensureProject, listFiles, listTabs, setTabs, resolveProjectDir } = ctx.projectFiles; const { insertConversation } = ctx.conversations; const { getTemplate, listTemplates, deleteTemplate, insertTemplate, findTemplateByNameAndProject, updateTemplate } = ctx.templates; - const { listLatestProjectRunStatuses, listProjectsAwaitingInput, normalizeProjectDisplayStatus, composeProjectDisplayStatus, listProjects } = ctx.status; + const { listLatestProjectRunStatuses, listProjectsAwaitingInput, normalizeProjectDisplayStatus, composeProjectDisplayStatus, listProjects, listUnboundProjects } = ctx.status; const { subscribeFileEvents, activeProjectEventSinks } = ctx.events; const { randomId } = ctx.ids; const { validateProjectDesignSystemId, validateProjectSkillId } = ctx.validation; + const { collabSync, teamProjectCatalog, workspaceTypes } = ctx; + const enforceWorkspaceProjectMutation = createEnforceWorkspaceProjectMutation( + ctx.verifyWorkspaceRequestAuthority, + ); + const authorizeProjectRequest = + ctx.authorizeProjectRequest ?? + createAuthorizeProjectRequest({ + db, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + isProjectRevoked: (_db, projectId) => + ctx.isProjectRevoked?.(projectId) ?? false, + ...(ctx.verifyWorkspaceRequestAuthority + ? { verifyWorkspaceRequestAuthority: ctx.verifyWorkspaceRequestAuthority } + : {}), + sendApiError, + }); + async function verifiedWorkspaceProjectContext( + req: any, + ): Promise<WorkspaceProjectContext | null> { + if (!ctx.verifyWorkspaceRequestAuthority) return null; + const verified = await ctx.verifyWorkspaceRequestAuthority(req); + return verified.ok ? workspaceResourceContextFromVerified(verified.context) : null; + } + /** + * Where a created project belongs when the request has no authorization gate + * of its own — the duplicate / design-system-copy pair and the + * project-location scan importer. An asserted pair is verified through the + * same directory lookup as `POST /api/projects`; a headerless legacy/local + * request remains unbound and a failed assertion writes nothing. + */ + const resolveCreatedProjectHome = createCreatedProjectWorkspaceResolver({ + ...(ctx.fetchProjectCreationWorkspaceDirectory + ? { fetchWorkspaceDirectory: ctx.fetchProjectCreationWorkspaceDirectory } + : {}), + }); + function sendMissingWorkspaceContext(res: Response) { + return sendApiError(res, 401, 'WORKSPACE_CONTEXT_REQUIRED', 'workspace context is required'); + } + async function authoritativeWorkspaceProjectContext( + req: any, + res: Response, + expectedWorkspaceId: string, + ): Promise<WorkspaceProjectContext | null> { + if (!ctx.verifyWorkspaceRequestAuthority) { + const legacy = workspaceProjectContext(req, expectedWorkspaceId); + if (!legacy) sendMissingWorkspaceContext(res); + return legacy; + } + const verified = await ctx.verifyWorkspaceRequestAuthority(req); + if (!verified.ok) { + sendApiError(res, verified.status, verified.code, verified.message); + return null; + } + if (verified.context.workspaceId !== expectedWorkspaceId) { + sendApiError( + res, + 403, + 'WORKSPACE_ACCESS_DENIED', + 'the requested workspace does not match the route workspace', + ); + return null; + } + return workspaceResourceContextFromVerified(verified.context); + } + /** + * Refuse — loudly — to record a team share in a workspace that cannot host + * one. Loudly is the point: the impossible rows this prevents are invisible + * locally and only surface as `403 missing_principal` on every later collab + * call, which is how one shipped and survived in a dogfood user's daemon. + */ + function sendTeamShareScopeRefused( + res: Response, + ctx: WorkspaceProjectContext, + reason: TeamShareScopeRefusal, + ) { + console.warn( + `[od] refused a team share into workspace ${ctx.workspaceId} (${reason}): ` + + 'a team share requires a team workspace; a personal workspace has no team plane.', + ); + return sendApiError( + res, + 409, + 'WORKSPACE_TEAM_SHARE_REQUIRES_TEAM_WORKSPACE', + 'a project can only be shared to a team from a team workspace', + ); + } + function pendingSyncIntent(projectId: string, workspaceId: string, visibility: 'personal' | 'team') { + return { + event: visibility === 'team' ? 'project_team_share_requested' : 'project_team_unshare_requested', + projectId, + workspaceId, + }; + } + class TeamProjectCatalogListError extends Error { + constructor(readonly cause: unknown) { + super('team project catalog list failed'); + this.name = 'TeamProjectCatalogListError'; + } + } + function normalizeWorkspaceProjectRow(row: any, ctx: WorkspaceProjectContext) { + let metadata: unknown; + try { + metadata = row.metadataJson ? JSON.parse(row.metadataJson) : undefined; + } catch { + metadata = undefined; + } + // A move/rename/share-visibility change touches only the workspace_projects + // row, not the project's own content (projects.updated_at) — but it is real, + // recent activity on this project from the user's point of view. Report the + // later of the two so the "最近更新" label matches the sort order above + // (ORDER BY MAX(p.updated_at, wp.updated_at)), instead of a card that jumps + // to the top of the list while still showing a stale "18 hours ago". + const lastActivityAt = Math.max(row.updatedAt, row.workspaceUpdatedAt ?? 0); + const project = { + id: row.id, + name: row.name, + skillId: row.skillId, + designSystemId: row.designSystemId, + pendingPrompt: row.pendingPrompt ?? undefined, + metadata, + appliedPluginSnapshotId: row.appliedPluginSnapshotId ?? undefined, + customInstructions: row.customInstructions ?? undefined, + createdAt: row.createdAt, + updatedAt: lastActivityAt, + // Carried on the nested project too, so a client that unwraps the summary + // into a plain Project keeps the binding instead of dropping it. + workspaceId: row.workspaceId ?? null, + }; + const resourceState = isWorkspaceLocked(ctx) && row.workspaceVisibility === 'team' + ? 'frozen' + : row.resourceState; + const wp = { + visibility: row.workspaceVisibility, + resourceState, + createdByWorkspaceMemberId: row.createdByWorkspaceMemberId ?? null, + }; + return { + id: project.id, + name: project.name, + workspaceId: row.workspaceId, + visibility: row.workspaceVisibility, + resourceState, + createdByWorkspaceMemberId: row.createdByWorkspaceMemberId ?? null, + updatedByWorkspaceMemberId: row.updatedByWorkspaceMemberId ?? null, + resourceHubResourceId: row.resourceHubResourceId ?? null, + cloudTombstonedAt: row.cloudTombstonedAt ?? null, + currentUserAccess: projectAccess(wp, ctx, workspaceTypes), + syncState: row.syncState ?? 'local_only', + ...(row.syncState === 'pending_upload' + ? { pendingSyncIntent: pendingSyncIntent(project.id, row.workspaceId, row.workspaceVisibility) } + : {}), + createdAt: row.createdAt, + updatedAt: lastActivityAt, + metadata, + project, + }; + } + function workspaceProjectPrincipal(ctx: WorkspaceProjectContext): ResourceHubPrincipal { + return { + memberId: ctx.workspaceMemberId, + teamId: ctx.workspaceId, + role: ctx.role, + lifecycleState: ctx.lifecycleState, + }; + } + function msFromIso(value: string): number { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : Date.now(); + } + function accessForRemoteTeamProject(remote: VelaTeamProjectRecord, ctx: WorkspaceProjectContext) { + const frozen = remote.access.frozen || isWorkspaceLocked(ctx); + const canView = remote.access.canView && !frozen && ctx.memberStatus === 'active'; + // `remote.access.canEdit` alone is not enough to grant local mutation: it + // can be true for reasons that do not make THIS member the owner (a team + // admin's blanket edit grant, a generic per-project flag, etc.), and + // treating "can view something I don't own yet" as "adopt it and make it + // mine" is exactly the ownership-invention the adoption red line above + // forbids — a member discovering a teammate's shared project must stay + // read-only regardless of canEdit. Require this member to BE the project's + // owner too; only then is honoring canEdit "this member's own project, + // whose local row is stale" rather than "assign ownership to a reader". + const isOwner = remote.ownerMemberId === ctx.workspaceMemberId; + const canMutate = canView && remote.access.canEdit && isOwner; + const disabledReason = frozen + ? isWorkspaceLocked(ctx) + ? 'workspace_locked' + : 'resource_frozen' + : canView + ? undefined + : 'permission_denied'; + return { + canOpen: canView, + canRename: canMutate, + canDelete: canMutate, + canDuplicate: canMutate, + canMoveToTeam: false, + canMoveToPersonal: false, + canExport: canView, + canSendTo: canView, + canRestoreVersion: canMutate, + ...(disabledReason ? { disabledReason } : {}), + }; + } + function remoteTeamProjectSummary( + remote: VelaTeamProjectRecord, + ctx: WorkspaceProjectContext, + ) { + const createdAt = msFromIso(remote.createdAt); + const updatedAt = msFromIso(remote.updatedAt); + const syncState: ProjectSyncState = velaProjectSyncStateToProject(remote.syncState); + const resourceState = remote.access.frozen || isWorkspaceLocked(ctx) ? 'frozen' : 'active'; + const name = remote.displayName?.trim() || remote.projectId; + // A catalog-only summary has no local project directory yet. Reuse the + // existing placeholder metadata contract so clients do not issue local + // file/cover reads that can only 404 before the first explicit pull. The + // materialized local row replaces this projection (and clears the stamp) + // once real hub content lands. + const metadata = { sharedProjectPlaceholderAt: updatedAt }; + const project = { + id: remote.projectId, + name, + skillId: null, + designSystemId: null, + metadata, + createdAt, + updatedAt, + }; + return { + // Summary identity is the resource-hub id so two catalog entries that + // share the same projectId stay distinct in the list (unique React key / + // owner-scoped lookup by resource id). The web opens the card via the + // nested `project.id` below, so the real projectId is preserved there. + id: remote.resourceId, + name, + workspaceId: ctx.workspaceId, + visibility: 'team', + resourceState, + createdByWorkspaceMemberId: remote.ownerMemberId, + updatedByWorkspaceMemberId: remote.ownerMemberId, + resourceHubResourceId: remote.resourceId, + cloudTombstonedAt: null, + currentUserAccess: accessForRemoteTeamProject(remote, ctx), + syncState, + createdAt, + updatedAt, + metadata, + project, + }; + } + /** + * Catalog identities this member has just moved back to "personal". + * + * A move to personal deletes the hub catalog row inside the same request, + * but the team catalog is read through a stale-while-revalidate cache, so + * the very next list can still carry the row that was just removed. The + * move also nulls `resourceHubResourceId`, which is the key + * `listRemoteTeamProjectSummaries` dedupes on — so without this gate the + * stale row is re-materialised as a `visibility: 'team'` card and the + * project silently un-unshares itself. Worse, a remote summary is never + * `canMoveToPersonal`, so the user cannot undo it. + * + * `cloudTombstonedAt` is the local truth for "this member unshared it", and + * a re-share clears it (see `workspaceProjectMovePatch`). Scoping by owner + * keeps a teammate's own share of the same project id visible. + */ + function locallyTombstonedTeamProjects(localRows: any[], ctx: WorkspaceProjectContext) { + const projectIds = new Set<string>(); + const resourceIds = new Set<string>(); + for (const row of localRows) { + if (row.workspaceVisibility !== 'personal' || row.cloudTombstonedAt == null) continue; + projectIds.add(row.id); + resourceIds.add(projectResourceIdFor(row.id, workspaceProjectPrincipal(ctx))); + } + return { projectIds, resourceIds }; + } + function remoteTeamProjectWasUnsharedLocally( + remote: VelaTeamProjectRecord, + tombstoned: { projectIds: Set<string>; resourceIds: Set<string> }, + ctx: WorkspaceProjectContext, + ): boolean { + if (tombstoned.resourceIds.has(remote.resourceId)) return true; + // The resource id derivation depends on the principal that shared the + // project; fall back to owner-scoped project identity so an unshare + // performed under a different principal still suppresses its own row. + return remote.ownerMemberId === ctx.workspaceMemberId && tombstoned.projectIds.has(remote.projectId); + } + /** + * Reconcile a project's local `workspace_projects` row against what B's team + * catalog says about THIS member's access to it, in both directions. + * + * `listRemoteTeamProjectSummaries` passes an already-loaded local row for an + * exact resource/project match; unmatched catalog rows keep the historical + * project-id lookup used to repair stale bindings. This keeps the list path + * at one catalog call without adding one SQLite lookup per visible project. + * Exact matches may safely repair binding state, but a foreign mirror must + * remain creator-unattributed in SQLite; the remote owner is display/ + * authorization evidence, not evidence that this daemon created the + * project. `accessForRemoteTeamProject` derives the DISPLAYED capabilities + * from `remote.access.canEdit`; without matching ENFORCED state, the two + * directions disagree: + * - `canEdit: true` but the local row is missing/mismatched: the listing + * would show a normal-looking, "editable" project whose every save 403s, + * because `enforceWorkspaceProjectMutation` never finds a matching row. + * - `canEdit: false` but a stale local row happens to already sit under + * THIS workspace with THIS member recorded as its creator (a rarer, but + * real, coincidence — e.g. a locally-created draft that was never + * shared, then this project id got reused by an unrelated team share): + * the local row would grant a save the remote side has already revoked. + * Only a visibly stale exact binding or the existing narrow access-repair + * case is written. A correct mirror remains untouched. + */ + function reconcileLocalRowWithRemoteTeamAccess( + remote: VelaTeamProjectRecord, + ctx: WorkspaceProjectContext, + loadedExactRow?: any, + ): void { + const existing = loadedExactRow ?? getWorkspaceProjectByProjectId(db, remote.projectId); + const existingVisibility = existing?.visibility ?? existing?.workspaceVisibility; + // Ownership match required, same reasoning as accessForRemoteTeamProject + // above: never rebind a row to make a reader look like this project's + // creator just because B's generic canEdit happens to read true for them. + const isOwner = remote.ownerMemberId === ctx.workspaceMemberId; + const persistedCreatorMemberId = isOwner ? ctx.workspaceMemberId : null; + const canEdit = remote.access.canEdit && remote.access.canView && !remote.access.frozen && isOwner; + const expectedResourceState = remote.access.frozen ? 'frozen' : 'active'; + const expectedSyncState = velaProjectSyncStateToProject(remote.syncState); + if (canEdit) { + const alreadyCorrect = existing + && existing.workspaceId === ctx.workspaceId + && existingVisibility === 'team' + && existing.createdByWorkspaceMemberId === persistedCreatorMemberId + && existing.resourceHubResourceId === remote.resourceId + && existing.resourceState === expectedResourceState + && existing.syncState === expectedSyncState; + if (alreadyCorrect) return; + rebindWorkspaceProject(db, remote.projectId, { + workspaceId: ctx.workspaceId, + visibility: 'team', + resourceState: expectedResourceState, + createdByWorkspaceMemberId: persistedCreatorMemberId, + updatedByWorkspaceMemberId: ctx.workspaceMemberId, + resourceHubResourceId: remote.resourceId, + cloudTombstonedAt: null, + syncState: expectedSyncState, + // This runs INSIDE the list read, against B's catalog — nobody changed + // the project, so it must not restamp `lastActivityAt` below (which is + // `MAX(p.updated_at, wp.updated_at)`). See SYNC_KEEPS_UPDATED_AT. + updatedAt: SYNC_KEEPS_UPDATED_AT, + }); + return; + } + // canEdit: false. An exact resource-id match is authoritative enough to + // repair a materialized mirror whose placeholder binding has no creator: + // it is the same hub share, not an unrelated local project with a colliding + // project id. Otherwise only tighten a row that currently claims THIS + // workspace + THIS member as a team-writable binding for THIS project. + const exactRemoteBinding = existing + && existing.workspaceId === ctx.workspaceId + && existingVisibility === 'team' + && existing.resourceHubResourceId === remote.resourceId; + if ( + exactRemoteBinding + && existing.createdByWorkspaceMemberId === persistedCreatorMemberId + && existing.resourceState === expectedResourceState + && existing.syncState === expectedSyncState + ) { + return; + } + const wronglyPermissive = existing + && existing.workspaceId === ctx.workspaceId + && existingVisibility === 'team' + && existing.createdByWorkspaceMemberId === ctx.workspaceMemberId; + if (!exactRemoteBinding && !wronglyPermissive) return; + rebindWorkspaceProject(db, remote.projectId, { + workspaceId: ctx.workspaceId, + visibility: 'team', + resourceState: expectedResourceState, + createdByWorkspaceMemberId: persistedCreatorMemberId, + updatedByWorkspaceMemberId: ctx.workspaceMemberId, + resourceHubResourceId: remote.resourceId, + syncState: expectedSyncState, + // Same reason as the canEdit branch above: reconciliation, not activity. + updatedAt: SYNC_KEEPS_UPDATED_AT, + }); + } + /** + * Give a project with NO local `workspace_projects` row a chance to learn it + * is actually a team resource before `/move` defaults it to personal. + * + * `ensureWorkspaceProjection(project, ctx, 'personal')` (below, in the move + * route) unconditionally binds a brand-new row as `visibility: 'personal'`. + * That default is harmless for a "move to team" request — canMoveToTeam + * requires exactly that starting visibility — but it is fatal for a "move to + * personal" request: the code has just invented the very state + * (`visibility: 'personal'`) that makes canMoveToPersonal impossible, then + * rejects the request for contradicting the state it invented one line + * earlier (PROJECT_DELETE_FORBIDDEN, recvqfNnRETNtM / recvqgejeqK2OJ). + * + * A project reaches `/move` with no local row for reasons that have nothing + * to do with whether it is genuinely a team resource: the brand/design-system + * extraction pipeline (`brands/index.ts`) inserts its backing project without + * ever calling `ensureWorkspaceProject` or registering it with the hub's own + * team-project catalog, and a project shared to this team from a different + * device/session never gets a row written into THIS daemon's own sqlite + * until something reconciles it. The web client's own "shared" badge and its + * "move out of team" affordance (`createSharedProjectPredicate`, + * `RecentProjectsStrip.tsx`) already read this exact catalog + * (`GET /api/workspace/projects/team` → `createTeamProjectsLister` → + * `velaCliTeamProjectCatalog`, the same instance threaded into this route as + * `teamProjectCatalog`) — so whenever that affordance is visible at all, the + * hub already knows this project is team-visible, whether or not this + * exact daemon's local sqlite has caught up. + * + * Reconciliation is itself authority-sensitive. A catalog reader who is + * neither the recorded project creator nor a Workspace owner/admin must not + * turn the orphan into a sticky Team binding: doing so would consume the + * only evidence that lets a later privileged caller repair the historical + * row. Creator identity keeps the ordinary creator-only path; owner/admin + * receives a request-local recovery witness returned to the move route + * below. The transient row is deleted if the remote unshare fails, and the + * witness itself is never retained as authority, so it cannot broaden later + * mutations on an ordinary already-bound Team project. + * + * Deliberately best-effort: a catalog outage must not turn an unshare + * attempt into a 500. Falling through to the pre-existing personal default + * is exactly the answer this function would give anyway if the hub had no + * record for the project. + */ + type UnboundProjectMoveReconciliation = + | 'none' + | 'creator' + | 'privileged' + | 'denied'; + + async function reconcileUnboundProjectBeforeMove( + projectId: string, + ctx: WorkspaceProjectContext, + ): Promise<UnboundProjectMoveReconciliation> { + if (!teamProjectCatalog) return 'none'; + let remoteProjects: VelaTeamProjectRecord[]; + try { + remoteProjects = await teamProjectCatalog.list(workspaceProjectPrincipal(ctx)); + } catch { + return 'none'; + } + const remote = remoteProjects.find((item) => item.projectId === projectId && item.access.canView); + if (!remote) return 'none'; + const creator = remote.ownerMemberId === ctx.workspaceMemberId; + const privilegedRecovery = + (ctx.role === 'owner' || ctx.role === 'admin') + && ctx.memberStatus === 'active' + && ctx.lifecycleState === 'active' + && ctx.canShareProjects + && ctx.canWriteSyncedFiles + && !remote.access.frozen; + if (!creator && !privilegedRecovery) return 'denied'; + ensureWorkspaceProject(db, { + projectId, + workspaceId: ctx.workspaceId, + visibility: 'team', + resourceState: remote.access.frozen ? 'frozen' : 'active', + createdByWorkspaceMemberId: remote.ownerMemberId ?? null, + updatedByWorkspaceMemberId: ctx.workspaceMemberId, + resourceHubResourceId: remote.resourceId, + cloudTombstonedAt: null, + syncState: 'synced', + }); + // The normal creator check handles creator-owned rows. Only a privileged + // non-creator needs the ephemeral override, and a frozen catalog entry + // must remain immutable even during recovery. + if (creator) return 'creator'; + return 'privileged'; + } + function catalogEnrichedLocalTeamProjectSummary( + summary: any, + remote: VelaTeamProjectRecord, + ctx: WorkspaceProjectContext, + ) { + const name = remote.displayName?.trim(); + const frozen = remote.access.frozen || isWorkspaceLocked(ctx); + return { + ...summary, + ...(name ? { name } : {}), + createdByWorkspaceMemberId: remote.ownerMemberId, + resourceState: frozen ? 'frozen' : 'active', + currentUserAccess: accessForRemoteTeamProject(remote, ctx), + syncState: velaProjectSyncStateToProject(remote.syncState), + project: { + ...summary.project, + ...(name ? { name } : {}), + }, + }; + } + async function listRemoteTeamProjectSummaries(localRows: any[], ctx: WorkspaceProjectContext) { + if (!teamProjectCatalog) { + return { + matchedByResourceId: new Map<string, VelaTeamProjectRecord>(), + remoteSummaries: [], + }; + } + const localResourceIds = new Set(localRows.map((row) => row.resourceHubResourceId).filter(Boolean)); + const localRowByExactRemoteIdentity = new Map( + localRows + .filter((row) => row.resourceHubResourceId) + .map((row) => [`${row.resourceHubResourceId}\0${row.id}`, row] as const), + ); + const tombstoned = locallyTombstonedTeamProjects(localRows, ctx); + let remoteProjects: VelaTeamProjectRecord[]; + try { + remoteProjects = await teamProjectCatalog.list(workspaceProjectPrincipal(ctx)); + } catch (error) { + throw new TeamProjectCatalogListError(error); + } + const seenResourceIds = new Set<string>(); + const visibleProjects = remoteProjects + .filter((project) => project.workspaceId === ctx.workspaceId) + .filter((project) => project.access.canView) + .filter((project) => !remoteTeamProjectWasUnsharedLocally(project, tombstoned, ctx)); + for (const project of visibleProjects) { + try { + const exactRow = localRowByExactRemoteIdentity.get(`${project.resourceId}\0${project.projectId}`); + reconcileLocalRowWithRemoteTeamAccess(project, ctx, exactRow); + } catch (error) { + // Best-effort: a reconciliation failure must not break the list itself + // (the client still gets a correct-enough READ from accessForRemoteTeamProject + // below; only the next SAVE would still need a retry). + console.error('[team-projects] failed to reconcile local row with remote access', error); + } + } + const matchedByResourceId = new Map( + visibleProjects + .filter((project) => localRowByExactRemoteIdentity.has(`${project.resourceId}\0${project.projectId}`)) + .map((project) => [project.resourceId, project] as const), + ); + const remoteSummaries = visibleProjects + .filter((project) => !localResourceIds.has(project.resourceId)) + .filter((project) => { + if (seenResourceIds.has(project.resourceId)) return false; + seenResourceIds.add(project.resourceId); + return true; + }) + .map((project) => remoteTeamProjectSummary(project, ctx)); + return { matchedByResourceId, remoteSummaries }; + } + /** + * Bind a project to this workspace, or hand back the binding it already has. + * + * The lookup is by PROJECT, not by `(workspace, project)`. A project belongs + * to exactly one workspace (collab/workspace-project-home.ts), so "no row in + * the workspace I am currently looking at" does not mean "unbound" — reading + * it that way is what made an older build write one ownerless row per + * workspace visited and put the same 草稿 list in front of every workspace. + */ + function ensureWorkspaceProjection(project: any, ctx: WorkspaceProjectContext, visibility = 'personal') { + const existing = getWorkspaceProjectByProjectId(db, project.id); + return existing ?? ensureWorkspaceProject(db, { + projectId: project.id, + workspaceId: ctx.workspaceId, + visibility, + resourceState: 'active', + createdByWorkspaceMemberId: null, + updatedByWorkspaceMemberId: null, + syncState: 'local_only', + resourceHubResourceId: null, + cloudTombstonedAt: null, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + }); + } + + /** + * Bind a freshly duplicated / design-system-copied project into the SAME + * workspace the request that made it is acting in. + * + * `POST /api/projects` binds the project it creates immediately (see + * `workspaceIdForCreate` below), but duplicate and design-system-copy used + * to skip that step entirely — the new project row landed with NO + * `workspace_projects` row at all. It stayed an unbound orphan until + * whichever workspace's project list happened to be read next, and only a + * PERSONAL workspace read ever adopts an orphan + * (`bindUnboundProjectsToPersonalWorkspace` only runs for + * `ctx.workspaceType === 'personal'`). So a duplicate made from inside a + * team workspace silently re-homed into the caller's personal workspace + * the next time it was read, instead of staying in the team it was + * actually duplicated from (recvqbjbudBS9r). + * + * Called only after `enforceWorkspaceProjectMutation` already allowed the + * duplicate/copy, which is proof `ctx` names an active, write-capable + * member of the workspace that owns the SOURCE project — exactly the right + * home for the copy too. + * + * A request with no identity remains a true legacy/unbound copy. Modern web + * callers lock and send the source project's persisted exact scope. + */ + function bindDuplicateIntoRequestWorkspace( + ctx: WorkspaceResourceContext | null, + targetProjectId: string, + now: number, + ) { + if (ctx === null) return; + ensureWorkspaceProject(db, { + projectId: targetProjectId, + workspaceId: ctx.workspaceId, + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: ctx.workspaceMemberId, + updatedByWorkspaceMemberId: ctx.workspaceMemberId, + syncState: 'local_only', + resourceHubResourceId: null, + cloudTombstonedAt: null, + createdAt: now, + updatedAt: now, + }); + } + /** + * Claim a project this daemon has never bound to ANY workspace into the + * CURRENT mutating request's workspace, right before + * `enforceWorkspaceProjectMutation` evaluates it. + * + * the verified Workspace mutation gate denies any + * mutation the moment the two-key lookup comes back empty + * (`workspaceResourceMutationAllowed`'s `if (!row) return false;`) — right + * for a project genuinely bound to a DIFFERENT workspace than the one the + * caller claims, but wrong for a project this daemon has never bound + * anywhere at all. That exact state is reachable one call up this same + * route: `bindDuplicateIntoRequestWorkspace` above skips binding the COPY + * whenever the duplicating request carried no workspace headers + * (`ctx === null` — a legitimate legacy/pre-context caller, per its own doc + * comment), leaving the copy permanently unbound. The FIRST later mutation + * that DOES carry real headers — e.g. duplicating that same copy again once + * the client's `workspaceContext` has resolved — then 403s as "workspace + * project mutation is not allowed" even though nothing has ever claimed the + * project (recvqbhor3pai2, "复制的项目再次复制"). + * + * Keyed on "does ANY `workspace_projects` row exist for this project id at + * all" (`getWorkspaceProjectByProjectId`), not on the current + * `ctx.workspaceId` — a project already bound elsewhere (including a + * remote team project a prior list read already reconciled, which always + * attributes the REAL hub owner, never the reader) is left exactly where it + * is; this only ever claims a true orphan, matching `ensureWorkspaceProject`'s + * own idempotency contract. + * + * Attributes an owner, deliberately NOT the `null` an ordinary lazy-read + * projection uses (`ensureWorkspaceProjection`). A passive list read must not + * silently hand out ownership just because it happened to run first; an + * explicit mutation request naming this exact project is the "yes, this is + * mine" signal a read never had. + * + * But that owner is NOT the request's own claim. `workspaceProjectContextFromRequest` + * only PARSES `x-od-workspace-*`, which is an unauthenticated hint any local + * caller can forge, and this row's `createdByWorkspaceMemberId` is what + * `workspaceResourceAccess` turns into `selfCreated` — the bit that grants a + * non-privileged member mutation rights over it. Writing the header value + * meant a plain curl could claim someone else's orphaned project into a + * workspace it has no membership in and install itself as the author. + * + * So the workspace and authorship both come from + * `resolveCreatedProjectHome`, the same exact verifier every created-project + * path uses: + * + * - the asserted identity VERIFIES against the membership directory -> claim + * it, attributed to the DIRECTORY's member id rather than the header's; + * - it does NOT verify — foreign, inactive, removed, or authority unreadable + * -> write NOTHING; + * - no pair was asserted -> write nothing, and let the pre-existing gate + * below answer. A caller that cannot prove membership over a project + * nothing has ever claimed is exactly who that gate is for; inventing a + * binding to keep it happy is what this fix removes. + * + * Failing closed is essential because this binding is sticky: assigning an + * orphan from a forged or unverifiable request could prevent its rightful + * Workspace from reconciling it later. + * + * The `null`/`'missing'` early return is unchanged and load-bearing, and is + * why this does not simply use `createdProjectWorkspaceHome`'s own third + * branch. The verified Workspace mutation gate runs immediately after this and + * its HEADERLESS branch answers 401 WORKSPACE_CONTEXT_REQUIRED as soon as ANY + * row exists for the resource. Claiming on a request that asserts nothing + * would therefore convert a working headerless mutation into a 401. + */ + function reconcileUnboundProjectBeforeMutation( + req: any, + projectId: string, + home: WorkspaceResourceContext | null, + ) { + const asserted = workspaceProjectContextFromRequest(req); + if (asserted === null || asserted === 'missing') return; + if (getWorkspaceProjectByProjectId(db, projectId)) return; + if (!home) return; + // Verified assertions resolve to the exact pair used as the directory key. + if ( + home.workspaceId !== asserted.workspaceId + || home.workspaceMemberId !== asserted.workspaceMemberId + ) { + return; + } + const now = Date.now(); + ensureWorkspaceProject(db, { + projectId, + workspaceId: home.workspaceId, + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: home.workspaceMemberId, + updatedByWorkspaceMemberId: home.workspaceMemberId, + syncState: 'local_only', + resourceHubResourceId: null, + cloudTombstonedAt: null, + createdAt: now, + updatedAt: now, + }); + } + function workspaceProjectRowVisibleForLocations( + row: any, + locations: Array<{ id: string; path: string; builtIn?: boolean }>, + ): boolean { + let metadata: unknown; + try { + metadata = row.metadataJson ? JSON.parse(row.metadataJson) : undefined; + } catch { + metadata = undefined; + } + return projectVisibleForLocations({ metadata }, locations); + } + + function workspaceProjectRowBelongsToCurrentWorkspace(row: any, ctx: WorkspaceProjectContext): boolean { + // A revoked pulled mirror stays bound to its exact Team identity as a + // non-destructive tombstone. It must not appear in any project list while + // its stale local bytes are quarantined. + if (row.resourceState === 'deleted') return false; + if (ctx.workspaceType !== 'team') return true; + // Legacy rows created before workspace isolation may have been projected into + // a team workspace as personal projects with no owner. They actually belong + // to the user's personal workspace, so suppress them in team views without + // deleting any local data. Real team-workspace drafts carry an owner member. + return !(row.workspaceVisibility === 'personal' && row.createdByWorkspaceMemberId == null); + } + + function workspaceProjectRowsForIds( + projectIds: string[], + ctx: WorkspaceProjectContext, + locations: Array<{ id: string; path: string; builtIn?: boolean }>, + ) { + for (const id of projectIds) { + const project = getProject(db, id); + if (ctx.workspaceType === 'personal' && project && projectVisibleForLocations(project, locations)) { + ensureWorkspaceProjection(project, ctx, 'personal'); + } + } + return listWorkspaceProjects(db, ctx.workspaceId) + .filter((row: any) => workspaceProjectRowBelongsToCurrentWorkspace(row, ctx)) + .filter((row: any) => workspaceProjectRowVisibleForLocations(row, locations)); + } + + function workspaceProjectCreatedByCurrentMember(project: any, ctx: WorkspaceProjectContext): boolean { + if (project.createdByWorkspaceMemberId === ctx.workspaceMemberId) return true; + return ( + ctx.workspaceType === 'personal' && + project.visibility === 'personal' && + project.createdByWorkspaceMemberId == null + ); + } + + /** + * Bind projects that belong to NO workspace to this personal workspace. + * + * The rule is adoption of orphans, not a back-fill of everything. A project + * that already has a binding is left exactly where it is; only a project with + * no row anywhere is claimed. Those are the pre-workspace ("legacy") projects + * — created before workspaces existed, or left unbound by the repair in + * collab/workspace-project-home.ts — and losing them across the upgrade would + * be data loss, which the red-line test in tests/routes/workspace-projects.ts + * guards. + * + * The target is the user's PERSONAL workspace, per product: it always exists, + * so there is always somewhere to put an orphan, and it is the honest home for + * a project that predates any team. Team workspaces are excluded on purpose — + * adopting a user's private pre-workspace drafts into a team would expose them + * to people who never had them. + * + * Which personal workspace, when the user has several? The one they opened + * first after upgrading. There is no better evidence available: the projects + * carry no workspace of their own, and a workspace is only knowable as + * personal from the request that names it. Doing this on a read rather than in + * the migration is what buys that knowledge. + */ + function bindUnboundProjectsToPersonalWorkspace( + ctx: WorkspaceProjectContext, + locations: Array<{ id: string; path: string; builtIn?: boolean }>, + ) { + if (ctx.workspaceType !== 'personal') return; + for (const project of listProjects(db).filter((item: any) => projectVisibleForLocations(item, locations))) { + if (getWorkspaceProjectByProjectId(db, project.id)) continue; + ensureWorkspaceProjection(project, ctx, 'personal'); + } + } async function loadPluginRegistryView() { const [skills, designSystems] = await Promise.all([ listSkills(SKILLS_DIR), @@ -1403,8 +2627,12 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe } }); - app.post('/api/project-locations/scan', async (_req, res) => { + app.post('/api/project-locations/scan', async (req, res) => { try { + // Resolve once before scanning or inserting anything. An explicitly + // scoped request whose membership is removed/unavailable must not leave + // partially imported unbound projects behind. + const createHome = await resolveCreatedProjectHome(req); const locations = (await configuredProjectLocations()).filter((loc: any) => !loc.builtIn); const imported = []; const existing: string[] = []; @@ -1450,6 +2678,18 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe createdAt: now, updatedAt: now, }); + // A project this scan adopts off disk is as much a created project + // as one typed into the composer, and needs the same home + // workspace. Without this the imported project is an orphan the + // moment it appears: denied its first run by + // the verified Workspace mutation gate, and billing-less on any run + // that does get through. + bindCreatedProjectToWorkspace( + (input) => ensureWorkspaceProject(db, input), + createHome, + manifest.id, + now, + ); if (project) imported.push(project); } catch (err: any) { skipped.push({ path: entry.dir, reason: String(err?.message ?? err) }); @@ -1460,6 +2700,15 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe const body = { scanned, imported, existing, skipped }; res.json(body); } catch (err: any) { + if (err instanceof CreatedProjectWorkspaceResolutionError) { + return sendApiError( + res, + err.status, + err.code, + err.message, + err.retryable ? { retryable: true } : {}, + ); + } sendApiError(res, 400, 'BAD_REQUEST', String(err)); } }); @@ -1485,12 +2734,25 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe } } } + // This is the NO-SCOPE catalog: no `x-od-workspace-*` headers are read + // here at all, so every unbound (never-claimed) project must be visible + // (pre-workspace-isolation compatibility) while every project some + // workspace HAS claimed must not leak to a caller with no identity to + // check it against — a signed-out client, a removed member, or a plain + // `curl` (spec 04 §10: "no scope" must not mean "trust everything"). + // `listUnboundProjects` is the join that enforces this; a workspace- + // scoped caller uses `GET /api/workspaces/:id/projects` instead, which + // has its own ctx-gated membership check. Every row here is, by + // construction, unbound — so `workspaceId` is always `null`; no binding + // lookup needed (a `listWorkspaceProjectBindings` scan here would only + // ever resolve to misses). /** @type {import('@open-design/contracts').ProjectsResponse} */ const body = { - projects: listProjects(db) + projects: listUnboundProjects(db) .filter((project: any) => projectVisibleForLocations(project, locations)) .map((project: any) => ({ ...project, + workspaceId: null, status: brandAwareProjectStatus( project, composeProjectDisplayStatus( @@ -1508,6 +2770,338 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe } }); + app.get('/api/workspaces/:workspaceId/projects', async (req, res) => { + try { + const authoritativeCtx = await authoritativeWorkspaceProjectContext( + req, + res, + req.params.workspaceId, + ); + if (!authoritativeCtx) return; + const assertedCtx = workspaceProjectContextFromRequest(req); + const ctx = assertedCtx && assertedCtx !== 'missing' + ? { + ...authoritativeCtx, + // Request capability flags are UI ceilings only: they may hide an + // action, but never elevate directory-backed authority. + canShareProjects: + authoritativeCtx.canShareProjects && assertedCtx.canShareProjects, + canWriteSyncedFiles: + authoritativeCtx.canWriteSyncedFiles && assertedCtx.canWriteSyncedFiles, + } + : authoritativeCtx; + if (ctx.memberStatus === 'removed') { + /** @type {import('@open-design/contracts').WorkspaceProjectsResponse} */ + const body = { projects: [] }; + return res.json(body); + } + const locations = await configuredProjectLocations(); + bindUnboundProjectsToPersonalWorkspace(ctx, locations); + const view = typeof req.query.view === 'string' ? req.query.view : 'all'; + if (view !== 'all' && view !== 'recent' && view !== 'drafts' && view !== 'team') { + return sendApiError(res, 400, 'BAD_REQUEST', 'view must be all, recent, drafts, or team'); + } + const owner = typeof req.query.owner === 'string' ? req.query.owner : 'all'; + const visibility = typeof req.query.visibility === 'string' ? req.query.visibility : 'all'; + const rows = listWorkspaceProjects(db, ctx.workspaceId) + .filter((row: any) => workspaceProjectRowBelongsToCurrentWorkspace(row, ctx)) + .filter((row: any) => workspaceProjectRowVisibleForLocations(row, locations)); + const queryCanIncludeTeam = + view !== 'drafts' && + visibility !== 'personal' && + (view === 'team' || view === 'recent' || visibility === 'team' || (view === 'all' && visibility === 'all')); + // Only a team workspace has a remote team-project catalog. A personal + // workspace must never merge the caller's team projects into its list — + // the Vela CLI team-projects lister is scoped to the active team, not the + // queried workspace, so without this guard team projects leak into (and + // duplicate within) a personal workspace's project list. + const needsRemoteTeamProjects = queryCanIncludeTeam && ctx.workspaceType === 'team'; + const remoteMerge = needsRemoteTeamProjects + ? await listRemoteTeamProjectSummaries(rows, ctx) + : null; + const mergedProjects = [ + ...rows.map((row: any) => { + const summary = normalizeWorkspaceProjectRow(row, ctx); + const remote = row.workspaceVisibility === 'team' && row.resourceHubResourceId + ? remoteMerge?.matchedByResourceId.get(row.resourceHubResourceId) + : null; + return remote && remote.projectId === row.id + ? catalogEnrichedLocalTeamProjectSummary(summary, remote, ctx) + : summary; + }), + ...(remoteMerge?.remoteSummaries ?? []), + ]; + const projects = mergedProjects + .filter((project: any) => { + const createdByCurrentMember = workspaceProjectCreatedByCurrentMember(project, ctx); + if (view === 'drafts') { + if (project.visibility !== 'personal' || !createdByCurrentMember) return false; + } + if (view === 'team' && project.visibility !== 'team') return false; + if ((visibility === 'personal' || visibility === 'team') && project.visibility !== visibility) return false; + if (owner === 'mine' && !createdByCurrentMember) return false; + if (owner === 'others' && createdByCurrentMember) return false; + return true; + }); + /** @type {import('@open-design/contracts').WorkspaceProjectsResponse} */ + const body = { projects }; + res.json(body); + } catch (err: any) { + if (err?.name === 'TeamProjectCatalogListError') { + return sendApiError(res, 502, 'TEAM_PROJECT_CATALOG_UNAVAILABLE', err.message); + } + sendApiError(res, 500, 'INTERNAL_ERROR', String(err)); + } + }); + + function validVisibility(value: unknown): value is 'personal' | 'team' { + return value === 'personal' || value === 'team'; + } + function parseProjectIds(value: unknown): string[] | null { + if (!Array.isArray(value) || value.length === 0) return null; + const ids = []; + for (const id of value) { + if (typeof id !== 'string' || !id.trim() || !isSafeId(id)) return null; + ids.push(id); + } + return ids; + } + + function workspaceMoveAllowed(summary: any, targetVisibility: 'personal' | 'team'): boolean { + if (targetVisibility === 'team') return summary.currentUserAccess.canMoveToTeam; + return summary.currentUserAccess.canMoveToPersonal; + } + async function requestTeamVisibility(projectIds: string[], ctx: WorkspaceProjectContext, visibility: 'personal' | 'team') { + for (const projectId of projectIds) { + if (visibility === 'team') { + await collabSync.requestTeamShare(projectId, workspaceProjectPrincipal(ctx)); + } else { + await collabSync.requestTeamUnshare(projectId, workspaceProjectPrincipal(ctx)); + } + } + // The catalog this daemon serves is now stale by construction — drop it so + // the refetch the client fires on this response reads the new list instead + // of the one from before the move. Best-effort: the move itself already + // succeeded, and a cold cache is a slow list, not a failed share. + try { + collabSync.invalidateTeamProjectCatalog?.(); + } catch { + // ignore + } + } + function ownerForTeamShare(summary: any, ctx: WorkspaceProjectContext, visibility: 'personal' | 'team') { + if (visibility !== 'team') return summary?.createdByWorkspaceMemberId ?? null; + return summary?.createdByWorkspaceMemberId ?? ctx.workspaceMemberId; + } + function workspaceProjectMovePatch( + id: string, + summary: any, + ctx: WorkspaceProjectContext, + visibility: 'personal' | 'team', + ) { + return { + visibility, + createdByWorkspaceMemberId: ownerForTeamShare(summary, ctx, visibility), + updatedByWorkspaceMemberId: ctx.workspaceMemberId, + resourceHubResourceId: visibility === 'team' ? projectResourceIdFor(id, workspaceProjectPrincipal(ctx)) : null, + cloudTombstonedAt: visibility === 'team' ? null : Date.now(), + syncState: visibility === 'team' ? 'pending_upload' : 'local_only', + }; + } + function restoreWorkspaceProjectRow(row: any) { + updateWorkspaceProject(db, row.workspaceId, row.id, { + visibility: row.workspaceVisibility, + resourceState: row.resourceState, + createdByWorkspaceMemberId: row.createdByWorkspaceMemberId ?? null, + updatedByWorkspaceMemberId: row.updatedByWorkspaceMemberId ?? null, + resourceHubResourceId: row.resourceHubResourceId ?? null, + cloudTombstonedAt: row.cloudTombstonedAt ?? null, + syncState: row.syncState ?? 'local_only', + version: row.workspaceVersion ?? 1, + updatedAt: row.workspaceUpdatedAt ?? Date.now(), + }); + } + + /** + * True when a team-share request was refused because the hub catalog + * already registers this project under a DIFFERENT member's ownership + * (vela's `team_project_owner_conflict`, re-thrown through the CLI + * transport). The literal is the hub API's stable error token, so matching + * it keeps this mapping independent of how the CLI frames its stderr text. + * The conflict is permanent until the registered owner unshares the + * project, so it must not collapse into the generic BAD_REQUEST bucket the + * web renders as "try again later". + */ + function isTeamProjectOwnerConflictError(error: unknown): boolean { + return /team_project_owner_conflict/i.test(String(error)); + } + + app.post('/api/workspaces/:workspaceId/projects/:projectId/move', async (req, res) => { + try { + const ctx = await authoritativeWorkspaceProjectContext(req, res, req.params.workspaceId); + if (!ctx) return; + const project = getProject(db, req.params.projectId); + const locations = await configuredProjectLocations(); + if (!project || !projectVisibleForLocations(project, locations)) return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found'); + const visibility = req.body?.visibility; + if (!validVisibility(visibility)) { + return sendApiError(res, 400, 'BAD_REQUEST', 'visibility must be personal or team'); + } + if (visibility === 'team') { + const refusal = teamShareRefusalFor(ctx, workspaceTypes); + if (refusal) return sendTeamShareScopeRefused(res, ctx, refusal); + } + // A "move to personal" request on a project this daemon has never + // locally bound must not be judged against a 'personal' default this + // same request is about to invent — see + // `reconcileUnboundProjectBeforeMove`'s doc comment. Scoped to the + // 'personal' direction only: 'team' already matches the fresh default + // and must keep behaving exactly as before. + let orphanRecovery: UnboundProjectMoveReconciliation = 'none'; + if (visibility === 'personal' && ctx.workspaceType === 'team' && !getWorkspaceProjectByProjectId(db, project.id)) { + orphanRecovery = await reconcileUnboundProjectBeforeMove(project.id, ctx); + if (orphanRecovery === 'denied') { + return sendApiError(res, 403, 'PROJECT_DELETE_FORBIDDEN', 'project move forbidden'); + } + } + const wp = ensureWorkspaceProjection(project, ctx, 'personal'); + const row = listWorkspaceProjects(db, ctx.workspaceId).find((item: any) => item.id === project.id); + if (!row || !wp) return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found'); + const summary = normalizeWorkspaceProjectRow(row, ctx); + const privilegedOrphanRecoveryAllowed = + orphanRecovery === 'privileged' + && visibility === 'personal' + && ctx.memberStatus === 'active' + && ctx.lifecycleState === 'active' + && ctx.canShareProjects + && ctx.canWriteSyncedFiles; + if ( + !workspaceMoveAllowed(summary, visibility) + && !privilegedOrphanRecoveryAllowed + ) { + return sendApiError(res, 403, 'PROJECT_DELETE_FORBIDDEN', 'project move forbidden'); + } + updateWorkspaceProject(db, ctx.workspaceId, project.id, workspaceProjectMovePatch(project.id, summary, ctx, visibility)); + try { + await requestTeamVisibility([project.id], ctx, visibility); + } catch (error) { + if (orphanRecovery === 'privileged') { + // This request created the row solely as an ephemeral recovery + // witness. Keeping it after an unshare failure would turn the + // historical orphan into an ordinary creator-owned Team binding + // and permanently block the same owner/admin from retrying. + deleteWorkspaceProject(db, ctx.workspaceId, project.id); + } else { + restoreWorkspaceProjectRow(row); + } + throw error; + } + const updatedRow = listWorkspaceProjects(db, ctx.workspaceId).find((item: any) => item.id === project.id); + res.json({ project: normalizeWorkspaceProjectRow(updatedRow, ctx) }); + } catch (err: any) { + if (isTeamProjectOwnerConflictError(err)) { + return sendApiError(res, 409, 'TEAM_PROJECT_OWNER_CONFLICT', String(err)); + } + sendApiError(res, 400, 'BAD_REQUEST', String(err)); + } + }); + + app.post('/api/workspaces/:workspaceId/projects/batch-move', async (req, res) => { + try { + const ctx = await authoritativeWorkspaceProjectContext(req, res, req.params.workspaceId); + if (!ctx) return; + const visibility = req.body?.visibility; + const projectIds = parseProjectIds(req.body?.projectIds); + if (!validVisibility(visibility) || !projectIds) { + return sendApiError(res, 400, 'BAD_REQUEST', 'projectIds and visibility are required'); + } + if (visibility === 'team') { + const refusal = teamShareRefusalFor(ctx, workspaceTypes); + if (refusal) return sendTeamShareScopeRefused(res, ctx, refusal); + } + const locations = await configuredProjectLocations(); + const rows = workspaceProjectRowsForIds(projectIds, ctx, locations); + const summaries = projectIds.map((id: string) => { + const row = rows.find((item: any) => item.id === id); + return row ? normalizeWorkspaceProjectRow(row, ctx) : null; + }); + if (summaries.some((item: any) => !item)) return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found'); + const forbidden = summaries.filter((item: any) => !workspaceMoveAllowed(item, visibility)); + if (forbidden.length > 0) { + return sendApiError(res, 403, 'PROJECT_BATCH_CONTAINS_FORBIDDEN_ITEMS', 'batch contains forbidden projects'); + } + const previousRows = projectIds.map((id: string) => rows.find((item: any) => item.id === id)); + const moveMany = db.transaction((ids: string[]) => { + for (const id of ids) { + const summary = summaries.find((item: any) => item?.id === id); + updateWorkspaceProject(db, ctx.workspaceId, id, workspaceProjectMovePatch(id, summary, ctx, visibility)); + } + }); + moveMany(projectIds); + try { + await requestTeamVisibility(projectIds, ctx, visibility); + } catch (error) { + const rollbackMany = db.transaction((items: any[]) => { + for (const item of items) restoreWorkspaceProjectRow(item); + }); + rollbackMany(previousRows.filter(Boolean)); + throw error; + } + const updatedRows = listWorkspaceProjects(db, ctx.workspaceId); + const projects = projectIds.map((id: string) => normalizeWorkspaceProjectRow(updatedRows.find((row: any) => row.id === id), ctx)); + res.json({ ok: true, projects }); + } catch (err: any) { + if (isTeamProjectOwnerConflictError(err)) { + return sendApiError(res, 409, 'TEAM_PROJECT_OWNER_CONFLICT', String(err)); + } + sendApiError(res, 400, 'BAD_REQUEST', String(err)); + } + }); + + app.post('/api/workspaces/:workspaceId/projects/batch-delete', async (req, res) => { + try { + const ctx = await authoritativeWorkspaceProjectContext(req, res, req.params.workspaceId); + if (!ctx) return; + const projectIds = parseProjectIds(req.body?.projectIds); + if (!projectIds) return sendApiError(res, 400, 'BAD_REQUEST', 'projectIds are required'); + const locations = await configuredProjectLocations(); + const rows = workspaceProjectRowsForIds(projectIds, ctx, locations); + const summaries = projectIds.map((id: string) => { + const row = rows.find((item: any) => item.id === id); + return row ? normalizeWorkspaceProjectRow(row, ctx) : null; + }); + if (summaries.some((item: any) => !item)) return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found'); + const shared = summaries.filter((item: any) => item.visibility === 'team'); + if (shared.length > 0) { + return sendApiError(res, 403, 'PROJECT_UNSHARE_UNSUPPORTED', 'deleting shared team projects is not supported yet'); + } + const forbidden = summaries.filter((item: any) => !item.currentUserAccess.canDelete); + if (forbidden.length > 0) { + return sendApiError(res, 403, 'PROJECT_BATCH_CONTAINS_FORBIDDEN_ITEMS', 'batch contains forbidden projects'); + } + const finalProjectIds = projectIds.filter((id: string) => countWorkspaceProjectRefs(db, id) <= 1); + const deleteMany = db.transaction((ids: string[], finalIds: string[]) => { + for (const id of ids) deleteWorkspaceProject(db, ctx.workspaceId, id); + for (const id of finalIds) { + if (countWorkspaceProjectRefs(db, id) === 0) dbDeleteProject(db, id); + } + }); + const stagedDelete = finalProjectIds.length > 0 + ? await stageProjectDirsForDelete(PROJECTS_DIR, finalProjectIds, randomId()) + : null; + try { + deleteMany(projectIds, finalProjectIds); + } catch (error) { + await stagedDelete?.rollback(); + throw error; + } + await stagedDelete?.commit(); + res.json({ ok: true, deletedProjectIds: projectIds }); + } catch (err: any) { + sendApiError(res, 400, 'BAD_REQUEST', String(err)); + } + }); + function projectStatusFromRun(run: any) { const normalized = normalizeProjectDisplayStatus(run.status); // A just-finished in-memory run overrides the DB-derived status for its @@ -1552,6 +3146,13 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe app.post('/api/projects', async (req, res) => { try { + const createWorkspace = await authorizeCreatedProjectWorkspace( + req, + ctx.fetchProjectCreationWorkspaceDirectory, + ); + if (!createWorkspace.ok) { + return sendCreatedProjectWorkspaceError(res, createWorkspace); + } const { id, name, projectLocationId, skillId, designSystemId, pendingPrompt, metadata, customInstructions, skipDiscoveryBrief } = req.body || {}; if (typeof id !== 'string' || !isSafeId(id)) { @@ -1611,7 +3212,13 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe if (skipDiscoveryBrief !== undefined && typeof skipDiscoveryBrief !== 'boolean') { return sendApiError(res, 400, 'BAD_REQUEST', 'skipDiscoveryBrief must be a boolean'); } - const designSystemValidation = await validateProjectDesignSystemId(designSystemId); + const creationWorkspaceScope = { + workspaceId: createWorkspace.context?.workspaceId ?? null, + }; + const designSystemValidation = await validateProjectDesignSystemId( + designSystemId, + creationWorkspaceScope, + ); if (!designSystemValidation.ok) { return sendApiError( res, @@ -1621,7 +3228,10 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe ); } const normalizedDesignSystemId = designSystemValidation.id; - const skillValidation = await validateProjectSkillId(skillId); + const skillValidation = await validateProjectSkillId( + skillId, + creationWorkspaceScope, + ); if (!skillValidation.ok) { return sendApiError(res, 400, skillValidation.code, skillValidation.message); } @@ -1691,6 +3301,10 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe } : null; const now = Date.now(); + const cid = randomId(); + const initialSessionMode = normalizeChatSessionMode( + req.body?.conversationMode ?? req.body?.sessionMode, + ); let project; try { if (externalProjectDir) { @@ -1704,39 +3318,48 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe designSystemId: normalizedDesignSystemId, }); } - project = insertProject(db, { - id, - name: name.trim(), - skillId: normalizedSkillId, - designSystemId: normalizedDesignSystemId, - pendingPrompt: pendingPrompt || null, - metadata: projectMetadata, - customInstructions: - typeof customInstructions === 'string' - ? customInstructions - : null, - createdAt: now, - updatedAt: now, - }); + project = db.transaction(() => { + const createdProject = insertProject(db, { + id, + name: name.trim(), + skillId: normalizedSkillId, + designSystemId: normalizedDesignSystemId, + pendingPrompt: pendingPrompt || null, + metadata: projectMetadata, + customInstructions: + typeof customInstructions === 'string' + ? customInstructions + : null, + createdAt: now, + updatedAt: now, + }); + // Project, seed conversation, and workspace membership form one + // ownership record. A binding failure must leave none of them behind. + insertConversation(db, { + id: cid, + projectId: id, + title: null, + sessionMode: initialSessionMode, + createdAt: now, + updatedAt: now, + }); + bindCreatedProjectToWorkspace( + (input) => ensureWorkspaceProject(db, input), + createWorkspace.context, + id, + now, + ); + return createdProject; + })(); } catch (err) { + // External directories cannot participate in SQLite's transaction. + // Treat their creation as a recoverable side effect and compensate on + // any manifest or database transaction failure. if (externalProjectDir) { await rm(externalProjectDir, { recursive: true, force: true }).catch(() => {}); } throw err; } - // Seed a default conversation so the UI always has somewhere to write. - const cid = randomId(); - const initialSessionMode = normalizeChatSessionMode( - req.body?.conversationMode ?? req.body?.sessionMode, - ); - insertConversation(db, { - id: cid, - projectId: id, - title: null, - sessionMode: initialSessionMode, - createdAt: now, - updatedAt: now, - }); const explicitPlugin = typeof req.body?.pluginId === 'string' && req.body.pluginId.trim().length > 0 ? true @@ -1815,8 +3438,16 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe } } /** @type {import('@open-design/contracts').CreateProjectResponse} */ + const createdProject = resolvedSnapshot?.ok ? getProject(db, id) ?? project : project; const body = { - project: resolvedSnapshot?.ok ? getProject(db, id) ?? project : project, + // The binding above is part of the same transaction as the project and + // seed conversation. Return that authority immediately so the Web can + // scope its very first conversation/file reads without waiting for a + // later list/detail round trip. Headerless legacy creates remain + // explicitly unbound and therefore keep the original payload shape. + project: createWorkspace.context + ? { ...createdProject, workspaceId: createWorkspace.context.workspaceId } + : createdProject, conversationId: cid, ...(resolvedSnapshot?.ok ? { appliedPluginSnapshotId: resolvedSnapshot.snapshotId } @@ -1835,6 +3466,25 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe if (!sourceProject || !projectVisibleForLocations(sourceProject, locations)) { return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found'); } + const createHome = await resolveCreatedProjectHome(req); + // recvqbhor3pai2: a project this daemon has never bound anywhere (e.g. + // a copy left unbound by an earlier headerless duplicate — see + // `bindDuplicateIntoRequestWorkspace`'s doc comment) must not be + // permanently un-duplicatable the moment a real, authenticated request + // finally comes in for it. Claim it into the caller's own workspace + // first, exactly like this same route already does for the COPY it is + // about to create. + reconcileUnboundProjectBeforeMutation(req, sourceProject.id, createHome); + if (!await enforceWorkspaceProjectMutation( + req, + res, + sendApiError, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + db, + sourceProject.id, + 'duplicate', + )) return; if (isDesignSystemLikeProject(sourceProject)) { return sendApiError( res, @@ -1889,6 +3539,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe updatedAt: now, }); insertedProject = true; + bindDuplicateIntoRequestWorkspace(createHome, targetProjectId, now); const conversationId = randomId(); insertConversation(db, { id: conversationId, @@ -1907,7 +3558,9 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe } /** @type {import('@open-design/contracts').DuplicateProjectResponse} */ const body = { - project, + project: createHome + ? { ...project, workspaceId: createHome.workspaceId } + : project, conversationId, copiedFiles, }; @@ -1918,6 +3571,15 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe throw err; } } catch (err: any) { + if (err instanceof CreatedProjectWorkspaceResolutionError) { + return sendApiError( + res, + err.status, + err.code, + err.message, + err.retryable ? { retryable: true } : {}, + ); + } sendApiError(res, 400, 'BAD_REQUEST', String(err)); } }); @@ -1929,6 +3591,21 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe if (!sourceProject || !projectVisibleForLocations(sourceProject, locations)) { return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found'); } + const createHome = await resolveCreatedProjectHome(req); + // recvqbhor3pai2 — same reasoning as the sibling /duplicate route just + // above: a never-bound source project must not be permanently + // un-copyable once a real, authenticated request finally names it. + reconcileUnboundProjectBeforeMutation(req, sourceProject.id, createHome); + if (!await enforceWorkspaceProjectMutation( + req, + res, + sendApiError, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + db, + sourceProject.id, + 'duplicate', + )) return; if (isDesignSystemLikeProject(sourceProject)) { return sendApiError( res, @@ -1945,7 +3622,13 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe let createdDesignSystemId: string | null = null; let insertedProject = false; try { - const designSystem = await createUserDesignSystem(USER_DESIGN_SYSTEMS_DIR, { + const createDesignSystem = ctx.createWorkspaceOwnedDesignSystem + ?? ((root: string, input: UserDesignSystemInput, context: WorkspaceResourceContext | null) => + createUserDesignSystem(root, { + ...input, + ...(context ? { workspaceId: context.workspaceId } : {}), + })); + const designSystem = await createDesignSystem(USER_DESIGN_SYSTEMS_DIR, { title: targetName, summary: sourceNotes, category: 'Project Design System', @@ -1957,7 +3640,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe notes: sourceNotes, sourceNotes, }, - }); + }, createHome); createdDesignSystemId = designSystem.id; const metadata = { @@ -2016,6 +3699,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe updatedAt: now, }); insertedProject = true; + bindDuplicateIntoRequestWorkspace(createHome, targetProjectId, now); const conversationId = randomId(); insertConversation(db, { id: conversationId, @@ -2046,7 +3730,9 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe await linkUserDesignSystemProject(USER_DESIGN_SYSTEMS_DIR, designSystem.id, targetProjectId); /** @type {import('@open-design/contracts').CreateDesignSystemProjectFromProjectResponse} */ const body = { - project, + project: createHome + ? { ...project, workspaceId: createHome.workspaceId } + : project, conversationId, designSystemId: designSystem.id, copiedFiles, @@ -2061,6 +3747,15 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe throw err; } } catch (err: any) { + if (err instanceof CreatedProjectWorkspaceResolutionError) { + return sendApiError( + res, + err.status, + err.code, + err.message, + err.retryable ? { retryable: true } : {}, + ); + } sendApiError(res, 400, 'BAD_REQUEST', String(err)); } }); @@ -2070,6 +3765,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe const locations = await configuredProjectLocations(); if (!project || !projectVisibleForLocations(project, locations)) return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found'); + if (!await authorizeProjectRequest(req, res, project.id, { mode: 'read' })) return; // When a caller is about to *reference* this project (add it as read-only // context for another run), materialize its managed folder first so the // reference resolves to a real directory. See ensureReferencedProjectDir. @@ -2086,14 +3782,96 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe } } const resolvedDir = projectDetailResolvedDir(PROJECTS_DIR, project, resolveProjectDir); + const binding = getWorkspaceProjectByProjectId(db, project.id); /** @type {import('@open-design/contracts').ProjectResponse} */ - const body = { project, resolvedDir }; + const body = { + project: { + ...project, + workspaceId: + typeof binding?.workspaceId === 'string' && binding.workspaceId.trim() + ? binding.workspaceId.trim() + : null, + }, + resolvedDir, + }; + res.json(body); + }); + + app.get('/api/projects/:id/workspace-scope', async (req, res) => { + const project = getProject(db, req.params.id); + const locations = await configuredProjectLocations(); + if (!project || !projectVisibleForLocations(project, locations)) { + return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found'); + } + const binding = getWorkspaceProjectByProjectId(db, project.id); + const hasWorkspaceClaim = + headerValue(req, 'x-od-workspace-id') !== null + || headerValue(req, 'x-od-workspace-member-id') !== null; + if (binding && !hasWorkspaceClaim) { + // This is the same session-generation keyed authority broker used by the + // shell directory and ordinary read gate. A cold shell + bootstrap joins + // one upstream read; its short successful lease is exact-account scoped, + // while failures are not cached. Never consult current/default Workspace. + const directory = ctx.fetchWorkspaceDirectory + ? await ctx.fetchWorkspaceDirectory().catch( + (): WorkspaceDirectoryFetchResult => ({ ok: false, items: [] }), + ) + : { ok: false, items: [] }; + const bootstrap = resolveProjectWorkspaceScopeBootstrap({ + projectId: project.id, + binding, + directory, + }); + if (!bootstrap.ok) { + return sendApiError( + res, + bootstrap.status, + bootstrap.code, + bootstrap.message, + bootstrap.status === 503 ? { retryable: true } : {}, + ); + } + /** @type {import('@open-design/contracts').ProjectWorkspaceScopeResponse} */ + const body = { scope: bootstrap.scope }; + return res.json(body); + } + if (!await authorizeProjectRequest(req, res, project.id, { mode: 'read' })) return; + const directory = ctx.fetchWorkspaceDirectory + ? await ctx.fetchWorkspaceDirectory().catch( + (): WorkspaceDirectoryFetchResult => ({ ok: false, items: [] }), + ) + : { ok: false, items: [] }; + // Persisted binding is the resource identity. The authorization gate above + // freshly verifies the exact caller pair for a bound project; a genuinely + // unbound legacy project remains unbound even when a caller supplies an + // unrelated Workspace identity. + const scope = resolveProjectWorkspaceScope({ + projectId: project.id, + binding, + directory, + }); + /** @type {import('@open-design/contracts').ProjectWorkspaceScopeResponse} */ + const body = { scope }; res.json(body); }); app.patch('/api/projects/:id', async (req, res) => { try { const patch = req.body || {}; + const patchProject = getProject(db, req.params.id); + if (!patchProject) { + return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found'); + } + if (!await enforceWorkspaceProjectMutation( + req, + res, + sendApiError, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + db, + patchProject.id, + 'rename', + )) return; // baseDir / folder-import state is privileged: it's set only by the // import endpoint and otherwise immutable. Two failure modes to // guard against here: @@ -2215,7 +3993,12 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe return sendApiError(res, 400, 'BAD_REQUEST', 'customInstructions exceeds 5 000 character limit'); } if (Object.prototype.hasOwnProperty.call(patch, 'designSystemId')) { - const designSystemValidation = await validateProjectDesignSystemId(patch.designSystemId); + const projectWorkspaceId = + getWorkspaceProjectByProjectId(db, req.params.id)?.workspaceId ?? null; + const designSystemValidation = await validateProjectDesignSystemId( + patch.designSystemId, + { workspaceId: projectWorkspaceId }, + ); if (!designSystemValidation.ok) { return sendApiError( res, @@ -2227,7 +4010,12 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe patch.designSystemId = designSystemValidation.id; } if (Object.prototype.hasOwnProperty.call(patch, 'skillId')) { - const skillValidation = await validateProjectSkillId(patch.skillId); + const projectWorkspaceId = + getWorkspaceProjectByProjectId(db, req.params.id)?.workspaceId ?? null; + const skillValidation = await validateProjectSkillId( + patch.skillId, + { workspaceId: projectWorkspaceId }, + ); if (!skillValidation.ok) { return sendApiError(res, 400, skillValidation.code, skillValidation.message); } @@ -2261,6 +4049,12 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe const project = updateProject(db, req.params.id, patch); if (!project) return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found'); + if (typeof patch.name === 'string' && patch.name.trim().length > 0) { + // Write the rename through to the team catalog. Metadata-only changes + // never trigger a content publish, so without this a rename only + // reached teammates after the NEXT file edit — or never. + ctx.collabSync.refreshTeamProjectMetadata(req.params.id); + } /** @type {import('@open-design/contracts').ProjectResponse} */ const body = { project }; res.json(body); @@ -2271,6 +4065,51 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe app.delete('/api/projects/:id', async (req, res) => { try { + const project = getProject(db, req.params.id); + if (!project) { + return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found'); + } + if (!await enforceWorkspaceProjectMutation( + req, + res, + sendApiError, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + db, + project.id, + 'delete', + )) return; + // spec 04 §11: a team-visible project must be unshared from the hub + // BEFORE it disappears locally — mirrors the 'personal' branch of + // /move's `requestTeamVisibility`, the one other place this daemon + // already knows how to take a project out of the team space. Without + // this, `dbDeleteProject` only ever touches THIS caller's own + // `workspace_projects` row: the hub's published resource (and every + // OTHER member's already-bound local row) never learns the project is + // gone, so teammates keep seeing it. `enforceWorkspaceProjectMutation` + // just above already proved the caller may mutate this exact row, so + // no separate `canShareProjects` gate is layered on top here — the + // whole project is about to stop existing regardless. + const workspaceRow = getWorkspaceProjectByProjectId(db, project.id); + if (workspaceRow?.visibility === 'team') { + // Same context the gate above allowed this delete under — NOT a fresh + // header read, which is null for a headerless caller and would skip the + // hub work while still deleting locally. + const teamCtx = await verifiedWorkspaceProjectContext(req); + if (!teamCtx) { + // Unreachable while the gate is intact: it admits a team-bound row only + // for an explicit authoritative identity. Refuse rather than + // fall through, so a future gate change cannot quietly reintroduce a + // local-only delete of a still-shared project. + return sendApiError( + res, + 401, + 'WORKSPACE_CONTEXT_REQUIRED', + 'workspace context is required to unshare this project before deleting it', + ); + } + await requestTeamVisibility([project.id], teamCtx, 'personal'); + } // Stop any live agent run in this project before its row and directory // are removed, otherwise the CLI subprocess is orphaned — it keeps // billing and writes into a directory that no longer exists (#5468). @@ -2292,10 +4131,16 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe // Subscribers come and go as users open/close project tabs; the underlying // chokidar watcher is refcounted in project-watchers.ts so we never hold // descriptors for projects no UI is looking at. - app.get('/api/projects/:id/events', (req, res) => { + app.get('/api/projects/:id/events', async (req, res) => { if (!getProject(db, req.params.id)) { return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found'); } + if (!await authorizeProjectRequest( + req, + res, + req.params.id, + { mode: 'read', allowNavigationQuery: true }, + )) return; let sub: any; try { const sse = createSseResponse(res); @@ -2331,21 +4176,41 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe } }); - registerProjectConversationRoutes(app, ctx); + // Comments have no workspace binding of their own — thread down the SAME + // authoritative `enforceWorkspaceProjectMutation` instance so a comment's + // gate matches its parent project's exactly, instead of comments quietly + // shipping a second, weaker copy. + registerProjectConversationRoutes(app, { + ...ctx, + enforceWorkspaceProjectMutation, + authorizeProjectRequest, + sendApiError, + }); // ---- Tabs ----------------------------------------------------------------- - app.get('/api/projects/:id/tabs', (req, res) => { + app.get('/api/projects/:id/tabs', async (req, res) => { if (!getProject(db, req.params.id)) { return res.status(404).json({ error: 'project not found' }); } + if (!await authorizeProjectRequest(req, res, req.params.id, { mode: 'read' })) return; res.json(listTabs(db, req.params.id)); }); - app.put('/api/projects/:id/tabs', (req, res) => { + app.put('/api/projects/:id/tabs', async (req, res) => { if (!getProject(db, req.params.id)) { return res.status(404).json({ error: 'project not found' }); } + if (!await enforceWorkspaceProjectMutation( + req, + res, + sendApiError, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + db, + req.params.id, + 'writeFiles', + )) return; const { tabs = [], active = null, browserTabs = [] } = req.body || {}; if (!Array.isArray(tabs) || !tabs.every((t) => typeof t === 'string')) { return res.status(400).json({ error: 'tabs must be string[]' }); @@ -2518,15 +4383,41 @@ export function registerProjectArtifactRoutes(app: Express, ctx: RegisterProject } -export interface RegisterProjectFileRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'uploads' | 'node' | 'projectStore' | 'projectFiles' | 'documents' | 'artifacts' | 'projectPreviewScopes'> {} +export interface RegisterProjectFileRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'uploads' | 'node' | 'projectStore' | 'projectFiles' | 'documents' | 'artifacts' | 'projectPreviewScopes'> { + verifyWorkspaceRequestAuthority?: VerifyWorkspaceRequestAuthority; + authorizeProjectRequest?: AuthorizeProjectRequest; + /** Startup-hydrated O(1) quarantine lookup for stale Team mirrors. */ + isProjectRevoked?: (projectId: string) => boolean; +} export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFileRoutesDeps) { const { db } = ctx; const { sendApiError, sendMulterError } = ctx.http; - const { PROJECTS_DIR } = ctx.paths; + // The design-token suggestion route reads the design-system roots to resolve + // a project's tokens, so this scope needs them alongside PROJECTS_DIR. + const { PROJECTS_DIR, DESIGN_SYSTEMS_DIR, USER_DESIGN_SYSTEMS_DIR } = ctx.paths; const { upload } = ctx.uploads; const { fs } = ctx.node; - const { getProject } = ctx.projectStore; + const { getProject, getWorkspaceProject, getWorkspaceProjectByProjectId } = ctx.projectStore; + const enforceWorkspaceProjectMutation = createEnforceWorkspaceProjectMutation( + ctx.verifyWorkspaceRequestAuthority, + ); + const authorizeProjectRequest = + ctx.authorizeProjectRequest ?? + createAuthorizeProjectRequest({ + db, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + isProjectRevoked: (_db, projectId) => + ctx.isProjectRevoked?.(projectId) ?? false, + ...(ctx.verifyWorkspaceRequestAuthority + ? { verifyWorkspaceRequestAuthority: ctx.verifyWorkspaceRequestAuthority } + : {}), + sendApiError, + }); + const requestCanWriteWorkspaceProject = createWorkspaceProjectWriteAuthorityCheck( + ctx.verifyWorkspaceRequestAuthority, + ); const { listFiles, listProjectFolders, createProjectFolder, deleteProjectFolder, searchProjectFiles, readProjectFile, resolveProjectDir, resolveProjectFilePath, parseByteRange, renameProjectFile, deleteProjectFile, writeProjectFile, sanitizeName, sanitizePath, ensureProject } = ctx.projectFiles; const { buildDocumentPreview } = ctx.documents; const { validateArtifactManifestInput } = ctx.artifacts; @@ -3012,6 +4903,83 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile return filePath.split('/').map((segment) => encodeURIComponent(segment)).join('/'); } + function rewriteWorkspaceScopedHtmlAssetUrls( + html: string, + projectId: string, + ownerFilePath: string, + workspaceId: string, + workspaceMemberId: string, + ): string { + const assetAttr = /(\s)(src|poster|data-src)(\s*=\s*)(["'])([^"']*)\4/gi; + const linkTag = /<link\b[^>]*>/gi; + const linkHref = /(\shref\s*=\s*)(["'])([^"']*)\2/i; + const srcsetAttr = /(\ssrcset\s*=\s*)(["'])([^"']*)\2/gi; + const cssUrl = /url\(\s*(['"]?)([^'")]+)\1\s*\)/gi; + const ownerDir = path.posix.dirname(ownerFilePath); + const scopeQuery = `workspaceId=${encodeURIComponent(workspaceId)}` + + `&workspaceMemberId=${encodeURIComponent(workspaceMemberId)}`; + + const rewrite = (ref: string): string => { + const trimmed = ref.trim(); + if (!trimmed || /^(?:[a-z][a-z0-9+.-]*:|\/|#)/i.test(trimmed)) return ref; + const match = trimmed.match(/^([^?#]*)([?#][\s\S]*)?$/); + const rawPath = match?.[1] ?? trimmed; + const suffix = match?.[2] ?? ''; + let decodedPath = rawPath; + try { + decodedPath = decodeURIComponent(rawPath); + } catch { + return ref; + } + const resolved = path.posix.normalize(path.posix.join(ownerDir, decodedPath)); + if (!resolved || resolved === '..' || resolved.startsWith('../') || path.posix.isAbsolute(resolved)) { + return ref; + } + const scoped = `/api/projects/${encodeURIComponent(projectId)}/raw/` + + `${encodeProjectPathForUrl(resolved)}?${scopeQuery}`; + if (!suffix) return scoped; + if (suffix.startsWith('#')) return `${scoped}${suffix}`; + return `${scoped}&${suffix.slice(1)}`; + }; + + let next = html.replace( + assetAttr, + (match, space: string, name: string, eq: string, quote: string, value: string) => { + const rewritten = rewrite(value); + return rewritten === value ? match : `${space}${name}${eq}${quote}${rewritten}${quote}`; + }, + ); + next = next.replace(linkTag, (tag) => + tag.replace(linkHref, (match, prefix: string, quote: string, value: string) => { + const rewritten = rewrite(value); + return rewritten === value ? match : `${prefix}${quote}${rewritten}${quote}`; + }), + ); + next = next.replace(srcsetAttr, (match, prefix: string, quote: string, value: string) => { + // A data URL contains an unescaped comma, so the lightweight candidate + // splitter below cannot safely rewrite a mixed data-URL srcset. Leave the + // whole attribute untouched rather than corrupting embedded bytes. + if (/(?:^|,\s*)data:/i.test(value)) return match; + const rewritten = value + .split(',') + .map((candidate) => { + const body = candidate.trim(); + if (!body) return candidate; + const [url = '', ...descriptors] = body.split(/\s+/); + const rewrittenUrl = rewrite(url); + if (rewrittenUrl === url) return candidate; + const leading = candidate.match(/^\s*/)?.[0] ?? ''; + return `${leading}${[rewrittenUrl, ...descriptors].join(' ')}`; + }) + .join(','); + return rewritten === value ? match : `${prefix}${quote}${rewritten}${quote}`; + }); + return next.replace(cssUrl, (match, quote: string, value: string) => { + const rewritten = rewrite(value); + return rewritten === value ? match : `url(${quote}${rewritten}${quote})`; + }); + } + async function maybeResolveVitePreviewHtml({ file, projectId, @@ -3060,6 +5028,13 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile try { const since = Number(req.query?.since); const project = getProject(db, req.params.id); + if (!project) { + return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + } + if (!await authorizeProjectRequest(req, res, project.id, { mode: 'read' })) return; + if (project?.metadata?.teamMirrorRevokedAt) { + return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found'); + } const files = await listFiles(PROJECTS_DIR, req.params.id, { since: Number.isFinite(since) ? since : undefined, metadata: project?.metadata, @@ -3074,6 +5049,11 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile app.get('/api/projects/:id/search', async (req, res) => { try { + const searchProject = getProject(db, req.params.id); + if (!searchProject) { + return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + } + if (!await authorizeProjectRequest(req, res, searchProject.id, { mode: 'read' })) return; const query = String(req.query.q ?? ''); if (!query) { sendApiError(res, 400, 'BAD_REQUEST', 'q query parameter is required'); @@ -3081,7 +5061,6 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile } const pattern = req.query.pattern ? String(req.query.pattern) : null; const max = Math.min(Number(req.query.max) || 200, 1000); - const searchProject = getProject(db, req.params.id); const matches = await searchProjectFiles(PROJECTS_DIR, req.params.id, query, { pattern, max, @@ -3093,12 +5072,75 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile } }); + // Design-token reference values for the manual-edit panel: given the style + // values currently on the selected element, surface the project's own tokens + // that are near-matches, so an edit can snap back onto the design system + // instead of drifting into one-off literals. + app.get('/api/projects/:id/design-token-suggestions', async (req, res) => { + try { + const project = getProject(db, req.params.id); + if (!project) { + sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + return; + } + if (!await authorizeProjectRequest(req, res, project.id, { mode: 'read' })) return; + const allowedProps = new Set([ + 'color', + 'backgroundColor', + 'borderColor', + 'fontFamily', + 'fontSize', + 'fontWeight', + 'lineHeight', + 'letterSpacing', + 'width', + 'height', + 'gap', + 'padding', + 'margin', + 'borderRadius', + 'borderWidth', + ]); + const props = String(req.query.props ?? '') + .split(',') + .map((item) => item.trim()) + .filter((item): item is ProjectDesignTokenSuggestionProp => allowedProps.has(item)); + const values: Partial<Record<ProjectDesignTokenSuggestionProp, string>> = {}; + for (const [key, raw] of Object.entries(req.query)) { + if (!key.startsWith('value_')) continue; + const prop = key.slice('value_'.length); + if (!allowedProps.has(prop)) continue; + const value = Array.isArray(raw) ? raw[0] : raw; + if (typeof value === 'string' && value.trim()) values[prop as ProjectDesignTokenSuggestionProp] = value.trim(); + } + const query: ProjectDesignTokenSuggestionQuery = { values }; + if (typeof req.query.file === 'string') query.file = req.query.file; + if (typeof req.query.targetId === 'string') query.targetId = req.query.targetId; + if (props.length > 0) query.props = props; + const body = await buildProjectDesignTokenSuggestions({ + projectId: req.params.id, + project, + projectMetadata: project.metadata, + projectsRoot: PROJECTS_DIR, + designSystemsRoot: DESIGN_SYSTEMS_DIR, + userDesignSystemsRoot: USER_DESIGN_SYSTEMS_DIR, + listFiles, + resolveProjectDir, + query, + }); + res.json(body); + } catch (err: any) { + sendApiError(res, 400, 'BAD_REQUEST', String(err?.message || err)); + } + }); + app.get('/api/projects/:id/folders', async (req, res) => { try { const project = getProject(db, req.params.id); if (!project) { return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); } + if (!await authorizeProjectRequest(req, res, project.id, { mode: 'read' })) return; const folders = await listProjectFolders(PROJECTS_DIR, req.params.id, { metadata: project.metadata, }); @@ -3120,6 +5162,16 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile if (!project) { return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); } + if (!await enforceWorkspaceProjectMutation( + req, + res, + sendApiError, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + db, + project.id, + 'writeFiles', + )) return; const folder = await createProjectFolder( PROJECTS_DIR, req.params.id, @@ -3144,6 +5196,16 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile if (!project) { return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); } + if (!await enforceWorkspaceProjectMutation( + req, + res, + sendApiError, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + db, + project.id, + 'writeFiles', + )) return; await deleteProjectFolder( PROJECTS_DIR, req.params.id, @@ -3165,6 +5227,7 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); return; } + if (!await authorizeProjectRequest(req, res, project.id, { mode: 'read' })) return; const projectRoot = resolveProjectDir(PROJECTS_DIR, project.id, project.metadata); const audit = await auditDesignSystemPackage(projectRoot); res.setHeader('Cache-Control', 'no-store'); @@ -3181,6 +5244,7 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); return; } + if (!await authorizeProjectRequest(req, res, project.id, { mode: 'read' })) return; const requestedPath = previewFilePathForProject(project, req.query.file); const meta = await resolveProjectFilePath( PROJECTS_DIR, @@ -3188,7 +5252,16 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile requestedPath, project.metadata, ); - const scope = projectPreviewScopes.mint(project.id); + const requestContext = workspaceProjectContextFromRequest(req); + const scope = projectPreviewScopes.mint( + project.id, + requestContext === null || requestContext === 'missing' + ? null + : { + workspaceId: requestContext.workspaceId, + workspaceMemberId: requestContext.workspaceMemberId, + }, + ); /** @type {import('@open-design/contracts').ProjectPreviewUrlResponse} */ const body = { url: `/api/projects/${encodeURIComponent(project.id)}/preview/${scope}/${encodeProjectPathForUrl(meta.name)}`, @@ -3223,6 +5296,15 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile Math.min(Number.isFinite(requestedLimit) ? Math.floor(requestedLimit) : 96 * 1024, 512 * 1024), ); const project = getProject(db, projectId); + if (!project) { + return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + } + if (!await authorizeProjectRequest( + req, + res, + projectId, + { mode: 'read', allowNavigationQuery: true }, + )) return; const meta = await resolveProjectFilePath( PROJECTS_DIR, projectId, @@ -3277,10 +5359,27 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); return; } - if (!projectPreviewScopes.validate(project.id, scope)) { + const previewWorkspace = projectPreviewScopes.resolve(project.id, scope); + if (previewWorkspace === undefined) { sendApiError(res, 404, 'PREVIEW_SCOPE_NOT_FOUND', 'preview scope not found'); return; } + const authorityRequest = previewWorkspace + ? { + query: { + ...req.query, + workspaceId: previewWorkspace.workspaceId, + workspaceMemberId: previewWorkspace.workspaceMemberId, + }, + get: req.get.bind(req), + } + : req; + if (!await authorizeProjectRequest( + authorityRequest, + res, + projectId, + { mode: 'read', allowNavigationQuery: true }, + )) return; if (req.headers.origin === 'null') { res.header('Access-Control-Allow-Origin', '*'); } @@ -3331,6 +5430,18 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile const relPath = String(params[1] ?? ''); if (rejectInternalVersionPath(res, relPath)) return; const project = getProject(db, projectId); + if (!project) { + return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + } + if (!await authorizeProjectRequest( + req, + res, + projectId, + { mode: 'read', allowNavigationQuery: true }, + )) return; + if (project?.metadata?.teamMirrorRevokedAt) { + return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found'); + } // PreviewModal loads artifact HTML via srcdoc, giving the iframe Origin: "null". // data: URIs, file://, and some sandboxed iframes also send null — all are // local-only callers, so this is safe. Real cross-origin sites send a real @@ -3363,7 +5474,27 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile projectsRoot: PROJECTS_DIR, readProjectFile, }); - return applyUrlPreviewBridgesToHtml(transformed, file.mime, req.query.odPreviewBridge); + const bridged = applyUrlPreviewBridgesToHtml( + transformed, + file.mime, + req.query.odPreviewBridge, + ); + const workspaceId = typeof req.query.workspaceId === 'string' + ? req.query.workspaceId + : null; + const workspaceMemberId = typeof req.query.workspaceMemberId === 'string' + ? req.query.workspaceMemberId + : null; + if (!workspaceId || !workspaceMemberId || !/^text\/html(?:;|$)/i.test(file.mime)) { + return bridged; + } + return rewriteWorkspaceScopedHtmlAssetUrls( + Buffer.isBuffer(bridged) ? bridged.toString('utf8') : String(bridged), + projectId, + relPath, + workspaceId, + workspaceMemberId, + ); }, true, // revalidate: emit ETag/Last-Modified so covers/preview/export reuse cached assets ); @@ -3398,6 +5529,15 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile const relPath = String(params[1] ?? ''); if (rejectInternalVersionPath(res, relPath)) return; const project = getProject(db, projectId); + if (!project) { + return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + } + if (!await authorizeProjectRequest( + req, + res, + projectId, + { mode: 'read', allowNavigationQuery: true }, + )) return; const meta = await resolveProjectFilePath( PROJECTS_DIR, projectId, @@ -3443,6 +5583,19 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile const rawSplat = String(params[1] ?? ''); if (rejectInternalVersionPath(res, rawSplat)) return; const project = getProject(db, projectId); + if (!project) { + return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + } + if (!await enforceWorkspaceProjectMutation( + req, + res, + sendApiError, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + db, + project.id, + 'writeFiles', + )) return; await deleteProjectFile(PROJECTS_DIR, projectId, rawSplat, project?.metadata); await markProjectFileVersionStoreDeleted(PROJECTS_DIR, projectId, rawSplat, project?.metadata); /** @type {import('@open-design/contracts').DeleteProjectFileResponse} */ @@ -3462,6 +5615,15 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile app.get('/api/projects/:id/files/:name/preview', async (req, res) => { try { const project = getProject(db, req.params.id); + if (!project) { + return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + } + if (!await authorizeProjectRequest( + req, + res, + project.id, + { mode: 'read', allowNavigationQuery: true }, + )) return; const file = await readProjectFile( PROJECTS_DIR, req.params.id, @@ -3496,6 +5658,7 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile if (!project) { return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); } + if (!await authorizeProjectRequest(req, res, project.id, { mode: 'read' })) return; if (!/\.html?$/i.test(fileName)) { return sendApiError(res, 400, 'BAD_REQUEST', 'versions are only available for HTML files'); } @@ -3514,7 +5677,22 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile if (err?.code !== 'ENOENT') throw err; } let versions = await listProjectFileVersions(PROJECTS_DIR, project.id, historyFileName, project.metadata); - if (workingFileContent !== null && versions.length === 0) { + // Bootstrapping a baseline version is a WRITE, so it belongs only to a + // caller with write authority over this project. A readonly member + // reading a mirror of someone else's shared project gets the truthful + // empty history instead — the owner's real history can never be here + // (`.file-versions` is excluded from member mirrors), so synthesizing + // one would only manufacture history that never existed, inside a + // project the member is told they cannot modify. The read itself is + // never refused: browsing history stays open (飞书 recvq56vFjQKfT). + if (workingFileContent !== null && versions.length === 0 + && await requestCanWriteWorkspaceProject( + req, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + db, + project.id, + )) { const initial = await ensureCurrentProjectFileVersion( PROJECTS_DIR, project.id, @@ -3556,6 +5734,16 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile if (!project) { return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); } + if (!await enforceWorkspaceProjectMutation( + req, + res, + sendApiError, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + db, + project.id, + 'writeFiles', + )) return; const requestedFile = await readProjectFile(PROJECTS_DIR, project.id, fileName, project.metadata); if (!/\.html?$/i.test(requestedFile.name)) { return sendApiError(res, 400, 'BAD_REQUEST', 'versions are only available for HTML files'); @@ -3642,6 +5830,16 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile if (!project) { return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); } + if (!await enforceWorkspaceProjectMutation( + req, + res, + sendApiError, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + db, + project.id, + 'writeFiles', + )) return; const restored = await readProjectFileVersion( PROJECTS_DIR, project.id, @@ -3709,6 +5907,7 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile if (!project) { return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); } + if (!await authorizeProjectRequest(req, res, project.id, { mode: 'read' })) return; const body = await readProjectFileVersion( PROJECTS_DIR, project.id, @@ -3738,6 +5937,18 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile const fileSplat = String(params[1] ?? ''); if (rejectInternalVersionPath(res, fileSplat)) return; const project = getProject(db, projectId); + if (!project) { + return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + } + if (!await authorizeProjectRequest( + req, + res, + project.id, + { mode: 'read', allowNavigationQuery: true }, + )) return; + if (project?.metadata?.teamMirrorRevokedAt) { + return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found'); + } const file = await readProjectFile( PROJECTS_DIR, projectId, @@ -3770,6 +5981,26 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile async (req, res) => { try { const uploadProject = getProject(db, req.params.id); + const cleanupRejectedUpload = () => { + if (req.file?.path) fs.promises.unlink(req.file.path).catch(() => {}); + }; + if (!uploadProject && workspaceProjectContextFromRequest(req) !== null) { + cleanupRejectedUpload(); + return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + } + if (!await enforceWorkspaceProjectMutation( + req, + res, + sendApiError, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + db, + req.params.id, + 'writeFiles', + )) { + cleanupRejectedUpload(); + return; + } await ensureProject(PROJECTS_DIR, req.params.id, uploadProject?.metadata); if (req.file) { try { @@ -4000,6 +6231,19 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile } if (rejectInternalVersionPath(res, from) || rejectInternalVersionPath(res, to)) return; const project = getProject(db, req.params.id); + if (!project) { + return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + } + if (!await enforceWorkspaceProjectMutation( + req, + res, + sendApiError, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + db, + project.id, + 'writeFiles', + )) return; const result = await renameProjectFile( PROJECTS_DIR, req.params.id, @@ -4033,6 +6277,19 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile try { if (rejectInternalVersionPath(res, req.params.name)) return; const delProject = getProject(db, req.params.id); + if (!delProject) { + return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + } + if (!await enforceWorkspaceProjectMutation( + req, + res, + sendApiError, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + db, + delProject.id, + 'writeFiles', + )) return; await deleteProjectFile(PROJECTS_DIR, req.params.id, req.params.name, delProject?.metadata); await markProjectFileVersionStoreDeleted(PROJECTS_DIR, req.params.id, req.params.name, delProject?.metadata); /** @type {import('@open-design/contracts').DeleteProjectFileResponse} */ @@ -4051,16 +6308,21 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile } -export interface RegisterProjectUploadRoutesDeps extends RouteDeps<'db' | 'http' | 'uploads' | 'node' | 'paths' | 'projectStore' | 'projectFiles'> {} +export interface RegisterProjectUploadRoutesDeps extends RouteDeps<'db' | 'http' | 'uploads' | 'node' | 'paths' | 'projectStore' | 'projectFiles'> { + verifyWorkspaceRequestAuthority?: VerifyWorkspaceRequestAuthority; +} export function registerProjectUploadRoutes(app: Express, ctx: RegisterProjectUploadRoutesDeps) { const { db } = ctx; const { sendApiError } = ctx.http; const { handleProjectUpload } = ctx.uploads; const { PROJECTS_DIR } = ctx.paths; - const { getProject } = ctx.projectStore; + const { getProject, getWorkspaceProject, getWorkspaceProjectByProjectId } = ctx.projectStore; const { readProjectFile } = ctx.projectFiles; const { fs } = ctx.node; + const enforceWorkspaceProjectMutation = createEnforceWorkspaceProjectMutation( + ctx.verifyWorkspaceRequestAuthority, + ); app.post( '/api/projects/:id/upload', @@ -4068,6 +6330,24 @@ export function registerProjectUploadRoutes(app: Express, ctx: RegisterProjectUp async (req, res) => { try { const incoming = Array.isArray(req.files) ? req.files : []; + const cleanupRejectedUpload = () => { + for (const f of incoming) { + if (f?.path) fs.promises.unlink(f.path).catch(() => {}); + } + }; + if (!await enforceWorkspaceProjectMutation( + req, + res, + sendApiError, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + db, + req.params.id, + 'writeFiles', + )) { + cleanupRejectedUpload(); + return; + } // Subfolder the upload targeted (sanitized, forward-slash, '' for root), // stashed by the multer destination resolver. Prepend it so callers // get the file's true project-relative path, not just its basename. diff --git a/apps/daemon/src/routes/routine.ts b/apps/daemon/src/routes/routine.ts index 41a781c8c06..f4e66bb8ec6 100644 --- a/apps/daemon/src/routes/routine.ts +++ b/apps/daemon/src/routes/routine.ts @@ -10,6 +10,7 @@ import { getProject, getRoutine, getRoutineRun, + getWorkspaceProjectByProjectId, insertRoutine, listRoutineRuns, listRoutines, @@ -21,10 +22,18 @@ import { validateTarget as validateRoutineTarget, type RoutineService, } from '../routines.js'; +import { + AutomationWorkspaceScopeError, + authorizePersistedAutomationWorkspaceScope, + authorizePersistedProjectWorkspace, + normalizePersistedAutomationWorkspaceScope, +} from '../automations/workspace-scope.js'; +import type { WorkspaceDirectoryFetchResult } from '../collab/vela-workspace-context.js'; import type { PathDeps, RouteDeps } from '../server-context.js'; export interface RegisterRoutineRoutesDeps extends RouteDeps<'db' | 'routines'> { paths: Pick<PathDeps, 'RUNTIME_DATA_DIR'>; + fetchWorkspaceDirectory?: () => Promise<WorkspaceDirectoryFetchResult>; } export type RoutineRoutesService = Pick< @@ -53,14 +62,28 @@ function normalizeRoutineContext(value: unknown) { throw new Error('context must be an object'); } const input = value as Record<string, unknown>; + const hasWorkspaceScope = Object.hasOwn(input, 'workspaceScope'); + const workspaceScope = hasWorkspaceScope + ? normalizePersistedAutomationWorkspaceScope(input.workspaceScope) + : null; + if (hasWorkspaceScope && input.workspaceScope !== null && !workspaceScope) { + throw new Error( + 'context.workspaceScope must contain workspaceId and workspaceMemberId', + ); + } const context = { skillIds: cleanStringList(input.skillIds, 'context.skillIds'), pluginIds: cleanStringList(input.pluginIds, 'context.pluginIds'), mcpServerIds: cleanStringList(input.mcpServerIds, 'context.mcpServerIds'), connectorIds: cleanStringList(input.connectorIds, 'context.connectorIds'), + ...(hasWorkspaceScope + ? { workspaceScope } + : {}), }; return Object.fromEntries( - Object.entries(context).filter(([, ids]) => ids.length > 0), + Object.entries(context).filter(([key, value]) => + key === 'workspaceScope' ? value !== null : Array.isArray(value) && value.length > 0, + ), ); } @@ -128,6 +151,129 @@ export function registerRoutineRoutes(app: Express, ctx: RegisterRoutineRoutesDe const { db } = ctx; const { routineService } = ctx.routines; + async function authorizeRoutineWorkspaceContext( + req: any, + context: ReturnType<typeof normalizeRoutineContext>, + targetMode: 'create_each_run' | 'reuse', + verifyExplicitScope = true, + ) { + if (targetMode === 'reuse') { + const { workspaceScope: _ignoredWorkspaceScope, ...projectBoundContext } = context; + return projectBoundContext; + } + const scope = normalizePersistedAutomationWorkspaceScope(context.workspaceScope); + if (!scope) return context; + if (!verifyExplicitScope) return { ...context, workspaceScope: scope }; + const claimedWorkspaceId = String(req.get?.('x-od-workspace-id') ?? '').trim(); + const claimedMemberId = String(req.get?.('x-od-workspace-member-id') ?? '').trim(); + if ( + claimedWorkspaceId !== scope.workspaceId + || claimedMemberId !== scope.workspaceMemberId + ) { + throw new Error('routine Workspace scope must match the explicit request identity'); + } + await authorizePersistedAutomationWorkspaceScope(scope, ctx.fetchWorkspaceDirectory); + return { ...context, workspaceScope: scope }; + } + + function claimedWorkspaceScope(req: any) { + const workspaceId = String(req.get?.('x-od-workspace-id') ?? '').trim(); + const workspaceMemberId = String( + req.get?.('x-od-workspace-member-id') ?? '', + ).trim(); + if (!workspaceId && !workspaceMemberId) return null; + if (!workspaceId || !workspaceMemberId) { + throw new Error('both Workspace and member identity headers are required'); + } + return { workspaceId, workspaceMemberId }; + } + + function persistedRoutineWorkspaceId(row: any): string | null { + if (row.projectMode === 'reuse' && row.projectId) { + return getWorkspaceProjectByProjectId(db, row.projectId)?.workspaceId ?? null; + } + return normalizePersistedAutomationWorkspaceScope( + parseStoredRoutineContext(row).workspaceScope, + )?.workspaceId ?? null; + } + + async function authorizeRoutineRecord(req: any, row: any) { + const claimed = claimedWorkspaceScope(req); + if (row.projectMode === 'reuse' && row.projectId) { + const binding = getWorkspaceProjectByProjectId(db, row.projectId); + if (!binding?.workspaceId) return null; + if (!claimed || claimed.workspaceId !== binding.workspaceId) { + throw new AutomationWorkspaceScopeError( + 'WORKSPACE_ACCESS_DENIED', + 'the routine belongs to a different Workspace', + false, + ); + } + const context = await authorizePersistedProjectWorkspace( + binding.workspaceId, + ctx.fetchWorkspaceDirectory, + ); + if (context.workspaceMemberId !== claimed.workspaceMemberId) { + throw new AutomationWorkspaceScopeError( + 'WORKSPACE_ACCESS_DENIED', + 'the routine belongs to a different Workspace member', + false, + ); + } + return { + workspaceId: context.workspaceId, + workspaceMemberId: context.workspaceMemberId, + }; + } + + const persisted = normalizePersistedAutomationWorkspaceScope( + parseStoredRoutineContext(row).workspaceScope, + ); + if (!persisted) return null; + if ( + !claimed + || claimed.workspaceId !== persisted.workspaceId + || claimed.workspaceMemberId !== persisted.workspaceMemberId + ) { + throw new AutomationWorkspaceScopeError( + 'WORKSPACE_ACCESS_DENIED', + 'the routine belongs to a different Workspace', + false, + ); + } + await authorizePersistedAutomationWorkspaceScope( + persisted, + ctx.fetchWorkspaceDirectory, + ); + return persisted; + } + + function exposeRoutineWorkspaceScope( + routine: ReturnType<typeof routineDbRowToContract>, + scope: { workspaceId: string; workspaceMemberId: string } | null, + ) { + if (!scope) return routine; + return { + ...routine, + context: { + ...routine.context, + workspaceScope: scope, + }, + }; + } + + function sendRoutineError(res: any, err: any, fallbackStatus: number) { + const status = err instanceof AutomationWorkspaceScopeError + ? err.code === 'WORKSPACE_AUTHORITY_UNAVAILABLE' ? 503 : 403 + : fallbackStatus; + return res.status(status).json({ + error: String(err?.message ?? err), + ...(err instanceof AutomationWorkspaceScopeError + ? { code: err.code, ...(err.retryable ? { retryable: true } : {}) } + : {}), + }); + } + app.get('/api/automation-templates', async (_req, res) => { try { res.json({ @@ -189,28 +335,69 @@ export function registerRoutineRoutes(app: Express, ctx: RegisterRoutineRoutesDe if (!partial || body.context !== undefined) normalizeRoutineContext(body.context); } - app.get('/api/routines', (_req, res) => { + app.get('/api/routines', async (req, res) => { try { - const routines = listRoutines(db).map((row) => { + const claimed = claimedWorkspaceScope(req); + if (claimed) { + await authorizePersistedAutomationWorkspaceScope( + claimed, + ctx.fetchWorkspaceDirectory, + ); + } + const routines = listRoutines(db).flatMap((row) => { + const persistedWorkspaceId = persistedRoutineWorkspaceId(row); + const persistedScope = row.projectMode === 'reuse' + ? null + : normalizePersistedAutomationWorkspaceScope( + parseStoredRoutineContext(row).workspaceScope, + ); + if (persistedWorkspaceId && persistedWorkspaceId !== claimed?.workspaceId) { + return []; + } + if ( + persistedScope + && persistedScope.workspaceMemberId !== claimed?.workspaceMemberId + ) { + return []; + } const latest = getLatestRoutineRun(db, row.id); const contract = routineDbRowToContract(row, latest); const nextDate = routineService?.nextRunAt(row.id) ?? null; contract.nextRunAt = nextDate ? nextDate.getTime() : null; - return contract; + return [ + exposeRoutineWorkspaceScope( + contract, + persistedWorkspaceId + ? persistedScope ?? claimed + : null, + ), + ]; }); res.json({ routines }); } catch (err: any) { - res.status(500).json({ error: String(err?.message ?? err) }); + sendRoutineError(res, err, 400); } }); - app.post('/api/routines', (req, res) => { + app.post('/api/routines', async (req, res) => { try { const body = req.body || {}; validateRoutineInput(body, false); const id = `routine-${randomUUID()}`; const now = Date.now(); const scheduleCols = scheduleToDbCols(body.schedule); + const context = await authorizeRoutineWorkspaceContext( + req, + normalizeRoutineContext(body.context), + body.target.mode, + ); + const createdScope = body.target.mode === 'reuse' + ? await authorizeRoutineRecord(req, { + projectMode: 'reuse', + projectId: body.target.projectId, + contextJson: '{}', + }) + : normalizePersistedAutomationWorkspaceScope(context.workspaceScope); insertRoutine(db, { id, name: body.name.trim(), @@ -220,29 +407,48 @@ export function registerRoutineRoutes(app: Express, ctx: RegisterRoutineRoutesDe projectId: body.target.mode === 'reuse' ? body.target.projectId : null, skillId: body.skillId ?? null, agentId: body.agentId ?? null, - contextJson: JSON.stringify(normalizeRoutineContext(body.context)), + contextJson: JSON.stringify(context), enabled: body.enabled !== false, createdAt: now, updatedAt: now, }); routineService?.rescheduleOne(id); const routine = routineFromDb(id); - res.status(201).json({ routine }); + res.status(201).json({ + routine: exposeRoutineWorkspaceScope(routine!, createdScope), + }); } catch (err: any) { - res.status(400).json({ error: String(err?.message ?? err) }); + const status = err instanceof AutomationWorkspaceScopeError + ? err.code === 'WORKSPACE_AUTHORITY_UNAVAILABLE' ? 503 : 403 + : 400; + res.status(status).json({ + error: String(err?.message ?? err), + ...(err instanceof AutomationWorkspaceScopeError + ? { code: err.code, ...(err.retryable ? { retryable: true } : {}) } + : {}), + }); } }); - app.get('/api/routines/:id', (req, res) => { - const routine = routineFromDb(req.params.id); - if (!routine) return res.status(404).json({ error: 'routine not found' }); - res.json({ routine }); + app.get('/api/routines/:id', async (req, res) => { + try { + const row = getRoutine(db, req.params.id); + if (!row) return res.status(404).json({ error: 'routine not found' }); + const scope = await authorizeRoutineRecord(req, row); + res.json({ + routine: exposeRoutineWorkspaceScope(routineFromDb(req.params.id)!, scope), + }); + } catch (err: any) { + sendRoutineError(res, err, 400); + } }); - app.patch('/api/routines/:id', (req, res) => { + app.patch('/api/routines/:id', async (req, res) => { try { const existing = getRoutine(db, req.params.id); if (!existing) return res.status(404).json({ error: 'routine not found' }); + const existingScope = await authorizeRoutineRecord(req, existing); + let resultingScope = existingScope; const body = req.body || {}; validateRoutineInput(body, true); const patch: any = {}; @@ -255,27 +461,83 @@ export function registerRoutineRoutes(app: Express, ctx: RegisterRoutineRoutesDe } if (body.skillId !== undefined) patch.skillId = body.skillId ?? null; if (body.agentId !== undefined) patch.agentId = body.agentId ?? null; - if (body.context !== undefined) patch.contextJson = JSON.stringify(normalizeRoutineContext(body.context)); + if (body.context !== undefined || body.target !== undefined) { + const effectiveTargetMode = body.target?.mode ?? existing.projectMode; + const storedContext = parseStoredRoutineContext(existing); + let context = body.context !== undefined + ? normalizeRoutineContext(body.context) + : storedContext; + const requestHasWorkspaceScope = Boolean( + body.context + && typeof body.context === 'object' + && !Array.isArray(body.context) + && Object.hasOwn(body.context, 'workspaceScope'), + ); + if ( + effectiveTargetMode === 'create_each_run' + && !requestHasWorkspaceScope + && (storedContext.workspaceScope || existingScope) + ) { + context = { + ...context, + workspaceScope: storedContext.workspaceScope ?? existingScope, + }; + } + const authorizedContext = await authorizeRoutineWorkspaceContext( + req, + context, + effectiveTargetMode, + requestHasWorkspaceScope, + ); + patch.contextJson = JSON.stringify(authorizedContext); + resultingScope = effectiveTargetMode === 'create_each_run' + ? normalizePersistedAutomationWorkspaceScope( + authorizedContext.workspaceScope, + ) + : await authorizeRoutineRecord(req, { + ...existing, + projectMode: 'reuse', + projectId: body.target?.projectId ?? existing.projectId, + contextJson: JSON.stringify(authorizedContext), + }); + } if (body.enabled !== undefined) patch.enabled = Boolean(body.enabled); updateRoutine(db, req.params.id, patch); routineService?.rescheduleOne(req.params.id); - res.json({ routine: routineFromDb(req.params.id) }); + res.json({ + routine: exposeRoutineWorkspaceScope( + routineFromDb(req.params.id)!, + resultingScope, + ), + }); } catch (err: any) { - res.status(400).json({ error: String(err?.message ?? err) }); + sendRoutineError(res, err, 400); } }); - app.delete('/api/routines/:id', (req, res) => { - routineService?.unschedule(req.params.id); - const removed = dbDeleteRoutine(db, req.params.id); - if (!removed) return res.status(404).json({ error: 'routine not found' }); - res.status(204).end(); + app.delete('/api/routines/:id', async (req, res) => { + try { + const existing = getRoutine(db, req.params.id); + if (!existing) return res.status(404).json({ error: 'routine not found' }); + await authorizeRoutineRecord(req, existing); + routineService?.unschedule(req.params.id); + dbDeleteRoutine(db, req.params.id); + res.status(204).end(); + } catch (err: any) { + sendRoutineError(res, err, 400); + } }); app.post('/api/routines/:id/run', async (req, res) => { try { const existing = getRoutine(db, req.params.id); if (!existing) return res.status(404).json({ error: 'routine not found' }); + // Execution is not a local membership decision. The routine/project + // already persists its exact Workspace billing address; start the run + // with that address and let the authenticated Vela backend make the + // final membership, permission, and billing decision. Re-reading the + // daemon directory here made a transient outage either block the run or + // tempt callers to drop Team scope and charge Personal instead. const start = await routineService.runNow(req.params.id); res.status(202).json({ routine: routineFromDb(req.params.id), @@ -285,21 +547,27 @@ export function registerRoutineRoutes(app: Express, ctx: RegisterRoutineRoutesDe agentRunId: start.agentRunId, }); } catch (err: any) { - res.status(500).json({ error: String(err?.message ?? err) }); + sendRoutineError(res, err, 500); } }); - app.get('/api/routines/:id/runs', (req, res) => { - const existing = getRoutine(db, req.params.id); - if (!existing) return res.status(404).json({ error: 'routine not found' }); - const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 20)); - res.json({ runs: listRoutineRuns(db, req.params.id, limit) }); + app.get('/api/routines/:id/runs', async (req, res) => { + try { + const existing = getRoutine(db, req.params.id); + if (!existing) return res.status(404).json({ error: 'routine not found' }); + await authorizeRoutineRecord(req, existing); + const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 20)); + res.json({ runs: listRoutineRuns(db, req.params.id, limit) }); + } catch (err: any) { + sendRoutineError(res, err, 400); + } }); app.post('/api/routines/:id/runs/:runId/crystallize', async (req, res) => { try { const routine = getRoutine(db, req.params.id); if (!routine) return res.status(404).json({ error: 'routine not found' }); + await authorizeRoutineRecord(req, routine); const run = getRoutineRun(db, req.params.runId); if (!run || run.routineId !== req.params.id) { return res.status(404).json({ error: 'routine run not found' }); @@ -341,7 +609,7 @@ export function registerRoutineRoutes(app: Express, ctx: RegisterRoutineRoutesDe }); res.json({ ...result, routineId: routine.id, runId: run.id }); } catch (err: any) { - res.status(400).json({ error: String(err?.message ?? err) }); + sendRoutineError(res, err, 400); } }); } diff --git a/apps/daemon/src/routes/runs.ts b/apps/daemon/src/routes/runs.ts index c1571c40718..8184ee12d0a 100644 --- a/apps/daemon/src/routes/runs.ts +++ b/apps/daemon/src/routes/runs.ts @@ -26,6 +26,13 @@ import { newInsertId, readAnalyticsContext } from '../analytics.js'; import type { AnalyticsContext } from '../analytics.js'; import { spawnEnvForAgent } from '../agents.js'; import { agentCliEnvForAgent, readAppConfig } from '../app-config.js'; +import type { AuthorizeProjectRequest } from '../collab/project-request-authority.js'; +import { + workspaceResourceContextFromRequest, + type BoundWorkspaceResourceMutationGate, + type VerifyWorkspaceRequestAuthority, + type WorkspaceResourceAccessInput, +} from '../collab/workspace-resource-mutation.js'; import { codexSessionIdFromRunEvents, readCodexRolloutFirstCall, @@ -34,9 +41,9 @@ import type { ByokCredentialService } from '../byok/credential-service.js'; import type { ConnectorService } from '../connectors/service.js'; import { conversationTurnIndexForRun, + getFirstProjectConversation, getConversation, getProject, - listConversations, normalizeConversationSessionMode, updateProject, upsertMessage, @@ -107,6 +114,10 @@ import { deriveActivationMilestones, runAskedUserQuestion, } from '../runtimes/run-artifacts.js'; +import { + pinRunWorkspaceScopeForProject, + type PinnedRunWorkspaceScope, +} from '../runtimes/project-amr-trace-env.js'; import { runArtifactCountForRun, runDesignSystemCreatedForRun, @@ -240,16 +251,13 @@ function seededUserMessageTurnMetadataFields( interface ProjectRecord { id: string; name: string; + createdAt?: number; + updatedAt?: number; designSystemId?: string | null; metadata?: ProjectMetadata; appliedPluginSnapshotId?: string | null; } -interface ConversationRecord { - id: string; - createdAt?: number; -} - interface RunEventRecord extends RunEventForAnalyticsObservability, RunEventForDiagnostics, @@ -274,6 +282,7 @@ interface ChatRun { clientRequestId?: string | null; requestFingerprint?: string | null; agentId: string | null; + workspaceScope?: PinnedRunWorkspaceScope | null; model?: string | null; status: ChatRunStatus; createdAt: number; @@ -348,6 +357,7 @@ interface ChatRun { interface RunCreateMeta extends JsonRecord { projectId?: string; conversationId?: string; + userMessageId?: string; assistantMessageId?: string; clientRequestId?: string; requestFingerprint?: string; @@ -357,6 +367,7 @@ interface RunCreateMeta extends JsonRecord { message?: string; currentPrompt?: string; projectMetadata?: ProjectMetadata; + workspaceScope?: PinnedRunWorkspaceScope | null; } interface RunListFilters { @@ -501,6 +512,61 @@ export interface RegisterRunRoutesDeps { run: ChatRun, ) => void; }; + /** + * Workspace-identity gate for POST /api/runs and POST /api/chat — this + * file's two "create a run" entry points. Until this fix both had ZERO + * `enforceWorkspace*` coverage: unlike rename/delete/duplicate/writeFiles + * and comments (all gated per spec 04 §10/§11), any caller who knew a + * projectId could spawn an agent run against it — including a project + * bound to a TEAM workspace — with no workspace identity headers at all. + * + * Borrows the SAME `enforceWorkspaceProjectMutation` instance + * `routes/project/index.ts` builds via `createEnforceWorkspaceProjectMutation` + * (cross-checked against the daemon's own last-known membership) rather + * than re-deriving a second, possibly-drifting copy here — see + * `routes/project/comments.ts` for the established borrow-the-project's- + * gate pattern this mirrors. + * + * Optional, and a no-op when omitted, so fixtures that only exercise run + * creation (most of this file's existing tests, which use plain + * non-workspace-bound projects) keep compiling and behaving exactly as + * before — an unbound project's runs were never gated either way, since + * `enforceWorkspaceResourceMutation` itself passes a `row === null` lookup + * straight through regardless of ctx. + */ + enforceWorkspaceProjectMutation?: BoundWorkspaceResourceMutationGate; + /** Fresh exact authority for run reads/cancel after resolving run.projectId. */ + authorizeProjectRequest?: AuthorizeProjectRequest; + /** + * Paired with `enforceWorkspaceProjectMutation` above: the SAME + * `workspace_projects` binding lookups project's own mutation routes + * already use, so a run's gate reads the identical row rename/delete/ + * duplicate/comments already check instead of a second query shape. + */ + projectStore?: { + // `db` is typed `any` here (matching `BoundWorkspaceResourceMutationGate`'s + // own `db: unknown` seam) purely to sidestep strict-function-type + // contravariance: the concrete `db.ts` implementations take `SqliteDb`, + // and this field's value is threaded straight into + // `enforceWorkspaceProjectMutation`'s matching `db: unknown` parameters. + getWorkspaceProject: ( + db: any, + workspaceId: string, + projectId: string, + ) => WorkspaceResourceAccessInput | null | undefined; + getWorkspaceProjectByProjectId: ( + db: any, + projectId: string, + ) => (WorkspaceResourceAccessInput & { workspaceId?: string | null }) | null | undefined; + ensureWorkspaceProject?: ( + db: any, + input: Record<string, unknown>, + ) => (WorkspaceResourceAccessInput & { workspaceId?: string | null }) | null | undefined; + }; + amrWorkspaceScope?: { + isSignedIn: () => boolean | Promise<boolean>; + verifyWorkspaceRequestAuthority: VerifyWorkspaceRequestAuthority; + }; } type TerminalRunStatus = RunStatusForAnalytics & { @@ -582,14 +648,6 @@ function isProjectEnrichableDesignSystem(project: ProjectRecord): boolean { return metadata?.importedFrom === 'brand-extraction' || metadata?.importedFrom === 'design-system'; } -function toConversationRecords(value: unknown): ConversationRecord[] { - return Array.isArray(value) - ? value.filter((item): item is ConversationRecord => - Boolean(item && typeof item === 'object' && typeof (item as JsonRecord).id === 'string'), - ) - : []; -} - function toProjectFiles(value: unknown): ProjectFileEntry[] { return Array.isArray(value) ? value.filter((item): item is ProjectFileEntry => @@ -736,6 +794,7 @@ function runRequestFingerprint( delete logicalRequest.requestFingerprint; delete logicalRequest.resume; delete logicalRequest.analyticsHints; + delete logicalRequest.userMessageId; delete logicalRequest.assistantMessageId; delete logicalRequest.projectMetadata; delete logicalRequest.appliedPluginSnapshotId; @@ -851,6 +910,235 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) { reconcileAssistantMessageOnRunEnd, } = ctx.messages; + /** + * Pin a run to its persisted project binding. The sole adoption branch is a + * signed-in AMR request for a truly unbound historical project: a freshly + * verified exact Personal identity may write the same ownerless projection + * that the Personal project-list migration writes. Every other runtime keeps + * its legacy local path and never reads Workspace authority here. + */ + async function prepareRunWorkspaceScope( + req: ApiRequest, + res: ApiResponse, + projectId: string, + agentId: unknown, + ): Promise< + | { ok: true; workspaceScope: PinnedRunWorkspaceScope | null } + | { ok: false } + > { + if (!ctx.projectStore) return { ok: true, workspaceScope: null }; + const binding = ctx.projectStore.getWorkspaceProjectByProjectId(db, projectId); + const requestContext = workspaceResourceContextFromRequest(req); + if (binding) { + // A shared Team project is a single-writer resource. Billing still uses + // the persisted Workspace binding below, but starting an agent can write + // project files and conversation state, so the caller must separately + // prove project-owner mutation standing. Workspace owner/admin is not a + // substitute for the catalog's project owner. Personal and unshared + // bindings retain the legacy local-run behavior. + if ( + binding.visibility === 'team' + && ctx.authorizeProjectRequest + && !await ctx.authorizeProjectRequest( + req, + res, + projectId, + { mode: 'write', capability: 'writeFiles' }, + ) + ) { + return { ok: false }; + } + // Run billing scope is the persisted project binding. On the Personal + // lane a headerless local caller remains valid; Vela/AMR receives the + // signed-in account plus this exact binding and makes the membership/ + // balance decision. + const workspaceScope = pinRunWorkspaceScopeForProject(db, projectId); + if (!workspaceScope || workspaceScope.workspaceId !== binding.workspaceId) { + sendApiError( + res, + 409, + 'AMR_WORKSPACE_SCOPE_CONFLICT', + 'the project Workspace binding changed before the run could be pinned', + ); + return { ok: false }; + } + if (requestContext === null) return { ok: true, workspaceScope }; + if (requestContext === 'missing') { + sendApiError( + res, + 400, + 'WORKSPACE_CONTEXT_INCOMPLETE', + 'both workspace and member identity are required', + ); + return { ok: false }; + } + if (requestContext.workspaceId !== binding.workspaceId) { + sendApiError( + res, + 403, + 'WORKSPACE_PROJECT_PERMISSION_DENIED', + 'run workspace does not match the persisted project workspace', + ); + return { ok: false }; + } + return { ok: true, workspaceScope }; + } + + // This migration guard is deliberately AMR-only. Local CLIs, BYOK + // providers, and every other runtime retain the legacy unbound path and do + // not even probe AMR login or Workspace authority. + if (agentId !== 'amr' || !ctx.amrWorkspaceScope) { + return { ok: true, workspaceScope: null }; + } + if (!await ctx.amrWorkspaceScope.isSignedIn()) { + return { ok: true, workspaceScope: null }; + } + + if (requestContext === null) { + sendApiError( + res, + 409, + 'AMR_WORKSPACE_SCOPE_REQUIRED', + 'open the project from your Personal Workspace before running AMR Cloud', + ); + return { ok: false }; + } + if (requestContext === 'missing') { + sendApiError( + res, + 400, + 'WORKSPACE_CONTEXT_INCOMPLETE', + 'both workspace and member identity are required', + ); + return { ok: false }; + } + + const verified = + await ctx.amrWorkspaceScope.verifyWorkspaceRequestAuthority(req); + if (!verified.ok) { + sendApiError(res, verified.status, verified.code, verified.message); + return { ok: false }; + } + if ( + verified.context.workspaceId !== requestContext.workspaceId + || verified.context.workspaceMemberId !== requestContext.workspaceMemberId + ) { + sendApiError( + res, + 403, + 'WORKSPACE_ACCESS_DENIED', + 'the verified Workspace identity does not match the run request', + ); + return { ok: false }; + } + if (verified.context.workspaceType !== 'personal') { + sendApiError( + res, + 409, + 'AMR_PERSONAL_WORKSPACE_REQUIRED', + 'historical projects can only be adopted into a Personal Workspace', + ); + return { ok: false }; + } + const ensureWorkspaceProject = ctx.projectStore.ensureWorkspaceProject; + if (!ensureWorkspaceProject) { + sendApiError( + res, + 409, + 'AMR_WORKSPACE_SCOPE_REQUIRED', + 'the project must be migrated into a Personal Workspace before running AMR Cloud', + ); + return { ok: false }; + } + + const project = toProjectRecord(getProject(db, projectId)); + if (!project) { + sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + return { ok: false }; + } + const { getWorkspaceProjectByProjectId } = ctx.projectStore; + const bindPersonal = db.transaction(() => { + const existing = getWorkspaceProjectByProjectId(db, projectId); + if (existing) return existing; + ensureWorkspaceProject(db, { + projectId, + workspaceId: verified.context.workspaceId, + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: null, + updatedByWorkspaceMemberId: null, + syncState: 'local_only', + resourceHubResourceId: null, + cloudTombstonedAt: null, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + }); + return getWorkspaceProjectByProjectId(db, projectId); + }); + const adopted = bindPersonal(); + if (adopted?.workspaceId !== verified.context.workspaceId) { + sendApiError( + res, + 409, + 'AMR_WORKSPACE_SCOPE_CONFLICT', + 'the project was bound to another Workspace before AMR could start', + ); + return { ok: false }; + } + const workspaceScope = pinRunWorkspaceScopeForProject(db, projectId); + if (!workspaceScope || workspaceScope.workspaceId !== verified.context.workspaceId) { + sendApiError( + res, + 409, + 'AMR_WORKSPACE_SCOPE_CONFLICT', + 'the project Workspace binding changed before the run could be pinned', + ); + return { ok: false }; + } + return { ok: true, workspaceScope }; + } + + async function authorizeRunProject( + req: ApiRequest, + res: ApiResponse, + run: ChatRun, + options: { mode: 'read'; allowNavigationQuery?: boolean } | { + mode: 'write'; + capability: 'writeFiles'; + }, + ): Promise<boolean> { + if (!run.projectId || !ctx.authorizeProjectRequest) return true; + + // Local CLI/MCP callers predate Workspace transport headers. Once a run + // exists, its persisted agentId is the reliable runtime discriminator: + // non-AMR runtimes do not call the Workspace billing plane, so their + // headerless status/stream/cancel lifecycle must not depend on Workspace + // membership authority. AMR remains exact-authority-only. Likewise, any + // caller that explicitly asserts a Workspace identity still goes through + // the normal gate so a conflicting or partial scope cannot be ignored. + const requestContext = workspaceResourceContextFromRequest(req); + const carriesNavigationScope = + options.mode === 'read' + && options.allowNavigationQuery + && ( + (typeof req.query?.workspaceId === 'string' + && req.query.workspaceId.trim().length > 0) + || (typeof req.query?.workspaceMemberId === 'string' + && req.query.workspaceMemberId.trim().length > 0) + ); + if ( + typeof run.agentId === 'string' + && run.agentId.length > 0 + && run.agentId !== 'amr' + && requestContext === null + && !carriesNavigationScope + ) { + return true; + } + + return ctx.authorizeProjectRequest(req, res, run.projectId, options); + } + function runToolBundleDeliveryTargetForProject( projectId: unknown, metadata: ProjectMetadata, @@ -1019,6 +1307,12 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) { resolvedByokInputError, ); } + if (typeof meta.projectId === 'string' && meta.projectId) { + const preparedWorkspaceScope = + await prepareRunWorkspaceScope(req, res, meta.projectId, meta.agentId); + if (!preparedWorkspaceScope.ok) return; + meta.workspaceScope = preparedWorkspaceScope.workspaceScope; + } const toolBundleSupport = validateRunToolBundleForAgent( toolBundle.bundle, typeof meta.agentId === 'string' ? getAgentDef(meta.agentId) : null, @@ -1104,17 +1398,7 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) { (typeof meta.conversationId !== 'string' || !meta.conversationId) ) { try { - const convs = toConversationRecords(listConversations(db, meta.projectId)); - const defaultConv = convs.length > 0 - ? [...convs].sort((a, b) => { - const aCreated = Number(a?.createdAt); - const bCreated = Number(b?.createdAt); - if (Number.isFinite(aCreated) && Number.isFinite(bCreated) && aCreated !== bCreated) { - return aCreated - bCreated; - } - return String(a?.id ?? '').localeCompare(String(b?.id ?? '')); - })[0] - : null; + const defaultConv = getFirstProjectConversation(db, meta.projectId); if (defaultConv && typeof defaultConv.id === 'string' && defaultConv.id) { meta.conversationId = defaultConv.id; conversationFallbackBound = true; @@ -1154,17 +1438,41 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) { // omit it. Without a server-side pin, pinAssistantMessageOnRunCreate no-ops, // lastMessageId stays null, and multi-turn native session resume is skipped // (missing_cursor / resume_skipped). Ownership is validated above first. - // Also seed the user turn when the server bound conversationId via the - // headless fallback even if the client already supplied a pin — the pre- - // refactor path always seeded in that case, and MCP allows pin + omitted - // conversationId independently. + // A web client also supplies userMessageId so this route can pin the user + // row before the assistant row. Its separate best-effort PUT may arrive + // later; upserting the same id then preserves the position established + // here. Headless fallback keeps its existing generated-id behavior. // // Prepare seed payload before createOrReuse, but only persist when the run // is newly created so lost-response retries with clientRequestId do not // duplicate user turns. const missingClientPin = typeof meta.assistantMessageId !== 'string' || !meta.assistantMessageId; - let omitPinUserSeed: { + const clientUserMessageId = + typeof meta.userMessageId === 'string' && meta.userMessageId + ? meta.userMessageId + : null; + if (clientUserMessageId && !isSafeId(clientUserMessageId)) { + return sendApiError(res, 400, 'BAD_REQUEST', 'userMessageId is invalid'); + } + if (clientUserMessageId && typeof meta.conversationId === 'string') { + const existingUserPin = db + .prepare(`SELECT conversation_id AS conversationId FROM messages WHERE id = ?`) + .get(clientUserMessageId) as { conversationId?: unknown } | undefined; + if ( + existingUserPin + && existingUserPin.conversationId !== meta.conversationId + ) { + return sendApiError( + res, + 409, + 'IDEMPOTENCY_CONFLICT', + 'userMessageId belongs to a different conversation', + ); + } + } + let runUserSeed: { + id: string; conversationId: string; content: string; attachments: ReturnType<typeof seededUserMessageAttachmentFields>; @@ -1173,7 +1481,7 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) { if ( typeof meta.conversationId === 'string' && meta.conversationId && - (missingClientPin || conversationFallbackBound) + (clientUserMessageId || missingClientPin || conversationFallbackBound) ) { if (missingClientPin) { meta.assistantMessageId = randomUUID(); @@ -1202,7 +1510,8 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) { ? originalMessage : null; if (promptForUserMessage !== null) { - omitPinUserSeed = { + runUserSeed = { + id: clientUserMessageId ?? randomUUID(), conversationId: meta.conversationId, content: promptForUserMessage, attachments: seededAttachments, @@ -1270,22 +1579,22 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) { } resumed = true; } - if (creation.kind === 'created' && omitPinUserSeed) { + if (creation.kind === 'created' && runUserSeed) { try { const now = Date.now(); - upsertMessage(db, omitPinUserSeed.conversationId, { - id: randomUUID(), + upsertMessage(db, runUserSeed.conversationId, { + id: runUserSeed.id, role: 'user', - content: omitPinUserSeed.content, + content: runUserSeed.content, startedAt: now, endedAt: now, // Same turn metadata the web client writes via PUT /messages so // reload/retry keep sessionMode, runContext, and applied plugin. - ...omitPinUserSeed.turnMetadata, + ...runUserSeed.turnMetadata, // Preserve request attachments/commentAttachments on the seeded user // turn so reload/listMessages still show chips and annotation context // for omit-pin / headless clients (same columns as PUT /messages). - ...omitPinUserSeed.attachments, + ...runUserSeed.attachments, }); // Bump parent project updatedAt so listProjects reorders (same as // PUT /messages). Headless/API turns that never hit that route would @@ -2054,10 +2363,55 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) { } }); - app.get('/api/runs', (req: ApiRequest, res: ApiResponse) => { + app.get('/api/runs', async (req: ApiRequest, res: ApiResponse) => { const { projectId, conversationId, status } = req.query; const runs = design.runs.list({ projectId, conversationId, status }); - const body = { runs: runs.map(design.runs.statusBody) }; + let visibleRuns = runs; + if (typeof projectId === 'string' && projectId) { + const binding = + ctx.projectStore?.getWorkspaceProjectByProjectId(db, projectId); + if (binding) { + const requestContext = workspaceResourceContextFromRequest(req); + if (requestContext === null) { + // Headerless local CLI/MCP callers may list only the runs whose + // persisted runtime is known not to use AMR's Workspace billing + // plane. Filtering the whole set avoids both insertion-order bugs: + // an AMR first row cannot block local runs, and a non-AMR first row + // cannot accidentally reveal AMR or unknown-runtime runs. + visibleRuns = runs.filter( + (run) => + typeof run.agentId === 'string' + && run.agentId.length > 0 + && run.agentId !== 'amr', + ); + } else if ( + ctx.authorizeProjectRequest + && !await ctx.authorizeProjectRequest( + req, + res, + projectId, + { mode: 'read' }, + ) + ) { + return; + } + } + } else if ( + ctx.projectStore + && runs.some( + (run) => + run.projectId + && ctx.projectStore?.getWorkspaceProjectByProjectId(db, run.projectId), + ) + ) { + return sendApiError( + res, + 400, + 'PROJECT_SCOPE_REQUIRED', + 'projectId is required when listing Workspace-bound runs', + ); + } + const body = { runs: visibleRuns.map(design.runs.statusBody) }; res.json(body); }); @@ -2108,6 +2462,7 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) { if (!runId) return sendApiError(res, 400, 'BAD_REQUEST', 'run id missing'); const run = design.runs.get(runId); if (!run) return sendApiError(res, 404, 'NOT_FOUND', 'run not found'); + if (!await authorizeRunProject(req, res, run, { mode: 'read' })) return; const status = design.runs.statusBody(run); const project = run.projectId ? toProjectRecord(getProject(db, run.projectId)) : null; let files: ProjectFileEntry[] = []; @@ -2194,6 +2549,7 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) { if (!runId) return sendApiError(res, 400, 'BAD_REQUEST', 'run id missing'); const run = design.runs.get(runId); if (!run) return sendApiError(res, 404, 'NOT_FOUND', 'run not found'); + if (!await authorizeRunProject(req, res, run, { mode: 'read' })) return; const status = design.runs.statusBody(run); if (!design.runs.isTerminal(run.status)) { res.json(status); @@ -2232,11 +2588,17 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) { }); }); - app.get('/api/runs/:id/events', (req: ApiRequest, res: ApiResponse) => { + app.get('/api/runs/:id/events', async (req: ApiRequest, res: ApiResponse) => { const runId = routeParamId(req); if (!runId) return sendApiError(res, 400, 'BAD_REQUEST', 'run id missing'); const run = design.runs.get(runId); if (!run) return sendApiError(res, 404, 'NOT_FOUND', 'run not found'); + if (!await authorizeRunProject( + req, + res, + run, + { mode: 'read', allowNavigationQuery: true }, + )) return; design.runs.stream(run, req, res); }); @@ -2245,6 +2607,12 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) { if (!runId) return sendApiError(res, 400, 'BAD_REQUEST', 'run id missing'); const run = design.runs.get(runId); if (!run) return sendApiError(res, 404, 'NOT_FOUND', 'run not found'); + if (!await authorizeRunProject( + req, + res, + run, + { mode: 'read', allowNavigationQuery: true }, + )) return; const { encodeOdEventForAgui } = await import('@open-design/agui-adapter'); const sse = createSseResponse(res); const lastEventId = Number(req.get('Last-Event-ID') || req.query.after || 0); @@ -2294,6 +2662,12 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) { if (!runId) return sendApiError(res, 400, 'BAD_REQUEST', 'run id missing'); const run = design.runs.get(runId); if (!run) return sendApiError(res, 404, 'NOT_FOUND', 'run not found'); + if (!await authorizeRunProject( + req, + res, + run, + { mode: 'write', capability: 'writeFiles' }, + )) return; const status = await design.runs.cancel(run); const body = { ok: true, run: status }; res.json(body); @@ -2367,6 +2741,12 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) { toolBundle: toolBundle.bundle, ...(chatProject?.metadata ? { projectMetadata: chatProject.metadata } : {}), }; + if (typeof meta.projectId === 'string' && meta.projectId) { + const preparedWorkspaceScope = + await prepareRunWorkspaceScope(req, res, meta.projectId, meta.agentId); + if (!preparedWorkspaceScope.ok) return; + meta.workspaceScope = preparedWorkspaceScope.workspaceScope; + } meta.requestFingerprint = runRequestFingerprint(meta); const creation = design.runs.createOrReuse(meta); if (creation.kind === 'conflict') { diff --git a/apps/daemon/src/routes/static-resource.ts b/apps/daemon/src/routes/static-resource.ts index 04596828907..c58aedd3e70 100644 --- a/apps/daemon/src/routes/static-resource.ts +++ b/apps/daemon/src/routes/static-resource.ts @@ -1,8 +1,16 @@ -import type { Express } from 'express'; +import type { Express, Response } from 'express'; import type Database from 'better-sqlite3'; import path from 'node:path'; import fs from 'node:fs'; -import type { DesignSystemTokenContractRebuildJobResponse } from '@open-design/contracts'; +import type { + DesignSystemTokenContractRebuildJobResponse, + WorkspaceCollabContext, +} from '@open-design/contracts'; +import { TeamResourceCopyForbiddenError } from '@open-design/contracts'; +import { + enforceTeamResourceCopyAllowed, + type TeamResourceStateProvider, +} from '../collab/team-resource-state.js'; import { detectAgents, detectAgentsStream } from '../agents.js'; import { SkillImportError, @@ -13,6 +21,17 @@ import { splitDerivedSkillId, updateUserSkill, } from '../skills.js'; +import { + deleteWorkspaceResourceByResourceId, + ensureWorkspaceResource, + getWorkspaceResource, + getWorkspaceResourceByResourceId, +} from '../db.js'; +import { + enforceVerifiedWorkspaceResourceMutation, + resolveOptionalWorkspaceRequestAuthority, + type VerifyWorkspaceRequestAuthority, +} from '../collab/workspace-resource-mutation.js'; import { listCodexPets, readCodexPetSpritesheet } from '../codex-pets.js'; import { syncCommunityPets } from '../community-pets-sync.js'; import { readDesignSystem } from '../design-systems/index.js'; @@ -27,6 +46,10 @@ import { renderDesignSystemShowcase } from '../design-systems/showcase.js'; import { listPromptTemplates, readPromptTemplate } from '../media/prompt-templates.js'; import { readAppConfig } from '../app-config.js'; import { installFromTarget, uninstallById } from '../library-install.js'; +import { + installSkillFromRemoteSource, + type SkillInstallErrorCode, +} from '../services/skill-installation.js'; import type { RouteDeps } from '../server-context.js'; export interface RegisterAtomRoutesDeps { @@ -34,12 +57,16 @@ export interface RegisterAtomRoutesDeps { resources: { FIRST_PARTY_ATOMS: Array<{ id: string; taskKinds: string[]; [key: string]: unknown }> }; } -export interface RegisterStaticResourceRoutesDeps extends RouteDeps<'http' | 'paths' | 'resources'> { +export interface RegisterStaticResourceRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'resources'> { + verifyWorkspaceRequestAuthority?: VerifyWorkspaceRequestAuthority; tokenContractRebuild?: { maybeStartForImportedDesignSystem?: ( designSystemId: string, ) => Promise<DesignSystemTokenContractRebuildJobResponse | undefined>; }; + /** Team-resource copy red-line (D3). When present, a frozen team skill cannot + * be edit-shadowed into a personal editable copy. Omit to skip (no-op). */ + teamResources?: TeamResourceStateProvider; } export function registerAtomRoutes(app: Express, ctx: RegisterAtomRoutesDeps) { @@ -69,6 +96,7 @@ export function registerAtomRoutes(app: Express, ctx: RegisterAtomRoutesDeps) { } export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticResourceRoutesDeps) { + const { db } = ctx; const { RUNTIME_DATA_DIR, RUNTIME_DATA_DIR_CANONICAL, @@ -87,14 +115,138 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe listAllDesignTemplates, listAllSkillLikeEntries, listAllDesignSystems, + resolveWorkspaceScope, + canMutateUserDesignSystem, mimeFor, } = ctx.resources; const { isLocalSameOrigin, resolvedPortRef, sendApiError } = ctx.http; + const teamResources = ctx.teamResources; const requireLocalOrigin = (req: any, res: any) => { if (isLocalSameOrigin(req, resolvedPortRef.current)) return true; sendApiError(res, 403, 'FORBIDDEN', 'local origin required'); return false; }; + const sendWorkspaceScopeError = (res: Response, error: unknown): boolean => { + if ( + !error + || typeof error !== 'object' + || !('status' in error) + || (error.status !== 400 && error.status !== 403 && error.status !== 503) + || !('code' in error) + || typeof error.code !== 'string' + ) { + return false; + } + res.status(error.status).json({ + error: error.code, + message: error instanceof Error ? error.message : String(error.code), + ...('retryable' in error && error.retryable === true ? { retryable: true } : {}), + }); + return true; + }; + // Stamp a freshly imported/installed skill with the caller's workspace, the + // same moment plugin install does (`installOrUpgradePlugin` in server.ts). + // A caller with no workspace headers (`od skill import`, a not-logged-in + // web session) leaves the skill unbound — visible everywhere, same as + // every skill imported before this shipped ("no retroactive tagging"). + const bindImportedSkillToWorkspace = ( + authority: WorkspaceCollabContext | null, + skillId: string, + ): void => { + if (!authority) return; + ensureWorkspaceResource(db, 'skill', authority.workspaceId, skillId, { + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: authority.workspaceMemberId, + updatedByWorkspaceMemberId: authority.workspaceMemberId, + }); + }; + const requestWithNavigationScope = (req: any): any | 'conflict' => { + const workspaceId = typeof req.query?.workspaceId === 'string' + ? req.query.workspaceId.trim() + : ''; + const workspaceMemberId = typeof req.query?.workspaceMemberId === 'string' + ? req.query.workspaceMemberId.trim() + : ''; + if (!workspaceId && !workspaceMemberId) return req; + const headerWorkspaceId = req.get('x-od-workspace-id')?.trim() ?? ''; + const headerWorkspaceMemberId = + req.get('x-od-workspace-member-id')?.trim() ?? ''; + if ( + (headerWorkspaceId || headerWorkspaceMemberId) + && ( + headerWorkspaceId !== workspaceId + || headerWorkspaceMemberId !== workspaceMemberId + ) + ) { + return 'conflict'; + } + return { + get(name: string) { + const normalized = name.toLowerCase(); + if (normalized === 'x-od-workspace-id') return workspaceId || undefined; + if (normalized === 'x-od-workspace-member-id') { + return workspaceMemberId || undefined; + } + return req.get(name); + }, + }; + }; + const resolveWorkspaceAuthority = async ( + req: any, + res: Response, + options: { allowNavigationQuery?: boolean } = {}, + ): Promise<WorkspaceCollabContext | null | undefined> => { + const scopedRequest = options.allowNavigationQuery + ? requestWithNavigationScope(req) + : req; + if (scopedRequest === 'conflict') { + sendApiError( + res, + 400, + 'WORKSPACE_CONTEXT_CONFLICT', + 'workspace header and navigation scope must match', + ); + return undefined; + } + const authority = await resolveOptionalWorkspaceRequestAuthority( + scopedRequest, + ctx.verifyWorkspaceRequestAuthority, + ); + if (!authority.ok) { + sendApiError(res, authority.status, authority.code, authority.message, { + ...(authority.retryable ? { retryable: true } : {}), + }); + return undefined; + } + return authority.context; + }; + // Gate a mutation route for a skill bound into `workspace_resources`. Only + // applies when the skill actually carries a binding row (installed/imported + // through the workspace-aware routes above after this shipped) — an unbound + // legacy skill stays outside the isolation regime, mirroring the plugin + // uninstall route's same conditional gate. + const enforceSkillWorkspaceMutation = async ( + req: any, + res: any, + skillId: string, + capability: 'delete' | 'writeFiles', + ): Promise<boolean> => { + const binding = getWorkspaceResourceByResourceId(db, 'skill', skillId); + if (!binding) return true; + return enforceVerifiedWorkspaceResourceMutation( + 'skill', + req, + res, + sendApiError, + (dbArg, workspaceId, resourceId) => getWorkspaceResource(dbArg as typeof db, 'skill', workspaceId, resourceId), + (dbArg, resourceId) => getWorkspaceResourceByResourceId(dbArg as typeof db, 'skill', resourceId), + db, + skillId, + capability, + ctx.verifyWorkspaceRequestAuthority, + ); + }; const importedDesignSystemResponse = async <T extends { id: string }>(designSystem: T) => { let tokenContractRebuild: DesignSystemTokenContractRebuildJobResponse | undefined; try { @@ -161,9 +313,15 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe } }); - app.get('/api/skills', async (_req, res) => { + app.get('/api/skills', async (req, res) => { try { - const skills = await listAllSkills(); + // Workspace-scoped (see `skillVisibleFromWorkspace` in skills.ts): a + // skill imported into a different workspace than the caller's is + // hidden, same one-way rule `GET /api/plugins` already applies. + const authority = await resolveWorkspaceAuthority(req, res); + if (authority === undefined) return; + const workspaceId = authority?.workspaceId ?? null; + const skills = await listAllSkills({ workspaceId }); // Strip full body + on-disk dir from the listing — frontend fetches the // body via /api/skills/:id when needed (keeps the listing payload small). res.json({ @@ -179,7 +337,10 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe app.get('/api/skills/:id', async (req, res) => { try { - const skills = await listAllSkills(); + const authority = await resolveWorkspaceAuthority(req, res); + if (authority === undefined) return; + const workspaceId = authority?.workspaceId ?? null; + const skills = await listAllSkills({ workspaceId }); const skill = findSkillById(skills, req.params.id); if (!skill) return res.status(404).json({ error: 'skill not found' }); const { dir: _dir, ...serializable } = skill; @@ -224,8 +385,11 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe // automatically because listSkills walks USER_SKILLS_DIR first. app.post('/api/skills/import', async (req, res) => { try { + const authority = await resolveWorkspaceAuthority(req, res); + if (authority === undefined) return; const result = await importUserSkill(USER_SKILLS_DIR, req.body || {}); - const skills = await listAllSkills(); + bindImportedSkillToWorkspace(authority, result.id); + const skills = await listAllSkills({ workspaceId: authority?.workspaceId ?? null }); const skill = findSkillById(skills, result.id); if (!skill) { return sendApiError( @@ -258,17 +422,26 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe // the bundled assets/references/scripts/examples). See PR #955 review. app.put('/api/skills/:id', async (req, res) => { try { - const skills = await listAllSkills(); + const authority = await resolveWorkspaceAuthority(req, res); + if (authority === undefined) return; + const skills = await listAllSkills({ workspaceId: authority?.workspaceId ?? null }); const skill = findSkillById(skills, req.params.id); if (!skill) { return sendApiError(res, 404, 'NOT_FOUND', 'skill not found'); } + // AC-9 copy red-line (D3): a frozen team skill cannot be edit-shadowed into + // a personal editable copy. No-op until the resource-hub reports this skill + // as a frozen team resource. + if (teamResources) { + await enforceTeamResourceCopyAllowed(teamResources, { kind: 'skill', resourceId: skill.id }); + } + if (!await enforceSkillWorkspaceMutation(req, res, skill.id, 'writeFiles')) return; const result = await updateUserSkill(USER_SKILLS_DIR, { ...(req.body || {}), id: skill.id, sourceDir: skill.dir, }); - const next = await listAllSkills(); + const next = await listAllSkills({ workspaceId: authority?.workspaceId ?? null }); const updated = findSkillById(next, result.id); if (!updated) { return sendApiError( @@ -286,6 +459,9 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe }, }); } catch (err: any) { + if (err instanceof TeamResourceCopyForbiddenError) { + return sendApiError(res, 403, err.code, err.message); + } if (err instanceof SkillImportError) { const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'BAD_REQUEST' ? 400 : 500; return sendApiError(res, status, err.code, err.message); @@ -299,7 +475,10 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe // file tree (capped server-side to keep payload bounded). app.get('/api/skills/:id/files', async (req, res) => { try { - const skills = await listAllSkills(); + const authority = await resolveWorkspaceAuthority(req, res); + if (authority === undefined) return; + const workspaceId = authority?.workspaceId ?? null; + const skills = await listAllSkills({ workspaceId }); const skill = findSkillById(skills, req.params.id); if (!skill) { return sendApiError(res, 404, 'NOT_FOUND', 'skill not found'); @@ -386,13 +565,37 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe } }); - app.get('/api/design-systems', async (_req, res) => { + app.get('/api/design-systems', async (req, res) => { try { - const systems = await listAllDesignSystems(); - res.json({ - designSystems: systems.map(({ body, ...rest }) => rest), + // The library CATALOG is workspace-scoped (#145): user design systems all + // share one directory on disk, so without this the systems authored in + // one workspace also filled a brand-new one. Every other caller of + // `listAllDesignSystems` resolves a system by id and stays unscoped. + const systems = await listAllDesignSystems({ + workspaceId: (await resolveWorkspaceScope?.(req)) ?? null, }); + // recvqb6mfyqXLD: decorate every teamSynced entry with the same + // mutate verdict the PATCH/DELETE routes enforce, so any surface that + // renders straight off this list (e.g. `ProjectView`'s in-project + // Design System tab, which resolves its own `designSystemEditable` + // from this exact array rather than the single-item detail fetch) can + // gate its Publish toggle / delete affordances on it too — not just + // the detail route. Skipped for anything not `teamSynced` (the + // overwhelming majority: every built-in preset plus the caller's own + // systems) so a hot, frequently-polled list read does not pay a + // per-item disk/hub round trip it already knows the answer to. + const designSystems = canMutateUserDesignSystem + ? await Promise.all( + systems.map(async ({ body, ...rest }) => ( + rest.teamSynced + ? { ...rest, canMutate: await canMutateUserDesignSystem(USER_DESIGN_SYSTEMS_DIR, rest.id, req) } + : rest + )), + ) + : systems.map(({ body, ...rest }) => rest); + res.json({ designSystems }); } catch (err: any) { + if (sendWorkspaceScopeError(res, err)) return; res.status(500).json({ error: String(err) }); } }); @@ -456,7 +659,15 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe // HTML rewrites assets to /api/skills/<id>/... and we want those URLs // to keep resolving regardless of which root owns the backing folder // after the skills/design-templates split. - const skills = await listAllSkillLikeEntries(); + const authority = await resolveWorkspaceAuthority(req, res, { + allowNavigationQuery: true, + }); + if (authority === undefined) return; + const workspaceId = authority?.workspaceId ?? null; + const skills = await listAllSkillLikeEntries({ workspaceId }); + const workspaceQuery = authority + ? `?workspaceId=${encodeURIComponent(authority.workspaceId)}&workspaceMemberId=${encodeURIComponent(authority.workspaceMemberId)}` + : ''; // 1. Derived `<parent>:<child>` id — resolve straight to the matching // file under <parentDir>/examples/. Done before findSkillById so the @@ -477,7 +688,7 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe const html = await fs.promises.readFile(candidate, 'utf8'); return res .type('text/html') - .send(rewriteSkillAssetUrls(html, parent.id)); + .send(rewriteSkillAssetUrls(html, parent.id, workspaceQuery)); } return res .status(404) @@ -495,7 +706,7 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe const html = await fs.promises.readFile(baked, 'utf8'); return res .type('text/html') - .send(rewriteSkillAssetUrls(html, skill.id)); + .send(rewriteSkillAssetUrls(html, skill.id, workspaceQuery)); } const tpl = path.join(skill.dir, 'assets', 'template.html'); @@ -507,7 +718,7 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe const assembled = assembleExample(tplHtml, slidesHtml, skill.name); return res .type('text/html') - .send(rewriteSkillAssetUrls(assembled, skill.id)); + .send(rewriteSkillAssetUrls(assembled, skill.id, workspaceQuery)); } catch { // Fall through to raw template on read failure. } @@ -516,14 +727,14 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe const html = await fs.promises.readFile(tpl, 'utf8'); return res .type('text/html') - .send(rewriteSkillAssetUrls(html, skill.id)); + .send(rewriteSkillAssetUrls(html, skill.id, workspaceQuery)); } const idx = path.join(skill.dir, 'assets', 'index.html'); if (fs.existsSync(idx)) { const html = await fs.promises.readFile(idx, 'utf8'); return res .type('text/html') - .send(rewriteSkillAssetUrls(html, skill.id)); + .send(rewriteSkillAssetUrls(html, skill.id, workspaceQuery)); } // Friendly fallback for skills that aggregate examples in a sibling @@ -551,7 +762,7 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe const html = await fs.promises.readFile(direct, 'utf8'); return res .type('text/html') - .send(rewriteSkillAssetUrls(html, skill.id)); + .send(rewriteSkillAssetUrls(html, skill.id, workspaceQuery)); } catch { continue; } @@ -580,7 +791,12 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe try { // Same rationale as /example above — assets need to resolve whether // the owning skill folder lives under skills/ or design-templates/. - const skills = await listAllSkillLikeEntries(); + const authority = await resolveWorkspaceAuthority(req, res, { + allowNavigationQuery: true, + }); + if (authority === undefined) return; + const workspaceId = authority?.workspaceId ?? null; + const skills = await listAllSkillLikeEntries({ workspaceId }); const skill = findSkillById(skills, req.params.id); if (!skill) { return res.status(404).type('text/plain').send('skill not found'); @@ -610,17 +826,42 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe app.post('/api/skills/install', async (req, res) => { if (!requireLocalOrigin(req, res)) return; try { - const result = await installFromTarget(req.body, USER_SKILLS_DIR, 'skill'); - if (!result.ok) return res.status(400).json({ error: result.error }); + const authority = await resolveWorkspaceAuthority(req, res); + if (authority === undefined) return; + const body = req.body && typeof req.body === 'object' ? req.body : {}; + const isLegacyTarget = + (body.source === 'github' && typeof body.url === 'string') || + (body.source === 'local' && typeof body.path === 'string'); + const result = isLegacyTarget + ? await installFromTarget(body, USER_SKILLS_DIR, 'skill') + : await installSkillFromRemoteSource( + USER_SKILLS_DIR, + typeof body.source === 'string' ? body.source : '', + ); + if (!result.ok) { + const statusByCode: Partial<Record<SkillInstallErrorCode, number>> = { + BAD_REQUEST: 400, + FETCH_FAILED: 502, + INVALID_ARCHIVE: 400, + INVALID_MANIFEST: 400, + CONFLICT: 409, + INTERNAL_ERROR: 500, + }; + const code = 'code' in result ? result.code : undefined; + return res + .status((code && statusByCode[code]) || 400) + .json({ error: result.error, ...(code ? { code } : {}) }); + } if (typeof result.dir !== 'string' || !result.dir) { return res.status(500).json({ error: 'skill install did not return an installation directory' }); } - const skills = await listAllSkills(); + const skills = await listAllSkills({ workspaceId: authority?.workspaceId ?? null }); const installedDir = fs.realpathSync.native(result.dir); const skill = skills.find((candidate) => fs.realpathSync.native(candidate.dir) === installedDir); if (!skill) { return res.status(500).json({ error: `installed skill was not found in catalog: ${result.dir}` }); } + bindImportedSkillToWorkspace(authority, skill.id); res.json({ skill: { ...skill, @@ -634,11 +875,25 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe } }); + // This route used to carry NO permission check at all: any caller (any + // workspace, any role) could delete any skill, including one installed by + // someone else or pulled in from a team share. Now gated the same way + // `POST /api/plugins/:id/uninstall` is, via the shared + // `enforceWorkspaceResourceMutation` — see `enforceSkillWorkspaceMutation` + // above for the "only when a binding row exists" conditional. app.delete('/api/skills/:id', async (req, res) => { if (!requireLocalOrigin(req, res)) return; try { + if (!await enforceSkillWorkspaceMutation(req, res, req.params.id, 'delete')) return; const result = await uninstallById(req.params.id, USER_SKILLS_DIR, SKILLS_DIR, 'skill'); if (!result.ok) return res.status(result.status || 400).json({ error: result.error }); + // Clean up the binding row too — `workspace_resources` has no + // FOREIGN KEY ... ON DELETE CASCADE (see db.ts's doc comment on the + // table), so skipping this would leave an orphan binding that + // re-importing the same skill id would find and silently reuse (stale + // workspace/visibility). A DELETE against a row that never existed is a + // no-op. + deleteWorkspaceResourceByResourceId(db, 'skill', req.params.id); res.json({ ok: true }); } catch (err: any) { res.status(500).json({ error: String(err) }); @@ -885,14 +1140,18 @@ export function assembleExample(templateHtml: string, slidesHtml: string, title: .replace(/<title>.*?<\/title>/, `<title>${title} | Open Design Example`); } -export function rewriteSkillAssetUrls(html: string, skillId: string) { +export function rewriteSkillAssetUrls( + html: string, + skillId: string, + workspaceQuery = '', +) { if (typeof html !== 'string' || html.length === 0) return html; return html.replace( /(\s(?:src|href)\s*=\s*)(['"])((?:\.\.\/([^/'"#?]+)\/)?(?:\.\/)?assets\/([^'"#?]+))(\2)/gi, (_match, attr, openQuote, _fullPath, siblingSkillId, relPath, closeQuote) => { const resolvedSkillId = siblingSkillId || skillId; const prefix = `/api/skills/${encodeURIComponent(resolvedSkillId)}/assets/`; - return `${attr}${openQuote}${prefix}${relPath}${closeQuote}`; + return `${attr}${openQuote}${prefix}${relPath}${workspaceQuery}${closeQuote}`; }, ); } diff --git a/apps/daemon/src/routes/team-resource-share.ts b/apps/daemon/src/routes/team-resource-share.ts new file mode 100644 index 00000000000..7b753cf7cb1 --- /dev/null +++ b/apps/daemon/src/routes/team-resource-share.ts @@ -0,0 +1,163 @@ +import type { Express, Request, Response } from 'express'; +import { + TeamResourceShareForbiddenError, + type TeamResourceRequestScope, + type TeamResourceShareRecord, + type TeamResourceShareService, +} from '../collab/team-resource-share.js'; + +export interface TeamResourceShareListing { + ids: string[]; + resources: TeamResourceShareRecord[]; +} + +export type TeamResourceScopeResolution = + | { ok: true; scope: TeamResourceRequestScope } + | { + ok: false; + status: 400 | 403 | 503; + code: string; + message: string; + retryable?: true; + }; + +export interface RegisterTeamResourceShareRoutesDeps { + /** URL segment for this resource kind: `design-systems` | `plugins` | `skills`. */ + basePath: string; + share: TeamResourceShareService; + /** Resolve the request's explicit Workspace against authoritative membership. */ + resolveScope: (req: Request) => Promise; + /** Optional materialization hook for shared team resources. */ + syncSharedResource?: ( + resource: TeamResourceShareRecord, + scope: TeamResourceRequestScope, + ) => Promise; + /** + * Optional stale-while-revalidate provider for the `/team` list. Each GET + * otherwise hits the resource hub (and re-materializes every shared resource) + * on the request path — slow when the workspace shell re-reads all three kinds + * on navigation. When provided, the route serves this cached listing instead, + * so warm reads return instantly and refresh in the background. + * + * When the provider also exposes `invalidate()` (see `createSwrCache` / + * `cachedTeamResourceList` in server.ts), the share/unshare handlers below + * call it on success so the client's immediate refetch reads the new state + * instead of the pre-change list the cache would otherwise keep serving for + * up to its freshMs (or the client's slower background poll once SSE lowers + * its cadence). Best-effort and optional: a `listTeam` with no `invalidate` + * just falls back to the old behavior of catching up on the next refresh. + */ + listTeam?: ((scope: TeamResourceRequestScope) => Promise) & { + invalidate?: (scope: TeamResourceRequestScope) => void; + }; +} + +/** + * Team resource sharing routes for one resource kind. A member promotes a + * personal resource into the team scope; the share service packs its directory + * and pushes it to the resource hub so teammates can pull it. Every route first + * verifies the request's explicit Workspace/member selector against the + * authoritative directory. A verified request still returns `shared: false` + * when the hub transport is not configured. Mounted once per kind (design + * systems, plugins, skills). + */ +export function registerTeamResourceShareRoutes( + app: Express, + deps: RegisterTeamResourceShareRoutesDeps, +): void { + const { basePath, share } = deps; + const root = `/api/workspace/${basePath}`; + + // The mutation itself already succeeded by the time this runs — a cache seam + // failing here must not turn a successful share/unshare into a reported + // failure, so this is best-effort and swallows its own errors. + function invalidateListTeam(scope: TeamResourceRequestScope): void { + try { + deps.listTeam?.invalidate?.(scope); + } catch { + // ignore + } + } + + async function resolveScope( + req: Request, + res: Response, + ): Promise { + let resolution: TeamResourceScopeResolution; + try { + resolution = await deps.resolveScope(req); + } catch { + res.status(503).json({ + error: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + message: 'workspace membership authority is temporarily unavailable', + retryable: true, + }); + return null; + } + if (resolution.ok) return resolution.scope; + res.status(resolution.status).json({ + error: resolution.code, + message: resolution.message, + ...(resolution.retryable ? { retryable: true } : {}), + }); + return null; + } + + // Ids shared to the team — drives the "team" collection for this kind. + app.get(`${root}/team`, async (req, res) => { + const scope = await resolveScope(req, res); + if (!scope) return; + if (deps.listTeam) { + res.json(await deps.listTeam(scope)); + return; + } + const resources = await share.sharedResources(scope); + if (deps.syncSharedResource) { + await Promise.all(resources.map((resource) => deps.syncSharedResource?.(resource, scope))); + } + res.json({ ids: resources.map((resource) => resource.id), resources }); + }); + + // Share a personal resource to the team. + app.post(`${root}/:id/share`, async (req, res) => { + const id = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id) : ''; + if (!id) return res.status(400).json({ error: 'invalid resource id' }); + const scope = await resolveScope(req, res); + if (!scope) return; + try { + const result = await share.share(id, scope); + if (!result) return res.json({ shared: false }); + // The cached `/team` listing is now stale by construction — drop it so + // the client's immediate refetch (it fires one right after this + // response) reads the new state instead of waiting out the cache's + // freshMs, or worse, the client's slower background poll once SSE + // lowers its cadence. + invalidateListTeam(scope); + res.json({ shared: true, version: result.version }); + } catch (error) { + if (error instanceof TeamResourceShareForbiddenError) { + return res.status(403).json({ error: 'WORKSPACE_RESOURCE_SHARE_DENIED' }); + } + res.status(500).json({ error: error instanceof Error ? error.message : 'share failed' }); + } + }); + + // Remove a resource from the team index. Vela remains the permission source of + // truth: only the resource owner can edit/remove the shared resource. + app.delete(`${root}/:id/share`, async (req, res) => { + const id = typeof req.params.id === 'string' ? decodeURIComponent(req.params.id) : ''; + if (!id) return res.status(400).json({ error: 'invalid resource id' }); + const scope = await resolveScope(req, res); + if (!scope) return; + try { + const unshared = await share.unshare(id, scope); + if (unshared) invalidateListTeam(scope); + res.json({ unshared }); + } catch (error) { + if (error instanceof TeamResourceShareForbiddenError) { + return res.status(403).json({ error: 'WORKSPACE_RESOURCE_UNSHARE_DENIED' }); + } + res.status(500).json({ error: error instanceof Error ? error.message : 'unshare failed' }); + } + }); +} diff --git a/apps/daemon/src/routes/team-resources.ts b/apps/daemon/src/routes/team-resources.ts new file mode 100644 index 00000000000..cd40c33b093 --- /dev/null +++ b/apps/daemon/src/routes/team-resources.ts @@ -0,0 +1,77 @@ +import type { Express } from 'express'; +import { + assertTeamResourceCopyAllowed, + createApiError, + createApiErrorResponse, + TeamResourceCopyForbiddenError, + type TeamResourceState, +} from '@open-design/contracts'; +import type { + TeamResourceKind, + TeamResourceKey, + TeamResourceStateProvider, +} from '../collab/team-resource-state.js'; + +export interface RegisterTeamResourceRoutesDeps { + teamResources: TeamResourceStateProvider; +} + +const KINDS: ReadonlySet = new Set(['design-system', 'plugin', 'skill']); +const STATES: ReadonlySet = new Set(['active', 'frozen', 'deleted']); + +function readKey(params: { kind?: string; id?: string }): TeamResourceKey | null { + const kind = params.kind; + const resourceId = typeof params.id === 'string' ? decodeURIComponent(params.id) : ''; + if (!kind || !KINDS.has(kind as TeamResourceKind) || !resourceId) return null; + return { kind: kind as TeamResourceKind, resourceId }; +} + +/** + * Team-resource routes (D1 state model + D3 enforcement). The copy-check runs + * the real copy red-line guard against the resolved resource state, so a frozen + * team resource is rejected with a 403 the same way the copy-out routes will be. + * The state provider is the E-resource-hub seam (the resource-hub owner). + */ +export function registerTeamResourceRoutes(app: Express, deps: RegisterTeamResourceRoutesDeps): void { + const { teamResources } = deps; + + app.get('/api/workspace/resources/:kind/:id/state', async (req, res) => { + const key = readKey(req.params); + if (!key) return res.status(400).json({ error: 'invalid resource key' }); + res.json(await teamResources.resolve(key)); + }); + + // Enforce the AC-9 copy red-line for a resource about to be copied to personal. + app.post('/api/workspace/resources/:kind/:id/copy-check', async (req, res) => { + const key = readKey(req.params); + if (!key) return res.status(400).json({ error: 'invalid resource key' }); + const target = await teamResources.resolve(key); + try { + assertTeamResourceCopyAllowed(target); + res.json({ allowed: true }); + } catch (error) { + if (error instanceof TeamResourceCopyForbiddenError) { + return res.status(403).json(createApiErrorResponse(createApiError(error.code, error.message))); + } + throw error; + } + }); + + // Dev/demo seam: mark a resource team-shared with a state (real hub-backed + // provider omits `set`, so this 404s in production instead of spoofing state). + app.put('/api/workspace/resources/:kind/:id/state', (req, res) => { + const key = readKey(req.params); + if (!key) return res.status(400).json({ error: 'invalid resource key' }); + if (!teamResources.set) return res.status(404).json({ error: 'resource state is not settable' }); + const body = (req.body ?? {}) as { scope?: unknown; state?: unknown }; + if (body.scope === 'personal') { + teamResources.set(key, { scope: 'personal' }); + return res.json({ scope: 'personal' }); + } + if (body.scope === 'team' && typeof body.state === 'string' && STATES.has(body.state as TeamResourceState)) { + teamResources.set(key, { scope: 'team', state: body.state as TeamResourceState }); + return res.json({ scope: 'team', state: body.state }); + } + res.status(400).json({ error: 'invalid resource state' }); + }); +} diff --git a/apps/daemon/src/routes/terminal.ts b/apps/daemon/src/routes/terminal.ts index ea8711cf55a..f4fc9467fab 100644 --- a/apps/daemon/src/routes/terminal.ts +++ b/apps/daemon/src/routes/terminal.ts @@ -1,10 +1,12 @@ import type { Express } from 'express'; import type { RouteDeps } from '../server-context.js'; import type { createTerminalService } from '../terminals.js'; +import type { AuthorizeProjectRequest } from '../collab/project-request-authority.js'; export interface RegisterTerminalRoutesDeps extends RouteDeps<'db' | 'http' | 'paths' | 'projectStore' | 'projectFiles'> { terminals: ReturnType; + authorizeProjectRequest: AuthorizeProjectRequest; } /** @@ -18,7 +20,7 @@ export interface RegisterTerminalRoutesDeps * editor would open. */ export function registerTerminalRoutes(app: Express, ctx: RegisterTerminalRoutesDeps) { - const { db, terminals } = ctx; + const { db, terminals, authorizeProjectRequest } = ctx; const { sendApiError, createSseResponse } = ctx.http; const { PROJECTS_DIR } = ctx.paths; const { getProject } = ctx.projectStore; @@ -39,10 +41,11 @@ export function registerTerminalRoutes(app: Express, ctx: RegisterTerminalRoutes return session; }; - app.get('/api/projects/:id/terminals', (req, res) => { + app.get('/api/projects/:id/terminals', async (req, res) => { if (!getProject(db, req.params.id)) { return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); } + if (!await authorizeProjectRequest(req, res, req.params.id, { mode: 'read' })) return; res.json({ terminals: terminals.list({ projectId: req.params.id }).map((s) => terminals.statusBody(s)) }); }); @@ -51,6 +54,12 @@ export function registerTerminalRoutes(app: Express, ctx: RegisterTerminalRoutes if (!project) { return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); } + if (!await authorizeProjectRequest( + req, + res, + req.params.id, + { mode: 'write', capability: 'writeFiles' }, + )) return; const body = req.body || {}; const cwd = resolveProjectDir(PROJECTS_DIR, project.id, project.metadata); try { @@ -70,13 +79,25 @@ export function registerTerminalRoutes(app: Express, ctx: RegisterTerminalRoutes } }); - app.get('/api/projects/:id/terminals/:tid/stream', (req, res) => { + app.get('/api/projects/:id/terminals/:tid/stream', async (req, res) => { + if (!await authorizeProjectRequest( + req, + res, + req.params.id, + { mode: 'read', allowNavigationQuery: true }, + )) return; const session = resolveSession(req, res); if (!session) return; terminals.stream(session, req, res, createSseResponse); }); - app.post('/api/projects/:id/terminals/:tid/stdin', (req, res) => { + app.post('/api/projects/:id/terminals/:tid/stdin', async (req, res) => { + if (!await authorizeProjectRequest( + req, + res, + req.params.id, + { mode: 'write', capability: 'writeFiles' }, + )) return; const session = resolveSession(req, res); if (!session) return; const data = req.body?.data; @@ -87,7 +108,13 @@ export function registerTerminalRoutes(app: Express, ctx: RegisterTerminalRoutes res.json({ ok }); }); - app.post('/api/projects/:id/terminals/:tid/resize', (req, res) => { + app.post('/api/projects/:id/terminals/:tid/resize', async (req, res) => { + if (!await authorizeProjectRequest( + req, + res, + req.params.id, + { mode: 'write', capability: 'writeFiles' }, + )) return; const session = resolveSession(req, res); if (!session) return; const { cols, rows } = req.body || {}; @@ -98,7 +125,13 @@ export function registerTerminalRoutes(app: Express, ctx: RegisterTerminalRoutes res.json({ ok, terminal: terminals.statusBody(session) }); }); - const handleKill = (req: any, res: any) => { + const handleKill = async (req: any, res: any) => { + if (!await authorizeProjectRequest( + req, + res, + req.params.id, + { mode: 'write', capability: 'writeFiles' }, + )) return; const session = resolveSession(req, res); if (!session) return; terminals.kill(session, 'SIGTERM'); diff --git a/apps/daemon/src/routes/vela.ts b/apps/daemon/src/routes/vela.ts index 8615efb84e5..6734ea993ad 100644 --- a/apps/daemon/src/routes/vela.ts +++ b/apps/daemon/src/routes/vela.ts @@ -34,6 +34,7 @@ import { readVelaCredentialRevision, readVelaControlApiContext, readVelaLoginStatus, + resolveVelaConsoleOrigin, readVelaLoginAttemptSnapshot, setVelaLiveAccount, shouldRefreshVelaLiveAccount, @@ -57,6 +58,76 @@ const AMR_API_PROXY_PREFIX = '/api/integrations/vela/api-proxy'; const VELA_MESSAGE_CENTER_PREFIX = '/api/integrations/vela/message-center'; const VELA_PUBLIC_MESSAGE_CENTER_PREFIX = '/api/integrations/vela/message-center-public'; const AMR_API_UPSTREAM_ORIGIN = 'https://amr-api.open-design.ai'; +const PROXY_HOP_BY_HOP_HEADERS = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'proxy-connection', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); +const VELA_WORKSPACE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; + +/** + * Upper bound, in ms, on how long a cold-cache `/status` read waits for the + * live billing fetch before answering without `account`. `vela billing + * summary` is a real subprocess spawn (up to the 10s exec timeout in + * fetchVelaBillingSummary) and every logout clears the live-account cache + * (see `clearAllVelaLiveAccounts`), so "sign out then sign back in" always + * lands here cold. Without a bound, a slow or hung billing probe delays the + * whole login-status response — the very check the avatar/menu/settings + * surfaces need FIRST — by however long the subprocess takes. Kept short + * (well under the exec timeout) so /status stays fast even when billing is + * slow; the single-flight fetch is NOT canceled when the wait lapses, so it + * keeps running and populates the cache (see `setVelaLiveAccount`) for the + * next read. Every consumer already re-reads /status on its own (mount, + * window focus/visibilitychange, or the `od:amr-login-status-change` event + * dispatched right after sign-in resolves), so the plan/balance simply + * arrives on that next read instead of holding this one hostage. + */ +const VELA_STATUS_LIVE_ACCOUNT_WAIT_MS = 1_200; + +/** Sentinel returned by {@link raceVelaLiveAccountFetch} when the wait lapses. */ +const VELA_LIVE_ACCOUNT_PENDING = Symbol('vela-live-account-pending'); + +/** + * Race an in-flight live-account fetch against a short timeout. Resolves with + * the fetched account (or null on failure — the fetch itself never rejects, + * see {@link fetchVelaLiveAccountSingleFlight}'s `.catch`) when it lands + * before `timeoutMs`; otherwise resolves with the pending sentinel WITHOUT + * touching `pending` — the fetch keeps running and still populates the + * live-account cache when it eventually settles. + */ +function raceVelaLiveAccountFetch( + pending: Promise, + timeoutMs: number, +): Promise { + return new Promise((resolve) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + resolve(VELA_LIVE_ACCOUNT_PENDING); + }, timeoutMs); + pending.then( + (account) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(account); + }, + () => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(null); + }, + ); + }); +} type ReadAppConfig = (dataDir: string) => Promise; type PublicBaseUrlResolver = (req: Request) => string; @@ -139,6 +210,21 @@ function shouldStreamVelaProxyRequest(req: Request, body: Buffer | null): boolea return req.method !== 'GET' && req.method !== 'HEAD' && body == null; } +function connectionHeaderTokens(value: string | string[] | undefined): Set { + const values = Array.isArray(value) ? value : value === undefined ? [] : [value]; + return new Set( + values + .flatMap((entry) => entry.split(',')) + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean), + ); +} + +function isProxyHopByHopHeader(name: string, connectionTokens: Set): boolean { + const lower = name.toLowerCase(); + return PROXY_HOP_BY_HOP_HEADERS.has(lower) || connectionTokens.has(lower); +} + /** * Pipe one leg of the AMR proxy with an explicit source-error guard. * @@ -167,16 +253,29 @@ function proxyAmrApiRequest(req: Request, res: Response): void { return; } const target = new URL(suffix, AMR_API_UPSTREAM_ORIGIN); + if (!target.pathname.startsWith('/api/v1/')) { + res.status(404).json({ error: 'unknown_amr_api_proxy_path' }); + return; + } + const workspaceId = req.headers['x-vela-workspace-id']; + if ( + workspaceId !== undefined + && (Array.isArray(workspaceId) || !VELA_WORKSPACE_ID_PATTERN.test(workspaceId)) + ) { + res.status(400).json({ error: 'invalid_workspace_id' }); + return; + } + const requestConnectionTokens = connectionHeaderTokens(req.headers.connection); + if (workspaceId !== undefined && requestConnectionTokens.has('x-vela-workspace-id')) { + res.status(400).json({ error: 'invalid_workspace_id' }); + return; + } const body = velaProxyRequestBody(req); const streamBody = shouldStreamVelaProxyRequest(req, body); const headers: Record = {}; for (const [key, value] of Object.entries(req.headers)) { const lower = key.toLowerCase(); - if ( - lower === 'host' || - lower === 'connection' || - lower === 'transfer-encoding' - ) { + if (lower === 'host' || isProxyHopByHopHeader(lower, requestConnectionTokens)) { continue; } if (lower === 'content-length' && body) continue; @@ -195,8 +294,14 @@ function proxyAmrApiRequest(req: Request, res: Response): void { }, (upstreamRes) => { res.status(upstreamRes.statusCode ?? 502); + const responseConnectionTokens = connectionHeaderTokens(upstreamRes.headers.connection); for (const [key, value] of Object.entries(upstreamRes.headers)) { - if (value !== undefined) res.setHeader(key, value); + if ( + value !== undefined + && !isProxyHopByHopHeader(key, responseConnectionTokens) + ) { + res.setHeader(key, value); + } } pipeProxyStreamWithGuard(upstreamRes, res, (err) => { if (!res.headersSent) { @@ -215,6 +320,11 @@ function proxyAmrApiRequest(req: Request, res: Response): void { res.end(); } }); + const abortUpstream = () => { + if (!res.writableEnded && !upstream.destroyed) upstream.destroy(); + }; + req.once('aborted', abortUpstream); + res.once('close', abortUpstream); if (body) upstream.write(body); if (streamBody) { pipeProxyStreamWithGuard(req, upstream, () => upstream.destroy()); @@ -400,6 +510,12 @@ export function registerVelaRoutes(app: Express, deps: RegisterVelaRoutesDeps): const configuredEnv = agentCliEnvForAgent(appConfig.agentCliEnv, 'amr'); const refresh = _req.query.refresh === '1' || _req.query.refresh === 'true'; const status = readVelaLoginStatus(mergeVelaEnv(env, configuredEnv)); + // Reported on every response, signed in or not: the client builds console + // links (wallet, plans, upgrade) from it and must not have to carry a + // hostname table for internal AMR environments. Absent for prod/fork + // builds, where the client keeps using the public product console. + const consoleOrigin = resolveVelaConsoleOrigin(env); + if (consoleOrigin) status.consoleOrigin = consoleOrigin; if (status.loggedIn) { // Key the live-account cache by the full credential revision (not just // profile) so a logout / account switch can never surface the previous @@ -417,20 +533,32 @@ export function registerVelaRoutes(app: Express, deps: RegisterVelaRoutesDeps): }); applyVelaLiveAccount(status, liveAccount); } else if (!cachedAccount) { - // Cold cache (or a fetch already in flight): BLOCK on the single-flight - // billing fetch so the first open already carries plan/balance. The - // consumers (settings card, inline switcher, avatar) read /status once - // and do not re-poll, so returning config-only here would hide the - // fields until the user refocuses. On failure the helper resolves null - // and the refresh throttle becomes a short negative cache/backoff, so - // repeated menu/focus polls degrade to config-only instead of each - // awaiting the same optional billing probe. - const liveAccount = + // Cold cache (or a fetch already in flight): wait up to + // VELA_STATUS_LIVE_ACCOUNT_WAIT_MS for the single-flight billing + // fetch so the first open still carries plan/balance in the common + // case (billing typically answers in well under a second). On + // failure the helper resolves null and the refresh throttle + // becomes a short negative cache/backoff, so repeated menu/focus + // polls degrade to config-only instead of each awaiting the same + // slow probe. If billing is genuinely slow (or hung), the wait + // lapses and this response goes out with `account` absent rather + // than blocking the login-status check itself — the fetch is left + // running and populates the cache for the next /status read (see + // VELA_STATUS_LIVE_ACCOUNT_WAIT_MS's docblock for why that is + // always reached soon after). + if ( inFlightVelaAccountFetches.has(accountCacheKey) || shouldRefreshVelaLiveAccount(accountCacheKey) - ? await fetchVelaLiveAccountSingleFlight(accountCacheKey, probe) - : null; - applyVelaLiveAccount(status, liveAccount); + ) { + const pending = fetchVelaLiveAccountSingleFlight(accountCacheKey, probe); + const liveAccount = await raceVelaLiveAccountFetch( + pending, + VELA_STATUS_LIVE_ACCOUNT_WAIT_MS, + ); + if (liveAccount !== VELA_LIVE_ACCOUNT_PENDING) { + applyVelaLiveAccount(status, liveAccount); + } + } } else { // Warm cache: serve it immediately; refresh in the background for the // next poll once the TTL has lapsed. diff --git a/apps/daemon/src/routines.ts b/apps/daemon/src/routines.ts index 69ab0604242..37de4ac4abf 100644 --- a/apps/daemon/src/routines.ts +++ b/apps/daemon/src/routines.ts @@ -39,6 +39,10 @@ export interface RoutineContextSelection { pluginIds?: string[]; mcpServerIds?: string[]; connectorIds?: string[]; + workspaceScope?: { + workspaceId: string; + workspaceMemberId: string; + } | null; } export interface Routine { diff --git a/apps/daemon/src/run-failure-classification.ts b/apps/daemon/src/run-failure-classification.ts index dd833aa7ebe..cc872f8ce7c 100644 --- a/apps/daemon/src/run-failure-classification.ts +++ b/apps/daemon/src/run-failure-classification.ts @@ -259,7 +259,7 @@ function promptTooLargeDetail(text: string): TrackingRunFailureDetail | null { // `prefill context too large` is the local-runtime (MLX) shape of the same // "the prompt does not fit" failure that currently leaks into execution_failed. if ( - /\b(context window|context size (?:has been )?exceeded|prompt too large|maximum context|too many tokens|input.*too large|request (?:body )?exceeds configured limit|output token maximum|maximum output tokens|CLAUDE_CODE_MAX_OUTPUT_TOKENS|exceeds the safe size|composed prompt exceeds|prompt token count .* exceeds|maximum context length|context too large|prefill context too large|reduce the length of (?:the )?(?:messages|input prompt)|request \(\d+ tokens\) exceeds the available context size|n_keep:\s*\d+\s*>=\s*n_ctx)\b/i.test(text) + /\b(context window|context size (?:has been )?exceeded|prompt too large|request_too_large|maximum context|too many tokens|input.*too large|request (?:body )?exceeds configured limit|output token maximum|maximum output tokens|CLAUDE_CODE_MAX_OUTPUT_TOKENS|exceeds the safe size|composed prompt exceeds|prompt token count .* exceeds|maximum context length|context too large|prefill context too large|reduce the length of (?:the )?(?:messages|input prompt)|request \(\d+ tokens\) exceeds the available context size|n_keep:\s*\d+\s*>=\s*n_ctx)\b/i.test(text) ) { return 'prompt_too_large'; } @@ -292,7 +292,7 @@ function clientRequestFailureDetail(text: string): TrackingRunFailureDetail | nu function isUpstreamDetailText(text: string): boolean { return isUpstreamClientErrorText(text) || - /\b(stream disconnected before completion|(?:stream|upstream) idle timeout|response\.completed|Transport error: network error|Upstream request failed|websocket closed|socket connection was closed unexpectedly|tls handshake eof|Connection reset by (?:peer|server)|TLS close_notify|Broken pipe|remote host|远程主机强迫关闭|No route to host|Connection refused|ConnectionRefused|error sending request|Provider returned error|high demand|model is at capacity|selected model is at capacity|temporarily unavailable|upstream_error|http2: response body closed|peer closed connection|incomplete chunked read|Client network socket disconnected before secure TLS connection|Connection failed repeatedly|lost its connection to (?:the Anthropic API|the configured custom Anthropic endpoint)|Server error mid-response|empty or malformed response|Unexpected server error|Streaming response failed|Failed to process error response|AMR model catalog is (?:temporarily )?unavailable)\b/i + /\b(stream disconnected before completion|(?:stream|upstream) idle timeout|no data received within configured window|response\.completed|Transport error: network error|Upstream request failed|websocket closed|socket connection was closed unexpectedly|tls handshake eof|Connection reset by (?:peer|server)|TLS close_notify|Broken pipe|remote host|远程主机强迫关闭|No route to host|Connection refused|ConnectionRefused|error sending request|Provider returned error|high demand|model is at capacity|selected model is at capacity|temporarily unavailable|upstream_error|http2: response body closed|peer closed connection|incomplete chunked read|Client network socket disconnected before secure TLS connection|Connection failed repeatedly|lost its connection to (?:the Anthropic API|the configured custom Anthropic endpoint)|Server error mid-response|empty or malformed response|Unexpected server error|Streaming response failed|Failed to process error response|AMR model catalog is (?:temporarily )?unavailable)\b/i .test(text); } @@ -387,7 +387,7 @@ function upstreamDetail(text: string): TrackingRunFailureDetail { return 'provider_routing_error'; } if (/\bhigh demand|temporary errors|model is at capacity|selected model is at capacity\b/i.test(text)) return 'provider_high_demand'; - if (/\b(stream disconnected before completion|(?:stream|upstream) idle timeout|response\.completed|websocket closed|socket connection was closed unexpectedly|connection reset|ConnectionRefused|tls handshake eof|tls close_notify|broken pipe|peer closed connection|remote host|远程主机强迫关闭|http2: response body closed|incomplete chunked read|Client network socket disconnected before secure TLS connection|Connection failed repeatedly|lost its connection to (?:the Anthropic API|the configured custom Anthropic endpoint)|Server error mid-response|empty or malformed response|Streaming response failed)\b/i + if (/\b(stream disconnected before completion|(?:stream|upstream) idle timeout|no data received within configured window|response\.completed|websocket closed|socket connection was closed unexpectedly|connection reset|ConnectionRefused|tls handshake eof|tls close_notify|broken pipe|peer closed connection|remote host|远程主机强迫关闭|http2: response body closed|incomplete chunked read|Client network socket disconnected before secure TLS connection|Connection failed repeatedly|lost its connection to (?:the Anthropic API|the configured custom Anthropic endpoint)|Server error mid-response|empty or malformed response|Streaming response failed)\b/i .test(text)) { return 'stream_disconnected'; } diff --git a/apps/daemon/src/runtimes/chat-run-lifecycle.ts b/apps/daemon/src/runtimes/chat-run-lifecycle.ts index 1a691c4986f..e97ce613357 100644 --- a/apps/daemon/src/runtimes/chat-run-lifecycle.ts +++ b/apps/daemon/src/runtimes/chat-run-lifecycle.ts @@ -1,4 +1,5 @@ const DEFAULT_CHAT_RUN_INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; +const DEFAULT_CHAT_RUN_FIRST_OUTPUT_TIMEOUT_MS = 0; const MAX_CHAT_RUN_INACTIVITY_TIMEOUT_MS = 24 * 60 * 60 * 1000; const DEFAULT_CHAT_RUN_ARTIFACT_QUIET_PERIOD_MS = 60 * 1000; @@ -12,6 +13,16 @@ export function assertValidRuntimeDefInactivityTimeoutMs(agentDefault?: number): } } +export function assertValidRuntimeDefFirstOutputTimeoutMs(agentDefault?: number): void { + if (agentDefault === undefined) return; + if (!Number.isFinite(agentDefault) || agentDefault < 0 || !Number.isInteger(agentDefault)) { + throw new RangeError( + `RuntimeAgentDef.firstOutputTimeoutMs must be a non-negative integer, got ${String(agentDefault)}. ` + + 'Fix the runtime def — invalid values used to silently disable the watchdog.', + ); + } +} + export function resolveChatRunInactivityTimeoutMs(agentDefault?: number) { assertValidRuntimeDefInactivityTimeoutMs(agentDefault); const env = Number(process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS); @@ -24,6 +35,18 @@ export function resolveChatRunInactivityTimeoutMs(agentDefault?: number) { return DEFAULT_CHAT_RUN_INACTIVITY_TIMEOUT_MS; } +export function resolveChatRunFirstOutputTimeoutMs(agentDefault?: number): number { + assertValidRuntimeDefFirstOutputTimeoutMs(agentDefault); + const env = Number(process.env.OD_CHAT_RUN_FIRST_OUTPUT_TIMEOUT_MS); + if (Number.isFinite(env)) { + return Math.min(MAX_CHAT_RUN_INACTIVITY_TIMEOUT_MS, Math.max(0, Math.floor(env))); + } + if (agentDefault !== undefined) { + return Math.min(MAX_CHAT_RUN_INACTIVITY_TIMEOUT_MS, agentDefault); + } + return DEFAULT_CHAT_RUN_FIRST_OUTPUT_TIMEOUT_MS; +} + export function resolveChatRunArtifactQuietPeriodMs() { const raw = Number(process.env.OD_CHAT_RUN_ARTIFACT_QUIET_PERIOD_MS); if (!Number.isFinite(raw)) return DEFAULT_CHAT_RUN_ARTIFACT_QUIET_PERIOD_MS; diff --git a/apps/daemon/src/runtimes/defs/amr.ts b/apps/daemon/src/runtimes/defs/amr.ts index 620b8758b70..a198e957243 100644 --- a/apps/daemon/src/runtimes/defs/amr.ts +++ b/apps/daemon/src/runtimes/defs/amr.ts @@ -668,4 +668,9 @@ export const amrAgentDef = { // provider is still working. Keep the outer chat watchdog aligned with the // 30-minute ACP stage timeout so the daemon does not fail the run first. inactivityTimeoutMs: 30 * 60 * 1000, + // Once the ACP handshake has completed and session/prompt is waiting on the + // provider, transport/status heartbeats must not leave the UI in Preparing + // indefinitely. Two minutes leaves conservative provider-startup headroom + // while still bounding the user's wait and one safe same-run retry. + firstOutputTimeoutMs: 2 * 60 * 1000, } satisfies RuntimeAgentDef; diff --git a/apps/daemon/src/runtimes/detection.ts b/apps/daemon/src/runtimes/detection.ts index 20688ec0d08..9527757c1fc 100644 --- a/apps/daemon/src/runtimes/detection.ts +++ b/apps/daemon/src/runtimes/detection.ts @@ -326,9 +326,9 @@ function stripFns( // `fallbackModels` slot here too. `helpArgs` / `capabilityFlags` / // `fallbackBins` / `maxPromptArgBytes` / `env` are probe-or-spawn-only // metadata and shouldn't bleed into the API response either. - // `inactivityTimeoutMs` is a spawn-time hint for the chat-run watchdog - // and is not part of the public AgentInfo contract — strip it here so - // the runtime registry stays the only consumer. + // Runtime timeout fields are spawn-time hints for chat-run watchdogs and + // are not part of the public AgentInfo contract — strip them here so the + // runtime registry stays the only consumer. const { buildArgs, listModels, @@ -341,6 +341,7 @@ function stripFns( maxPromptArgBytes, env, inactivityTimeoutMs, + firstOutputTimeoutMs, authProbe, ...rest } = def; diff --git a/apps/daemon/src/runtimes/env.ts b/apps/daemon/src/runtimes/env.ts index 6fedf78c7ed..f9d3ffe9a93 100644 --- a/apps/daemon/src/runtimes/env.ts +++ b/apps/daemon/src/runtimes/env.ts @@ -195,6 +195,11 @@ export function openDesignAmrTraceEnv(input: { runId: string; conversationId?: string | null; runAttempt: number; + // The exact persisted Workspace binding for this run's project, whether + // Team or Personal. Never derive it from an account-level current/active + // selection. Omission means the historical project is genuinely unbound; + // Vela/AMR owns the resulting wallet and membership decision. + workspaceId?: string | null; externalPluginAnalytics?: Record | null; }): NodeJS.ProcessEnv { if (input.agentId !== 'amr') return {}; @@ -208,6 +213,7 @@ export function openDesignAmrTraceEnv(input: { } const conversationId = input.conversationId?.trim(); + const workspaceId = input.workspaceId?.trim(); const plugin = input.externalPluginAnalytics; const bounded = (key: string, max = 128): string | null => { const value = plugin?.[key]; @@ -225,6 +231,7 @@ export function openDesignAmrTraceEnv(input: { OPEN_DESIGN_RUN_ID: runId, OPEN_DESIGN_RUN_ATTEMPT: String(Math.floor(input.runAttempt)), ...(conversationId ? { OPEN_DESIGN_SESSION_ID: conversationId } : {}), + ...(workspaceId ? { OPEN_DESIGN_WORKSPACE_ID: workspaceId } : {}), ...(bounded('pluginWorkflowId') ? { OPEN_DESIGN_PLUGIN_WORKFLOW_ID: bounded('pluginWorkflowId')! } : {}), diff --git a/apps/daemon/src/runtimes/executables.ts b/apps/daemon/src/runtimes/executables.ts index a9b3c46adff..f35c54bd793 100644 --- a/apps/daemon/src/runtimes/executables.ts +++ b/apps/daemon/src/runtimes/executables.ts @@ -180,7 +180,7 @@ function configuredExecutableOverride( ): string | null { const envKey = AGENT_BIN_ENV_KEYS.get(def?.id); if (!envKey) return null; - return executableFilePath(configuredEnv?.[envKey]); + return executableFilePath(configuredEnv?.[envKey] ?? process.env[envKey]); } export function resolveAmrOpenCodeExecutable( @@ -188,6 +188,23 @@ export function resolveAmrOpenCodeExecutable( ): string | null { const configured = executableFilePath(env.VELA_OPENCODE_BIN); if (configured) return configured; + // A selected Vela release is a two-part runtime: the CLI binary and the + // exact OpenCode companion shipped beside it. Prefer that companion before + // looking at the host PATH. Otherwise a Settings/VELA_BIN override can run + // against an unrelated wrapper or incompatible global OpenCode even though + // the selected Vela package already contains its known-good runtime. + const selectedVela = executableFilePath(env.VELA_BIN); + if (selectedVela) { + const selectedCompanion = executableFilePath( + path.join( + path.dirname(selectedVela), + 'libexec', + 'opencode', + process.platform === 'win32' ? 'opencode.exe' : 'opencode', + ), + ); + if (selectedCompanion) return selectedCompanion; + } // In packaged builds prefer the bundled companion under // `OD_RESOURCE_ROOT/bin/libexec/opencode/opencode` so a stale global // `opencode` on the user's PATH can't override the known-good build that diff --git a/apps/daemon/src/runtimes/json-event-stream.ts b/apps/daemon/src/runtimes/json-event-stream.ts index 74c0ce63605..0e005f46e62 100644 --- a/apps/daemon/src/runtimes/json-event-stream.ts +++ b/apps/daemon/src/runtimes/json-event-stream.ts @@ -11,6 +11,12 @@ type ParserState = { codexErrorEmitted: boolean; codexPreviousEventWasAgentMessage: boolean; codexLastAgentMessageEndedWithNewline: boolean; + // Per reasoning-item chars already emitted as thinking deltas, keyed by + // item id. Codex replays the accumulated summary text on every lifecycle + // event of the same item (started → updated → completed), so only the + // unseen suffix may be re-emitted. + codexReasoningEmittedByItem: Map; + codexReasoningEmittedAny: boolean; suppressNextArtifactText: boolean; suppressDuplicateArtifactText: boolean; artifactOpenCandidate: string; @@ -655,6 +661,42 @@ function handleCursorEvent(obj: unknown, onEvent: StreamEventHandler, state: Par return false; } +/** + * Codex streams model reasoning as summary items (`item.started` / + * `item.updated` / `item.completed` with `item.type === 'reasoning'`, the + * summary text accumulated on `item.text`). Emit the unseen suffix of each + * item as `thinking_delta` so the web's collapsible thinking block has real + * content behind the "Thinking" label; distinct reasoning items are joined + * with a blank line because the web folds consecutive thinking deltas into + * one block. Idempotent across repeated lifecycle events of the same item. + */ +function emitCodexReasoningItem( + obj: JsonObject, + onEvent: StreamEventHandler, + state: ParserState, +): boolean { + if ( + obj.type !== 'item.started' && + obj.type !== 'item.updated' && + obj.type !== 'item.completed' + ) { + return false; + } + if (!isRecord(obj.item) || obj.item.type !== 'reasoning') return false; + const key = typeof obj.item.id === 'string' ? obj.item.id : ''; + const text = typeof obj.item.text === 'string' ? obj.item.text : ''; + const emitted = state.codexReasoningEmittedByItem.get(key) ?? 0; + if (text.length > emitted) { + const suffix = text.slice(emitted); + const delta = + emitted === 0 && state.codexReasoningEmittedAny ? `\n\n${suffix}` : suffix; + onEvent({ type: 'thinking_delta', delta }); + state.codexReasoningEmittedByItem.set(key, text.length); + state.codexReasoningEmittedAny = true; + } + return true; +} + function handleCodexEvent(obj: unknown, onEvent: StreamEventHandler, state: ParserState): boolean { if (!isRecord(obj)) return false; @@ -709,6 +751,8 @@ function handleCodexEvent(obj: unknown, onEvent: StreamEventHandler, state: Pars return true; } + if (emitCodexReasoningItem(obj, onEvent, state)) return true; + if (obj.type === 'item.started' && isRecord(obj.item)) { const item = obj.item; if (emitCodexTodoList(item, onEvent)) { @@ -750,6 +794,16 @@ function handleCodexEvent(obj: unknown, onEvent: StreamEventHandler, state: Pars state.codexLastAgentMessageEndedWithNewline = false; return true; } + // Codex reports non-fatal in-stream notices (e.g. the skills + // context-budget warning) as `error` ITEMS while the turn keeps running; + // fatal failures arrive separately as top-level `error` / `turn.failed` + // events. Surface these as a visible warning pill instead of dropping + // them as raw noise — during a silent provider hang such an item can be + // the only signal the user ever gets (incident recvqgLmAkUM6G). + if (item.type === 'error' && typeof item.message === 'string' && item.message.length > 0) { + onEvent({ type: 'status', label: 'warning', detail: item.message }); + return true; + } if (item.type === 'command_execution' && typeof item.id === 'string') { state.codexPreviousEventWasAgentMessage = false; state.codexLastAgentMessageEndedWithNewline = false; @@ -826,6 +880,8 @@ export function createJsonEventStreamHandler(kind: ParserKind, onEvent: StreamEv codexErrorEmitted: false, codexPreviousEventWasAgentMessage: false, codexLastAgentMessageEndedWithNewline: false, + codexReasoningEmittedByItem: new Map(), + codexReasoningEmittedAny: false, suppressNextArtifactText: false, suppressDuplicateArtifactText: false, artifactOpenCandidate: '', diff --git a/apps/daemon/src/runtimes/project-amr-trace-env.ts b/apps/daemon/src/runtimes/project-amr-trace-env.ts new file mode 100644 index 00000000000..58f2f2e9281 --- /dev/null +++ b/apps/daemon/src/runtimes/project-amr-trace-env.ts @@ -0,0 +1,130 @@ +import { getWorkspaceProjectByProjectId } from '../db.js'; +import { openDesignAmrTraceEnv } from './env.js'; + +type SqliteDb = Parameters[0]; + +export type PinnedRunWorkspaceScope = Readonly<{ + schemaVersion: 1; + projectId: string; + workspaceId: string; + source: 'persisted_project_binding'; +}>; + +/** + * The spawn wallet is an address, not a local authorization decision. + * + * A persisted project binding always supplies its exact Workspace id to AMR. + * The authenticated Vela/AMR backend remains the authority for membership, + * balance, and billing eligibility. A daemon directory outage or stale + * membership view therefore cannot silently move a run to the Personal wallet. + */ +export type ProjectWorkspaceScopeOutcomeKind = + | 'resolved_persisted_binding' + | 'refused_unbound'; + +export interface ProjectWorkspaceScopeOutcome { + kind: ProjectWorkspaceScopeOutcomeKind; + projectId: string; + workspaceId: string | null; +} + +export class AmrWorkspaceScopeRequiredError extends Error { + readonly code = 'AMR_WORKSPACE_SCOPE_REQUIRED'; + readonly projectId: string | null; + + constructor(projectId: string | null) { + super( + projectId + ? `AMR Cloud requires project ${projectId} to be bound to a Workspace before running` + : 'AMR Cloud requires a Workspace-bound project before running', + ); + this.name = 'AmrWorkspaceScopeRequiredError'; + this.projectId = projectId; + } +} + +/** + * Freeze the billing address before a run is created. + * + * This is the only function in the run path that reads the mutable project + * binding. Its result is stored on the run and reused for every attempt. + */ +export function pinRunWorkspaceScopeForProject( + db: SqliteDb, + projectId: string, +): PinnedRunWorkspaceScope | null { + const normalizedProjectId = projectId.trim(); + if (!normalizedProjectId) return null; + const binding = getWorkspaceProjectByProjectId(db, normalizedProjectId); + const workspaceId = + typeof binding?.workspaceId === 'string' && binding.workspaceId.trim() + ? binding.workspaceId.trim() + : null; + if (!workspaceId) return null; + return Object.freeze({ + schemaVersion: 1, + projectId: normalizedProjectId, + workspaceId, + source: 'persisted_project_binding', + }); +} + +/** + * Build the AMR trace environment solely from the run's frozen binding. + * + * The request shell's active/current Workspace, the local membership + * directory, and the project's current binding are intentionally absent. + * Project A stays pinned to A for initial spawn and every retry even if the UI + * switches to B, authority lookup is unavailable, or the project is later + * rebound. Vela/AMR receives the signed-in account credentials plus + * `OPEN_DESIGN_WORKSPACE_ID=A` and remains the final authorization/billing + * authority. Missing proof is refused instead of falling through to Personal. + */ +export async function openDesignAmrTraceEnvForRun( + input: { + agentId: string; + runId: string; + conversationId?: string | null; + runAttempt: number; + projectId?: string | null; + workspaceScope?: PinnedRunWorkspaceScope | null; + externalPluginAnalytics?: Record | null; + }, + deps: { + onWorkspaceScopeOutcome?: (outcome: ProjectWorkspaceScopeOutcome) => void; + } = {}, +): Promise { + const traceInput = { + agentId: input.agentId, + runId: input.runId, + runAttempt: input.runAttempt, + ...(input.conversationId !== undefined + ? { conversationId: input.conversationId } + : {}), + ...(input.externalPluginAnalytics !== undefined + ? { externalPluginAnalytics: input.externalPluginAnalytics } + : {}), + }; + if (input.agentId !== 'amr') return openDesignAmrTraceEnv(traceInput); + + const projectId = input.projectId?.trim(); + if (!projectId) throw new AmrWorkspaceScopeRequiredError(null); + const workspaceId = + input.workspaceScope?.projectId === projectId + && input.workspaceScope.source === 'persisted_project_binding' + && input.workspaceScope.schemaVersion === 1 + && input.workspaceScope.workspaceId.trim() + ? input.workspaceScope.workspaceId.trim() + : null; + deps.onWorkspaceScopeOutcome?.({ + kind: workspaceId ? 'resolved_persisted_binding' : 'refused_unbound', + projectId, + workspaceId, + }); + if (!workspaceId) throw new AmrWorkspaceScopeRequiredError(projectId); + + return openDesignAmrTraceEnv({ + ...traceInput, + workspaceId, + }); +} diff --git a/apps/daemon/src/runtimes/runs.ts b/apps/daemon/src/runtimes/runs.ts index 47263a730a9..1ffc6bd15c0 100644 --- a/apps/daemon/src/runtimes/runs.ts +++ b/apps/daemon/src/runtimes/runs.ts @@ -71,6 +71,7 @@ function durableRunState(run) { ? { designSystemSelectionSource: run.designSystemSelectionSource } : {}), ...(typeof run.clientType === 'string' ? { clientType: run.clientType } : {}), + ...(run.workspaceScope !== undefined ? { workspaceScope: run.workspaceScope } : {}), ...(run.analyticsTelemetry ? { analyticsTelemetry: run.analyticsTelemetry } : {}), ...(run.promptTelemetry ? { promptTelemetry: run.promptTelemetry } : {}), ...(run.promptCache ? { promptCache: run.promptCache } : {}), @@ -401,6 +402,9 @@ export function createChatRunService({ manualResumeAttemptCount: 0, rechargeWaitDurationMs: 0, }; + if (Object.prototype.hasOwnProperty.call(meta, 'workspaceScope')) { + run.workspaceScope = meta.workspaceScope ?? null; + } runs.set(run.id, run); if (run.clientRequestId) runIdsByClientRequestId.set(run.clientRequestId, run.id); if ( diff --git a/apps/daemon/src/runtimes/types.ts b/apps/daemon/src/runtimes/types.ts index 8918908177f..5b25aef7042 100644 --- a/apps/daemon/src/runtimes/types.ts +++ b/apps/daemon/src/runtimes/types.ts @@ -229,6 +229,12 @@ export type RuntimeAgentDef = { // default. Operators can still override per-process via // `OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS` — that env wins. inactivityTimeoutMs?: number; + // Absolute ceiling between the runtime announcing that it is waiting for + // model output and the first substantive text/thinking/tool/artifact event. + // Unlike `inactivityTimeoutMs`, transport heartbeats and status events do not + // extend this deadline. Disabled when omitted; operators can override via + // `OD_CHAT_RUN_FIRST_OUTPUT_TIMEOUT_MS`. + firstOutputTimeoutMs?: number; // Opt-in compatibility for ACP adapters that terminate a prompt with a // `turn_end` session update rather than a session/prompt RPC response. acpTurnEndCompletesPrompt?: boolean; @@ -272,13 +278,14 @@ export type DetectedAgent = Omit< | 'versionProbeTimeoutMs' | 'maxPromptArgBytes' | 'env' - // `inactivityTimeoutMs` is a spawn-time-only hint consumed by the - // chat-run watchdog. It is not part of the public `/api/agents` + // Runtime timeout fields are spawn-time-only hints consumed by chat-run + // watchdogs. They are not part of the public `/api/agents` // contract (`packages/contracts/src/api/registry.ts#AgentInfo`), so - // omitting it here keeps the daemon response aligned with that + // omitting them here keeps the daemon response aligned with that // shared web/CLI shape — agents pick it up by reading the runtime // def directly, the registry payload stays unchanged. | 'inactivityTimeoutMs' + | 'firstOutputTimeoutMs' | 'authProbe' > & { models: RuntimeModelOption[]; diff --git a/apps/daemon/src/server-context.ts b/apps/daemon/src/server-context.ts index 2a39ddb108e..70a686d7f3e 100644 --- a/apps/daemon/src/server-context.ts +++ b/apps/daemon/src/server-context.ts @@ -3,6 +3,11 @@ import type { SkillInfo } from './skills.js'; import type { DesignSystemSummary } from './design-systems/index.js'; import type { RoutineRoutesService } from './routes/routine.js'; import type { OpenDesignPublicMetadataService } from './services/open-design-public-metadata.js'; +import type { ResourceHubPrincipal } from './collab/resource-principal.js'; +import type { + AuthorizeProjectRequest, + AuthorizeProjectToolRequest, +} from './collab/project-request-authority.js'; export interface HttpDeps { createSseResponse: (...args: any[]) => any; @@ -44,8 +49,30 @@ export interface PathDeps { export interface ResourceDeps { FIRST_PARTY_ATOMS?: Array; - listAllDesignSystems: () => Promise>; - listAllSkills: () => Promise>; + // `workspaceId` scopes the user half of the catalog to one workspace (#145). + // Omit it to resolve a design system by id from anywhere. + listAllDesignSystems: (options?: { + workspaceId?: string | null; + }) => Promise>; + // The workspace a catalog read should be scoped to (#145). Data-plane reads + // resolve it from this exact request's explicit Workspace/member identity, + // never from a daemon-global active/current Workspace. + resolveWorkspaceScope?: (req: any) => Promise; + // Whether the caller may mutate (edit / publish-toggle / delete) design + // system `id` — the same verdict the PATCH/DELETE routes enforce (see + // `registerDesignSystemRoutes`'s identically-named dep). Optional so a + // caller that never renders a design-system list (e.g. a route-only test + // fixture) does not have to supply it; the design-system LIST route below + // treats a missing implementation as "always mutable" (skips decorating + // `canMutate` rather than defaulting every entry to false). + canMutateUserDesignSystem?: (root: string, id: string, req: any) => Promise; + // `workspaceId` scopes user-imported skills to one workspace, same + // one-way "unclaimed visible everywhere, claimed elsewhere hidden" rule + // as `listAllDesignSystems` above. Omit it to resolve a skill by id (or + // compose the system prompt) from anywhere. + listAllSkills: (options?: { + workspaceId?: string | null; + }) => Promise>; // Mirrors listAllSkills but scans DESIGN_TEMPLATE_ROOTS so the Templates // surface only sees rendering-catalogue entries. listAllDesignTemplates: () => Promise>; @@ -53,7 +80,9 @@ export interface ResourceDeps { // resolvers (chat run system prompt, orbit template resolver, // /api/skills/:id/example, /api/skills/:id/assets/*) keep working when // a stored project.skillId points at either root. - listAllSkillLikeEntries: () => Promise>; + listAllSkillLikeEntries: (options?: { + workspaceId?: string | null; + }) => Promise>; mimeFor: (filePath: string) => string; } @@ -62,8 +91,15 @@ export interface RoutineDeps { } export interface ProjectPreviewScopeDeps { - mint: (projectId: string) => string; + mint: ( + projectId: string, + workspace?: { workspaceId: string; workspaceMemberId: string } | null, + ) => string; validate: (projectId: string, scope: string) => boolean; + resolve: ( + projectId: string, + scope: string, + ) => { workspaceId: string; workspaceMemberId: string } | null | undefined; } export interface TelemetryDeps { @@ -107,6 +143,8 @@ export interface ServerContext { uploads: any; node: any; projectStore: any; + authorizeProjectRequest: AuthorizeProjectRequest; + authorizeProjectToolRequest: AuthorizeProjectToolRequest; projectFiles: any; conversations: any; templates: any; @@ -139,6 +177,38 @@ export interface ServerContext { agents: any; critique: any; openDesignPublicMetadata: OpenDesignPublicMetadataService; + /** + * C-lane collaboration seam for D's project-visibility routes. After a + * successful personal→team move (D's move API), D's handler calls + * `collabSync.requestTeamShare(projectId, principal)` in-process to trigger + * the team sync: the project is marked pending and published to the resource + * hub so every teammate can discover + read it. Idempotent (safe to call again + * on a re-move). The principal is the same workspace/member that passed D's + * route-level permission check, so the side effect cannot publish/catalog + * under a different ambient workspace. D gates the move itself on + * `canShareProjects`, so this seam does NOT re-check permission. See + * routes/collab-sync.ts for the equivalent HTTP seam (POST /collab/sync-intent) + * used by the demo surface. + */ + collabSync: { + requestTeamShare(projectId: string, share?: string | ResourceHubPrincipal): Promise<{ version: number | null }>; + requestTeamUnshare(projectId: string, share?: string | ResourceHubPrincipal): Promise; + /** + * Re-upsert the shared project's hub catalog entry after a metadata-only + * change (rename). Without this a rename with no follow-up content + * publish never reached teammates. Fire-and-forget; no-op for projects + * not shared from this daemon. + */ + refreshTeamProjectMetadata(projectId: string): void; + /** + * Drop the cached team-project catalog because this daemon just changed it. + * The share/unshare response is what makes the client refetch, and without + * this that refetch is served the pre-change list out of the display cache + * — so a project the user just shared did not appear in 全部项目 until some + * later poll (acceptance #53). Fire-and-forget. + */ + invalidateTeamProjectCatalog?(): void; + }; lifecycle: { isDaemonShuttingDown: () => boolean; }; diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index b52cbed1446..0bc7305d0e8 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -19,6 +19,11 @@ import os from 'node:os'; import net from 'node:net'; import { executionProfileFromStreamFormat, PLUGIN_SHARE_ACTION_PLUGIN_IDS } from '@open-design/contracts'; import { isTodoWriteToolName, stopReasonIsTruncation, todoItemsFromTodoWriteInput } from '@open-design/contracts'; +import type { + CollabCloudMemberDirectoryEntry, + TeamProject, + WorkspaceCollabContext, +} from '@open-design/contracts'; import { composeSystemPrompt, detectDeckIntentSignal, @@ -102,6 +107,7 @@ import { import { writePromptAndEndStdin, applyClaudeStreamJsonRunBookkeeping, + assertValidRuntimeDefFirstOutputTimeoutMs, assertValidRuntimeDefInactivityTimeoutMs, bufferedAntigravityGeminiFirstTokenAt, classifyChatRunCloseStatus, @@ -109,6 +115,7 @@ import { resolveAcpStageTimeoutMs, resolveActiveInactivityTimeoutMs, resolveChatRunArtifactQuietPeriodMs, + resolveChatRunFirstOutputTimeoutMs, resolveChatRunInactivityTimeoutMs, resolveChatRunShutdownGraceMs, } from './runtimes/chat-run-lifecycle.js'; @@ -155,6 +162,7 @@ export { } from './runtimes/chat-prompt-inputs.js'; export { applyClaudeStreamJsonRunBookkeeping, + assertValidRuntimeDefFirstOutputTimeoutMs, assertValidRuntimeDefInactivityTimeoutMs, bufferedAntigravityGeminiFirstTokenAt, classifyChatRunCloseStatus, @@ -162,6 +170,7 @@ export { resolveAcpStageTimeoutMs, resolveActiveInactivityTimeoutMs, resolveChatRunArtifactQuietPeriodMs, + resolveChatRunFirstOutputTimeoutMs, resolveChatRunInactivityTimeoutMs, } from './runtimes/chat-run-lifecycle.js'; export { @@ -224,6 +233,15 @@ import { readVelaLoginStatus, resolveAmrProfile, } from './integrations/vela.js'; +import { projectResourceIdFor } from './integrations/vela-team-projects.js'; +import { + getTeamProjectMaterialization, + latestTeamProjectMaterializationVersion, + materializePulledTeamMirror, + teamProjectMaterializationMatches, + teamProjectMaterializationSupersedes, +} from './collab/team-mirror-materializer.js'; +import { recoverAuthorizedTeamProjectPromotions } from './collab/team-mirror-promotion.js'; import { amrAccountFailureDetails, classifyAmrAccountFailureSignal, @@ -282,10 +300,12 @@ import { resolveSandboxRuntimeConfig, } from './sandbox-mode.js'; import { + backfillDesignSystemWorkspaceResources, buildUserDesignSystemArchive, createUserDesignSystem, deleteUserDesignSystem, digestDesignSystemContext, + isTeamSyncedUserDesignSystem, LEGACY_DESIGN_SYSTEM_ARTIFACTS, linkUserDesignSystemProject, listDesignSystems, @@ -296,13 +316,25 @@ import { readDesignSystemStaticFile, readUserDesignSystemFile, resolveDesignSystemAssets, + stripPrefixAndValidateId, + syncUserDesignSystemAssetsFromFiles, updateUserDesignSystem, updateUserDesignSystemRevisionStatus, + type UserDesignSystemInput, } from './design-systems/index.js'; +import { + createWorkspaceOwnedDesignSystem as persistWorkspaceOwnedDesignSystem, +} from './design-systems/workspace-owned-create.js'; import { createDesignSystemGenerationJobStore } from './design-systems/generation-jobs.js'; import { createDesignSystemServerServices } from './design-systems/server-services.js'; import { prepareDesignTokenContractRebuild } from './design-systems/token-contract-rebuild.js'; import { registerBrandRoutes } from './brand-routes.js'; +import { + authorizeCreatedProjectWorkspace, + bindCreatedProjectToWorkspace, + createCreatedProjectWorkspaceResolver, + sendCreatedProjectWorkspaceError, +} from './collab/created-project-workspace.js'; import { applyDiffReviewDecisionToCwd, applyPlugin, @@ -335,6 +367,16 @@ import { startSnapshotGc, uninstallPlugin, } from './plugins/index.js'; +import { + activateWorkspaceTeamPluginIfStillShared, + pluginIdFromWorkspaceTeamPluginBinding, + resolveAndActivateWorkspaceTeamPlugin, + resolvePluginFolder, + resolveWorkspaceTeamPluginWithBindingGate, + workspaceTeamPluginBindingActivationFence, + workspaceTeamPluginBindingAllowsRead, + workspaceTeamPluginBindingResourceId, +} from './plugins/registry.js'; import { marketplaceManifestUrlForRegistry, marketplaceRegistryIdFromUrl, @@ -437,7 +479,7 @@ import { } from './design/index.js'; import { buildDocumentPreview } from './document-preview.js'; import { lintArtifact, renderFindingsForAgent } from './lint-artifact.js'; -import { loadCraftSections } from './craft.js'; +import { loadCraftSections, resolveCraftRequirements } from './craft.js'; import { skillCwdAliasSegment, stageActiveSkill } from './cwd-aliases.js'; import { buildDesktopArtifactExportInput, buildDesktopPdfExportInput } from './pdf-export.js'; import { generateMedia } from './media/index.js'; @@ -522,7 +564,7 @@ import { sanitizeName, sanitizePath, searchProjectFiles, - resolveProjectDir, + stageProjectDirsForDelete, resolveProjectFilePath, writeProjectFile, reconcileHtmlArtifactManifest, @@ -531,16 +573,29 @@ import { validateArtifactManifestInput } from './artifacts/manifest.js'; import { ArtifactPublicationBlockedError } from './artifacts/publication-guard.js'; import { appendMessageStatusEvent, + confirmPreviewCommentPinSeq, deleteConversation, deletePreviewComment, deleteProject as dbDeleteProject, + deleteWorkspaceProject, deleteTemplate, getConversation, getDeployment, getDeploymentById, + getLatestConversationIdForProject, getMessageTelemetryFinalizationState, + getPreviewComment, getProject, + countWorkspaceProjectRefs, + findTeamWorkspaceIdForProject, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + listWorkspaceProjectBindings, getTemplate, + ensureWorkspaceProject, + ensureWorkspaceResource, + getWorkspaceResource, + getWorkspaceResourceByResourceId, insertConversation, insertProject, insertRoutine, @@ -557,19 +612,31 @@ import { listMessages, listPreviewComments, listProjects, + listUnboundProjects, + listTeamWorkspaceProjectShares, + listTeamWorkspaceResourceWorkspaceIds, + listWorkspaceProjects, + listWorkspaceResources, listRoutines, listRoutineRuns, listTabs, listTemplates, getLatestRoutineRun, getRoutine, + mergeSyncedPreviewComment, normalizeConversationSessionMode, deleteRoutine as dbDeleteRoutine, openDatabase, + reorderPreviewComment, setTabs, + SYNC_KEEPS_UPDATED_AT, updateConversation, + updatePreviewCommentAnchor, updatePreviewCommentStatus, updateProject, + updateWorkspaceProject, + updateWorkspaceResource, + rebindWorkspaceProject, updateRoutine, updateRoutineRun, clearAgentSession, @@ -624,7 +691,7 @@ import { registerLiveArtifactRoutes } from './routes/live-artifact.js'; import { registerDesignSystemToolRoutes } from './routes/design-system-tool.js'; import { registerDeployRoutes, registerDeploymentCheckRoutes } from './routes/deploy.js'; import { registerMediaRoutes } from './routes/media.js'; -import { registerProjectRoutes, registerProjectArtifactRoutes, registerProjectFileRoutes, registerProjectUploadRoutes } from './routes/project/index.js'; +import { registerProjectRoutes, registerProjectArtifactRoutes, registerProjectFileRoutes, registerProjectUploadRoutes, createEnforceWorkspaceProjectMutation } from './routes/project/index.js'; import { registerVelaRoutes } from './routes/vela.js'; import { registerFinalizeRoutes, registerImportRoutes, registerProjectExportRoutes } from './import-export-routes.js'; import { registerHandoffRoutes } from './routes/handoff.js'; @@ -639,6 +706,133 @@ import { registerSocialShareRoutes } from './routes/social-share.js'; import { registerOpenDesignPublicMetadataRoutes } from './routes/open-design-public-metadata.js'; import { registerWhatsNewRoutes } from './routes/whats-new.js'; import { registerMemoryRoutes } from './routes/memory.js'; +import { + createCollabPresenceCloudClient, + registerCollabPresenceRoutes, +} from './routes/collab-presence.js'; +import { + registerCollabSyncRoutes, + type TeamMirrorPullScope, +} from './routes/collab-sync.js'; +import { + emitWorkspaceEventToScope, + registerCollabContextRoutes, +} from './routes/collab-context.js'; +import { registerTeamResourceRoutes } from './routes/team-resources.js'; +import { registerTeamResourceShareRoutes } from './routes/team-resource-share.js'; +import { createCollabRuntime } from './collab/runtime.js'; +import { + createActiveWorkspaceSelectionStore, +} from './collab/active-workspace-selection.js'; +import { + headerValue, + isWorkspaceResourceLocked, + workspaceResourceContext, + workspaceResourceContextFromRequest, +} from './collab/workspace-resource-mutation.js'; +import { createAuthorizeProjectRequest } from './collab/project-request-authority.js'; +import { withLastKnownWorkspaceContext } from './collab/workspace-context.js'; +import { + createWorkspaceTypeRegistry, + impossibleTeamShareRows, + projectCollabScope, +} from './collab/team-share-scope.js'; +import { resolveWorkspaceScope } from './collab/workspace-scope.js'; +import { + AmrWorkspaceScopeRequiredError, + openDesignAmrTraceEnvForRun, + pinRunWorkspaceScopeForProject, +} from './runtimes/project-amr-trace-env.js'; +import { + createWorkspaceDirectoryAuthorityBroker, + createWorkspaceContextProviderFromEnv, + fetchVelaWorkspaceDirectory, + workspaceContextFromDirectoryItem, +} from './collab/vela-workspace-context.js'; +import { verifyWorkspaceRequestContext } from './collab/request-workspace-context.js'; +import { + createWorkspaceBillingRuntimeCoordinator, + shouldEmitWorkspaceBillingRuntimeNudge, + WorkspaceBillingAccessRevokedError, +} from './collab/workspace-billing-runtime.js'; +import { + AUTHORITATIVE_PROJECT_PRESENCE_CAPABILITY, + startHubEventsSubscriber, +} from './collab/hub-events-subscriber.js'; +import { + createWorkspaceHubSubscriptionManager, + type WorkspaceHubSubscriptionManager, +} from './collab/workspace-hub-subscriptions.js'; +import { + activeTeamWorkspaceIdentity, + createProactiveContentPull, + type ProactiveContentPullTarget, +} from './collab/proactive-content-pull.js'; +import { createProjectContentTransferStateStore } from './collab/project-content-transfer-state.js'; +import { + emitSharedProjectPullTiming, + sharedProjectPullProfileEnabled, +} from './collab/pull-profile.js'; +import { createSyncDigestReader } from './collab/sync-digest.js'; +import { + createCollabSyncSnapshotStore, + parseMemberDirectorySnapshot, + parseTeamProjectSnapshot, +} from './collab/sync-snapshot-store.js'; +import { createPersistentSyncCache } from './collab/persistent-sync-cache.js'; +import { createSwrCache } from './collab/swr-cache.js'; +import { invalidateTeamResourceListingCaches } from './collab/team-resource-list-cache.js'; +import { readVelaControlApiContext } from './integrations/vela.js'; +import { fetchVelaWorkspaceBillingProjection } from './integrations/vela-billing.js'; +import { createCollabPublishWatcher } from './collab/collab-publish-watcher.js'; +import { + isUnmaterializedSharedPlaceholder, + SHARED_PROJECT_PLACEHOLDER_METADATA_KEY, +} from './collab/shared-project-placeholder.js'; +import { recoverPersistedTeamShareOwnership } from './collab/persisted-team-share.js'; +import { resolveProjectShareDir } from './collab/project-share-dir.js'; +import { createTeamProjectsLister } from './collab/team-projects.js'; +import { + createTeamResourceShareService, + teamResourceRequestScopeFromContext, + teamResourceRequestScopeForWorkspaceId, + unshareIfCurrentlyShared, + type TeamResourceRequestScope, + type TeamResourceShareRecord, + type TeamResourceShareService, +} from './collab/team-resource-share.js'; +import { + materializeWorkspaceScopedTeamResource, + readTeamResourceMaterialization, + teamResourceMaterializationDir, + teamResourceSourceKey, + teamResourceWorkspaceRoot, +} from './collab/team-resource-materialization.js'; +import { createTeamResourceVersionStore } from './collab/team-resource-version-store.js'; +import { + contextToResourceHubPrincipal, + type ResourceHubPrincipal, +} from './collab/resource-principal.js'; +import { createCollabCloudClientFromEnv } from './integrations/collab-cloud.js'; +import { createCollabCloudService } from './collab/collab-cloud-service.js'; +import { createWorkspaceInvalidationPoller } from './collab/workspace-invalidation-poller.js'; +import { + handleHubTeamProjectsChanged, + handlePolledWorkspaceInvalidation, + reconcileWorkspaceProjectsWithRemote, + reconcilerRemoteTeamProjects, + type LocalTeamProjectBinding, +} from './collab/workspace-projects-reconciler.js'; +import { + reconcileWorkspaceResourcesWithRemote, + type LocalTeamResourceBinding, +} from './collab/workspace-resources-reconciler.js'; +import { createVelaCliCollabClientFromEnv } from './collab/vela-cli-collab-client.js'; +import { + createScopedVelaTeamProjectCatalogClientCache, + createVelaCliTeamProjectCatalogClientFromEnv, + createVelaCliTeamProjectCatalogFromEnv, +} from './collab/vela-cli-team-projects.js'; import { registerTelemetryRoutes } from './routes/telemetry.js'; import { assembleExample, @@ -648,6 +842,10 @@ import { } from './routes/static-resource.js'; export { rewriteSkillAssetUrls } from './routes/static-resource.js'; import { registerRoutineRoutes, routineDbRowToContract } from './routes/routine.js'; +import { + bindProjectToPersistedAutomationWorkspace, + normalizePersistedAutomationWorkspaceScope, +} from './automations/workspace-scope.js'; import { resolveAmrModelProbe } from './runtimes/amr-model-probe.js'; import { createPluginInstallationHelpers, normalizeProjectPluginFolderPath, resolveProjectChildDirectory } from './services/plugin-installation.js'; import { createPluginShareTaskStore } from './services/plugin-share-tasks.js'; @@ -1017,6 +1215,14 @@ async function refreshAndPersistToken(dataDir, serverId, current) { const activeChatAgentEventSinks = new Map(); const activeProjectEventSinks = new Map(); +// Collab realtime hop-2: subscribers to the WORKSPACE-scoped invalidation SSE +// (`GET /api/workspace/events`). Every connection is freshly verified for an +// exact Workspace/member pair; sinks are partitioned by Workspace so one +// daemon can safely serve tabs viewing A and B concurrently. Delivery is +// workspace-wide within a partition because roster/catalog/context/team +// billing invalidations legitimately affect every member of that Workspace. +const workspaceEventSinks = + new Map void>>(); // Per-chat-run handles, keyed by runId. Lets non-stream side effects // (live-artifact create, project events) reach back into the chat // run's local state — currently used by the artifact quiet-period @@ -1102,6 +1308,60 @@ function emitProjectEvent(projectId, payload) { return true; } +// Broadcast a thin WORKSPACE-scoped invalidation only to the verified sink +// partition for `workspaceId`. There is deliberately no account-wide fallback: +// every producer below is attached to an explicit hub/poller/billing/project +// scope, and broad delivery would reveal cross-workspace activity timing. +function emitWorkspaceEvent( + workspaceId: string, + payload: { type: string; at?: number }, +): boolean { + return emitWorkspaceEventToScope( + workspaceEventSinks, + workspaceId, + payload, + ); +} + +/** + * Hub → daemon handling for the `workspace-context-changed` event (see + * `startHubEventsSubscriber`'s `onEvent` below). Vela sends this same event + * both for directory changes and membership changes (e.g. removal from a + * team). Besides forwarding the thin signal to the web, this kicks one + * immediate background reconciliation cycle. Request mutations independently + * perform fresh exact-scope authority checks and do not depend on this poll. + * + * Extracted as its own named, exported step (rather than inlined in the + * switch) so this invariant is directly unit-testable without standing up a + * real hub connection. + */ +export function handleHubWorkspaceContextChanged( + workspaceId: string, + pollWorkspaceInvalidation: () => Promise, +): void { + emitWorkspaceEvent( + workspaceId, + { type: 'workspace-context-changed', at: Date.now() }, + ); + void pollWorkspaceInvalidation().catch(() => undefined); +} + +/** + * A verified hub connection is itself a freshness boundary, including the + * daemon's very first connection. Published content and billing may already + * have changed before the subscriber came online, so both scopes catch up + * immediately instead of waiting for a later reconnect or poll tick. + */ +export function handleHubVerifiedConnection( + workspaceId: string | undefined, + catchUpPublishedHeads: (workspaceId: string) => Promise, + catchUpWorkspaceBilling: (workspaceId: string) => void, +): void { + if (!workspaceId) return; + void catchUpPublishedHeads(workspaceId).catch(() => undefined); + catchUpWorkspaceBilling(workspaceId); +} + // Windows ENAMETOOLONG mitigation constants const CMD_BAT_RE = /\.(cmd|bat)$/i; const PROMPT_TEMP_FILE = () => @@ -1665,11 +1925,12 @@ function createProjectPreviewScopeRegistry() { } return { - mint(projectId) { + mint(projectId, workspace = null) { pruneExpired(); const scope = randomUUID(); scopes.set(scope, { projectId: String(projectId), + workspace, expiresAt: Date.now() + PROJECT_PREVIEW_SCOPE_TTL_MS, }); return scope; @@ -1684,6 +1945,17 @@ function createProjectPreviewScopeRegistry() { } return entry.projectId === String(projectId); }, + resolve(projectId, scope) { + const key = String(scope || ''); + const entry = scopes.get(key); + if (!entry) return undefined; + if (entry.expiresAt <= Date.now()) { + scopes.delete(key); + return undefined; + } + if (entry.projectId !== String(projectId)) return undefined; + return entry.workspace ?? null; + }, }; } @@ -2168,6 +2440,10 @@ export async function startServer({ } const designSystemServices = createDesignSystemServerServices({ + // `db` (below) is not initialized yet at this point in `startServer` — + // pass a getter so `listAllSkills`'s workspace filter reads it lazily, + // once the first request that needs it actually arrives. + getDb: () => db, roots: { SKILL_ROOTS, DESIGN_TEMPLATE_ROOTS, ALL_SKILL_LIKE_ROOTS }, paths: { PROJECTS_DIR, DESIGN_SYSTEMS_DIR, USER_DESIGN_SYSTEMS_DIR }, skills: { listSkills, findSkillById }, @@ -2179,6 +2455,7 @@ export async function startServer({ listUserDesignSystemFiles, readUserDesignSystemFile, linkUserDesignSystemProject, + syncUserDesignSystemAssetsFromFiles, LEGACY_DESIGN_SYSTEM_ARTIFACTS, }, projects: { @@ -2191,6 +2468,31 @@ export async function startServer({ resolveProjectDir, isSafeId, }, + bindProjectToWorkspace: (projectId, createdAt, designSystem) => { + const workspaceId = designSystem.workspaceId?.trim(); + if (!workspaceId) return; + const binding = getWorkspaceResource( + db, + 'design_system', + workspaceId, + designSystem.id, + ); + const memberId = binding?.createdByWorkspaceMemberId?.trim(); + if (!memberId) return; + ensureWorkspaceProject(db, { + projectId, + workspaceId, + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: memberId, + updatedByWorkspaceMemberId: memberId, + syncState: 'local_only', + resourceHubResourceId: null, + cloudTombstonedAt: null, + createdAt, + updatedAt: createdAt, + }); + }, }); const { ensureUserDesignSystemWorkspaceProject, @@ -2203,6 +2505,8 @@ export async function startServer({ readAvailableDesignSystemPackageInfo, readAvailableDesignSystemStaticFile, readDesignSystemWorkspaceTextFile, + resolveUserDesignSystemShareDirectory, + syncUserDesignSystemAssetsFromWorkspace, validateProjectDesignSystemId, validateProjectSkillId, } = designSystemServices; @@ -2425,114 +2729,2994 @@ export async function startServer({ entryNamePrefix: 'open-design', }, }); - bundledMarketplaceEntries = result.registered.map((plugin) => ({ - name: `open-design/${plugin.id}`, - title: plugin.title, - title_i18n: plugin.manifest.title_i18n, - description: plugin.manifest.description, - description_i18n: plugin.manifest.description_i18n, - version: plugin.version, - source: bundledPluginRegistrySource(plugin.source), - publisher: { id: 'open-design', url: 'https://open-design.ai' }, - homepage: plugin.manifest.homepage, - license: plugin.manifest.license, - tags: plugin.manifest.tags, - capabilitiesSummary: Array.isArray(plugin.manifest.od?.capabilities) - ? plugin.manifest.od.capabilities - : undefined, - })); - if (result.registered.length > 0) { - console.log(`[plugins] registered ${result.registered.length} bundled plugin(s)`); + bundledMarketplaceEntries = result.registered.map((plugin) => ({ + name: `open-design/${plugin.id}`, + title: plugin.title, + title_i18n: plugin.manifest.title_i18n, + description: plugin.manifest.description, + description_i18n: plugin.manifest.description_i18n, + version: plugin.version, + source: bundledPluginRegistrySource(plugin.source), + publisher: { id: 'open-design', url: 'https://open-design.ai' }, + homepage: plugin.manifest.homepage, + license: plugin.manifest.license, + tags: plugin.manifest.tags, + capabilitiesSummary: Array.isArray(plugin.manifest.od?.capabilities) + ? plugin.manifest.od.capabilities + : undefined, + })); + if (result.registered.length > 0) { + console.log(`[plugins] registered ${result.registered.length} bundled plugin(s)`); + } + if (result.warnings.length > 0) { + for (const w of result.warnings) console.warn(`[plugins] bundled warn: ${w}`); + } + } catch (err) { + console.warn(`[plugins] bundled registration failed: ${(err)?.message ?? err}`); + } + + try { + const seedDirs = await fs.promises.readdir(PLUGIN_REGISTRY_DIR, { withFileTypes: true }).catch((err) => { + if (err?.code === 'ENOENT') return []; + throw err; + }); + const { ensureMarketplaceManifest } = await import('./plugins/marketplaces.js'); + for (const dirent of seedDirs) { + if (!dirent.isDirectory()) continue; + const id = dirent.name; + const manifestText = await marketplaceSeedManifestText(id, bundledMarketplaceEntries); + if (!manifestText) continue; + const configured = defaultMarketplaceSeedConfig(id); + const result = ensureMarketplaceManifest(db, { + id, + url: configured.url, + trust: configured.trust, + manifestText, + }); + if (result.ok) { + console.log(`[plugins] seeded ${id} registry source (${result.row.manifest.plugins.length} plugin(s))`); + } else { + console.warn(`[plugins] ${id} registry seed failed: ${result.message}`); + } + } + } catch (err) { + console.warn(`[plugins] registry seed failed: ${(err)?.message ?? err}`); + } + + // Plan §3.A5 / spec §16 Phase 5 / PB2: periodic snapshot GC. Disabled + // when OD_SNAPSHOT_GC_INTERVAL_MS is 0; otherwise one-time bootstrap + // sweep + interval. The function returns a NOOP_HANDLE when disabled + // so we don't have to branch on the result. + const snapshotGc = startSnapshotGc({ db }); + // One immediate sweep so a daemon that just gained the ALTER doesn't + // wait the full interval before reaping pre-existing expired rows. + try { + const initialSweep = pruneExpiredSnapshots(db); + if (initialSweep.removed > 0) { + console.log(`[plugins] snapshot GC startup sweep removed ${initialSweep.removed} row(s)`); + } + } catch (err) { + console.warn(`[plugins] snapshot GC startup sweep failed: ${(err)?.message ?? err}`); + } + void snapshotGc; // keep handle alive for the daemon's lifetime + + // Memory hygiene: one-time removal of entries the retired chat + // auto-extraction pipelines wrote (regex-pack artifacts + chat-form + // residue in user_profile). Marker-gated inside, so this is a no-op on + // every boot after the first. Best-effort — memory cleanup must never + // block the daemon from serving. + try { + const memoryCleanup = await runAutoExtractionCleanup(RUNTIME_DATA_DIR); + if (memoryCleanup.ran && (memoryCleanup.deletedIds.length > 0 || memoryCleanup.profilePruned)) { + console.log( + `[memory] auto-extraction cleanup removed ${memoryCleanup.deletedIds.length} entr(y/ies)` + + `${memoryCleanup.profilePruned ? ' and pruned user_profile to canonical fields' : ''}`, + ); + } + } catch (err) { + console.warn('[memory] auto-extraction cleanup failed:', err); + } + + // Warm agent-capability probes (e.g. whether the installed Claude Code + // build advertises --include-partial-messages) so the first /api/chat + // hits a populated cache even if /api/agents hasn't been called yet. + void readAppConfig(RUNTIME_DATA_DIR) + .then((config) => { + orbitService.configure(config.orbit); + return detectAgents(config.agentCliEnv ?? {}); + }) + .catch(() => detectAgents().catch(() => {})); + + await recoverStaleLiveArtifactRefreshes({ projectsRoot: PROJECTS_DIR }).catch((error) => { + console.warn('[od] Failed to recover stale live artifact refreshes:', error); + }); + + if (fs.existsSync(STATIC_DIR)) { + app.use(express.static(STATIC_DIR)); + } + + // ---- Projects (DB-backed) ------------------------------------------------- + + + // Team collaboration subsystem: presence + author-side publish scheduler. + // Product team workspaces publish and pull through the login-backed Vela CLI; + // non-Vela local modes retain the in-memory adapter for isolated development. + const describeCollabProject = (projectId: string) => { + const project = getProject(db, projectId); + if (!project) return null; + return { + name: project.name, + skillId: project.skillId ?? null, + designSystemId: project.designSystemId ?? null, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + ...(project.metadata ? { metadata: project.metadata } : {}), + }; + }; + const activeWorkspace = createActiveWorkspaceSelectionStore(RUNTIME_DATA_DIR); + const teamMirrorPromotionJournalDir = path.join( + RUNTIME_DATA_DIR, + 'team-mirror-promotions', + ); + await recoverAuthorizedTeamProjectPromotions({ + journalDir: teamMirrorPromotionJournalDir, + allowedProjectsRoot: PROJECTS_DIR, + isCommitted: (entry) => { + const stored = getTeamProjectMaterialization( + db, + entry.receipt.workspaceId, + entry.receipt.projectId, + ); + return teamProjectMaterializationMatches(stored, entry.receipt); + }, + isSuperseded: (entry) => { + const stored = getTeamProjectMaterialization( + db, + entry.receipt.workspaceId, + entry.receipt.projectId, + ); + return teamProjectMaterializationSupersedes(stored, entry.receipt); + }, + onError: (error) => { + console.warn('[od] failed to recover authorized team mirror promotion:', error); + }, + }); + // What this daemon has learned about each workspace's type, memoized from + // exact directory/context reads it already performs. It is the + // second witness behind the team-share invariant: a team share may only be + // recorded in — and a project-scoped collab call may only be pinned to — a + // workspace that can actually host a team plane. See collab/team-share-scope.ts. + const workspaceTypes = createWorkspaceTypeRegistry(); + const workspaceDirectoryAuthority = createWorkspaceDirectoryAuthorityBroker({ + fetchDirectory: async () => { + const result = await fetchVelaWorkspaceDirectory(); + if (result.ok) workspaceTypes.learn(result.items); + return result; + }, + }); + const fetchWorkspaceDirectory = workspaceDirectoryAuthority.read; + const fetchFreshMutationWorkspaceDirectory = + workspaceDirectoryAuthority.fresh; + const verifyExplicitWorkspaceRequestContext = async (input: { + req: any; + requireTeam?: boolean; + }, options: { fresh?: boolean } = {}) => { + if (process.env.OD_WORKSPACE_CONTEXT_SOURCE?.trim() === 'vela') { + return verifyWorkspaceRequestContext({ + ...input, + fetchWorkspaceDirectory: + options.fresh === false + ? fetchWorkspaceDirectory + : fetchFreshMutationWorkspaceDirectory, + }); + } + // Local/dev has no signed membership directory. Its explicit request + // headers are the complete, static authority; still never consult the + // daemon's mutable active-workspace context. + const claimed = workspaceResourceContextFromRequest(input.req); + if (claimed === null) { + return { + ok: false as const, + status: 400 as const, + code: 'WORKSPACE_CONTEXT_REQUIRED' as const, + message: 'an explicit workspace context is required', + }; + } + if (claimed === 'missing') { + return { + ok: false as const, + status: 400 as const, + code: 'WORKSPACE_CONTEXT_INCOMPLETE' as const, + message: 'both workspace and member identity are required', + }; + } + if ( + claimed.memberStatus !== 'active' + || claimed.lifecycleState === 'deleted' + || (input.requireTeam && claimed.workspaceType !== 'team') + ) { + return { + ok: false as const, + status: 403 as const, + code: 'WORKSPACE_ACCESS_DENIED' as const, + message: 'the requested workspace is not available to this member', + }; + } + return { + ok: true as const, + context: workspaceContextFromDirectoryItem({ + workspaceId: claimed.workspaceId, + workspaceName: claimed.workspaceId, + workspaceType: claimed.workspaceType, + workspaceMemberId: claimed.workspaceMemberId, + role: claimed.role, + memberStatus: claimed.memberStatus, + lifecycleState: claimed.lifecycleState, + }), + }; + }; + const verifyWorkspaceReadAuthority = (req: unknown) => + verifyExplicitWorkspaceRequestContext({ req }, { fresh: false }); + const verifyWorkspaceRequestAuthority = (req: unknown) => + verifyExplicitWorkspaceRequestContext({ req }); + const enforceAuthoritativeProjectMutation = createEnforceWorkspaceProjectMutation( + verifyWorkspaceRequestAuthority, + ); + // Project-creation writes must be authorized by AMR in production, while + // local/dev and explicitly anonymous clients keep their legacy behavior. + // Keep this separate from read-side directory fetches so an unconfigured + // daemon never turns ordinary local creation into a network-dependent path. + const fetchProjectCreationWorkspaceDirectory = + process.env.OD_WORKSPACE_CONTEXT_SOURCE?.trim() === 'vela' + ? fetchFreshMutationWorkspaceDirectory + : undefined; + const listWorkspaceDirectory = async () => { + const result = await fetchWorkspaceDirectory(); + return result.items; + }; + const resolveAuthoritativeTeamWorkspaceContext = async ( + workspaceId: string | null | undefined, + options: { fresh?: boolean } = {}, + ): Promise => { + const requestedWorkspaceId = workspaceId?.trim() ?? ''; + if (!requestedWorkspaceId) return null; + const directory = await ( + options.fresh + ? fetchFreshMutationWorkspaceDirectory() + : fetchWorkspaceDirectory() + ).catch(() => ({ + ok: false as const, + items: [], + })); + if (!directory.ok) return null; + const membership = directory.items.find( + (item) => + item.workspaceId === requestedWorkspaceId + && item.workspaceType === 'team' + && item.memberStatus === 'active' + && item.lifecycleState === 'active', + ); + return membership ? workspaceContextFromDirectoryItem(membership) : null; + }; + const teamResourceVersions = createTeamResourceVersionStore(RUNTIME_DATA_DIR); + const teamProjectContentResourceId = ( + projectId: string, + scope: { resourceTeamId: string; ownerMemberId: string }, + ) => + projectResourceIdFor(projectId, { + teamId: scope.resourceTeamId, + memberId: scope.ownerMemberId, + role: 'member', + lifecycleState: 'active', + workspaceType: 'team', + }); + /** + * Resolve design-system ownership/filtering from this exact request. + * + * Catalog and create are data-plane operations. Daemon-global active/current + * state can change between two tabs, so it is not authority for deciding + * which Workspace a request reads or writes. + */ + async function resolveDesignSystemWorkspaceContext( + req: any, + ): Promise { + const claimed = workspaceResourceContextFromRequest(req); + // A completely headerless local/signed-out request is the explicit legacy + // lane: built-ins plus unclaimed local resources, and new resources remain + // unbound. A half-specified identity is never that lane and is rejected by + // the verifier below. + if (claimed === null) return null; + const verified = await verifyExplicitWorkspaceRequestContext({ req }); + if (!verified.ok) { + throw Object.assign(new Error(verified.message), { + status: verified.status, + code: verified.code, + ...(verified.retryable ? { retryable: true } : {}), + }); + } + return verified.context; + } + + async function resolveDesignSystemWorkspaceScope(req: any): Promise { + const context = await resolveDesignSystemWorkspaceContext(req); + return context?.workspaceId.trim() || null; + } + + /** + * Create a user design system CLAIMED by the workspace it was authored in. + * + * User design systems share one flat directory, so the claim written here is + * the only thing that lets `GET /api/design-systems` keep one workspace's + * library out of another's (#145). Stamping at creation is deliberate: it is + * the one moment the authoring workspace is unambiguous, whereas deciding + * ownership later (at read time, from whatever workspace happens to be + * active) would re-home a system every time the user switched. + * + * Envelope double-write (spec 9.2): `metadata.json` stays the only thing + * `listDesignSystems`'s filter reads, but a claimed system also gets a row + * in the generic `workspace_resources` table — the same table plugin/skill + * already bind into — so design systems stop being the one resource type + * with zero rows there. Both writes happen from this single call site, so + * they can never drift apart. + */ + const createWorkspaceOwnedDesignSystemForContext = ( + root: string, + input: UserDesignSystemInput, + context: import('./collab/workspace-resource-mutation.js').WorkspaceResourceContext | null, + ) => persistWorkspaceOwnedDesignSystem(root, input, context, { + ensureWorkspaceResource: (resourceType, workspaceId, resourceId, envelope) => + ensureWorkspaceResource(db, resourceType, workspaceId, resourceId, envelope), + }); + const createWorkspaceOwnedDesignSystem = async ( + root: string, + input: UserDesignSystemInput, + req: any, + ) => { + const context = await resolveDesignSystemWorkspaceContext(req); + return createWorkspaceOwnedDesignSystemForContext(root, input, context); + }; + // Persistent half of the sync design: a cheap digest GET decides whether the + // catalog / member payload this daemon already has on disk is still current, + // so a cold start (or a workspace not touched in a while) can skip the real + // round-trip entirely. Snapshots live in the daemon database, which was + // opened from the resolved runtime data root. See collab/persistent-sync-cache.ts. + const collabSyncSnapshots = createCollabSyncSnapshotStore(db); + const velaCliCollabClient = createVelaCliCollabClientFromEnv(process.env); + const velaCliTeamProjectCatalog = createVelaCliTeamProjectCatalogFromEnv(); + const velaCliWorkspaceTeamProjectCatalog = + createVelaCliTeamProjectCatalogClientFromEnv(); + // Generic stale-while-revalidate cache (with an `invalidate()` escape hatch) + // — see collab/swr-cache.ts. + // Cache the workspace-scoped team catalog behind /api/workspaces/:id/projects + // ?view=… (the "All projects"/"Recent" pages) the same way. The wrapper keeps + // the verified request principal in both its key and its upstream call, so + // navigation stays instant without letting an active-workspace switch retarget + // an in-flight read. + const workspaceTeamProjectCatalog = velaCliWorkspaceTeamProjectCatalog + ? createScopedVelaTeamProjectCatalogClientCache( + velaCliWorkspaceTeamProjectCatalog, + ) + : velaCliWorkspaceTeamProjectCatalog; + // Preserve the legacy observation API for compatibility tests and dev + // tooling. Production data-plane routes never read current/lastKnown; they + // verify the exact Workspace/member carried by each request. + const workspaceContext = withLastKnownWorkspaceContext( + createWorkspaceContextProviderFromEnv(process.env, { + getActiveWorkspaceId: () => activeWorkspace.get(), + setLocalSelection: (workspaceId: string) => activeWorkspace.set(workspaceId), + // Only called after the membership directory CONFIRMS the pinned + // workspace is gone (removed member / deleted workspace) — never on a + // mere B outage. See resolvePinnedWorkspace in vela-workspace-context.ts. + clearLocalSelection: () => activeWorkspace.clear(), + }), + ); + /** + * Where a created project belongs for the surfaces with no authorization gate + * of their own. An explicit pair is verified through the same fresh directory + * authority as `POST /api/projects`; a headerless legacy/local request remains + * unbound. No active/current/last-known Workspace is consulted. + */ + const resolveCreatedProjectHome = createCreatedProjectWorkspaceResolver({ + ...(fetchProjectCreationWorkspaceDirectory + ? { fetchWorkspaceDirectory: fetchProjectCreationWorkspaceDirectory } + : {}), + }); + function persistWorkspaceProjectSyncState( + projectId: string, + workspaceId: string | null | undefined, + syncState: 'synced' | 'sync_failed', + ) { + if (!workspaceId) return; + // Where a background upload got to is sync bookkeeping, not a change to the + // project — see SYNC_KEEPS_UPDATED_AT. + updateWorkspaceProject(db, workspaceId, projectId, { + syncState, + updatedAt: SYNC_KEEPS_UPDATED_AT, + }); + } + function persistWorkspaceProjectVisibility( + input: { + projectId: string; + principal?: ResourceHubPrincipal | null; + visibility: 'personal' | 'team'; + ownerMemberId?: string | null; + updatedByMemberId?: string | null; + }, + ) { + const workspaceId = input.principal?.teamId; + if (!workspaceId) return; + const project = getProject(db, input.projectId); + // Keyed on the PROJECT, not on (workspace, project): a project belongs to + // exactly one workspace (collab/workspace-project-home.ts), so a project + // already bound elsewhere must not gain a second row here. + if (project && !getWorkspaceProjectByProjectId(db, input.projectId)) { + ensureWorkspaceProject(db, { + projectId: input.projectId, + workspaceId, + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: input.ownerMemberId ?? input.updatedByMemberId ?? null, + updatedByWorkspaceMemberId: input.updatedByMemberId ?? input.ownerMemberId ?? null, + resourceHubResourceId: null, + cloudTombstonedAt: null, + syncState: 'local_only', + createdAt: project.createdAt, + updatedAt: project.updatedAt, + }); + } + const patch = input.visibility === 'team' + ? { + visibility: 'team', + createdByWorkspaceMemberId: input.ownerMemberId ?? input.updatedByMemberId ?? null, + updatedByWorkspaceMemberId: input.updatedByMemberId ?? input.ownerMemberId ?? null, + resourceHubResourceId: projectResourceIdFor(input.projectId, input.principal), + cloudTombstonedAt: null, + syncState: 'synced', + } + : { + visibility: 'personal', + updatedByWorkspaceMemberId: input.updatedByMemberId ?? input.ownerMemberId ?? null, + resourceHubResourceId: null, + cloudTombstonedAt: Date.now(), + syncState: 'local_only', + }; + // `rebindWorkspaceProject`, not `updateWorkspaceProject`: the row this + // event is about can predate the share — a personal draft the user made + // before ever joining the team it just got shared into — so it sits under + // an unrelated, stale workspace_id. Asking for an update scoped to the + // NEW workspaceId would find nothing and silently never migrate it. + rebindWorkspaceProject(db, input.projectId, { ...patch, workspaceId }); + } + /** + * The recvqzaDvUU6B3 fresh-install wipe guard's one db-backed predicate: + * is this project's local record still an unmaterialized shared-project + * placeholder (see collab/shared-project-placeholder.ts)? Consulted by the + * publish watcher's shouldPublish AND the runtime's scheduler publish gate, + * so neither a new watch nor an already-scheduled flush can push a + * placeholder's empty directory over the team's real hub content. + */ + const projectIsUnmaterializedSharedPlaceholder = (projectId: string): boolean => + isUnmaterializedSharedPlaceholder(getProject(db, projectId)); + let invalidatePresenceReadCache = ( + _projectId: string, + _workspaceId?: string, + ): void => {}; + let markPresenceReadCacheStale = ( + _projectId: string, + _workspaceId?: string, + ): void => {}; + const collab = createCollabRuntime({ + workspaceContext, + canPublishProjectContent: (projectId) => + !projectIsUnmaterializedSharedPlaceholder(projectId), + resolveProjectDir: async (projectId) => { + const project = getProject(db, projectId); + if (project) await ensureProject(PROJECTS_DIR, projectId, project.metadata); + return resolveProjectShareDir(PROJECTS_DIR, projectId, project, resolveProjectDir); + }, + resolvePullDir: (projectId) => resolveProjectDir(PROJECTS_DIR, projectId), + describeProject: describeCollabProject, + ...(velaCliTeamProjectCatalog ? { teamProjectCatalog: velaCliTeamProjectCatalog } : {}), + onPublished: ({ projectId, principal }) => { + persistWorkspaceProjectSyncState(projectId, principal?.teamId, 'synced'); + }, + onError: ({ projectId, principal }) => { + persistWorkspaceProjectSyncState(projectId, principal?.teamId, 'sync_failed'); + }, + // Collab realtime hop-2: a member joined/left this project's presence set + // (fires only on explicit join/leave, not on every heartbeat). Push a thin + // `presence-changed` onto the project's existing events SSE so the open + // project view re-fetches presence instead of waiting for its poll tick. + onPresenceChange: ({ projectId }) => { + markPresenceReadCacheStale(projectId); + emitProjectEvent(projectId, { type: 'presence-changed', projectId, at: Date.now() }); + }, + }); + for (const share of listTeamWorkspaceProjectShares(db)) { + const restored = recoverPersistedTeamShareOwnership(share); + if (!restored) continue; + collab.rememberTeamShare( + restored.projectId, + restored.principal, + share.syncState === 'synced' || share.syncState === 'sync_failed' || share.syncState === 'pending_upload' + ? share.syncState + : 'pending_upload', + ); + } + /** + * Heal `workspace_projects` rows that already violate the team-share + * invariant: `visibility: 'team'` pinned to a PERSONAL workspace (see + * collab/team-share-scope.ts). Older builds let a share taken while the client + * sat on its personal workspace persist such a row, and the code guards alone + * leave an affected user permanently stuck — the row 403s every collab call it + * scopes and nothing ever rewrites it. + * + * Reconciliation at startup rather than a schema migration: the contradiction + * is only decidable against the workspace DIRECTORY (which ids are teams), + * which is a signed-in network fact a migration cannot see. Demotion is + * therefore evidence-gated — a workspace the directory does not name is left + * exactly as-is, and `visibility: 'personal'` rows are never candidates. + * + * A demoted row goes back to a local draft rather than being re-pointed at + * some team: which team was intended is not recoverable, and the user can + * simply re-share from the team workspace, which now writes a valid row. This + * touches local state only — no hub resource is deleted — and deliberately + * leaves `cloudTombstonedAt` null, so a copy that genuinely exists in the team + * catalog keeps showing up instead of being suppressed as "unshared here". + */ + const reconcileImpossibleTeamShares = async (): Promise => { + await listWorkspaceDirectory(); + const broken = impossibleTeamShareRows(listTeamWorkspaceProjectShares(db), workspaceTypes); + for (const row of broken) { + console.warn( + `[od] healing project ${row.projectId}: its team share pointed at personal workspace ` + + `${row.workspaceId}, which has no team plane. Re-share it from a team workspace.`, + ); + updateWorkspaceProject(db, row.workspaceId, row.projectId, { + visibility: 'personal', + resourceHubResourceId: null, + cloudTombstonedAt: null, + syncState: 'local_only', + // A startup heal of a row that was never valid; nobody changed the + // project — see SYNC_KEEPS_UPDATED_AT. + updatedAt: SYNC_KEEPS_UPDATED_AT, + }); + } + return broken.length; + }; + void reconcileImpossibleTeamShares().catch((error) => { + console.warn('[od] team-share scope reconciliation failed:', error); + }); + // Spec 9.2 one-time backfill: claim every pre-existing user design system + // whose metadata.json already names a workspace into the generic + // `workspace_resources` table too. Idempotent (see + // `backfillDesignSystemWorkspaceResources`'s own doc comment), so running + // it unconditionally on every startup is deliberate, same as + // `reconcileImpossibleTeamShares` just above. + void backfillDesignSystemWorkspaceResources(db, USER_DESIGN_SYSTEMS_DIR).catch((error) => { + console.warn('[od] design-system workspace-resource backfill failed:', error); + }); + const collabCloudClient = velaCliCollabClient ?? createCollabCloudClientFromEnv(); + const resolveBoundProjectWorkspaceContext = async ( + projectId: string, + ): Promise => { + const binding = getWorkspaceProjectByProjectId(db, projectId); + const workspaceId = binding?.workspaceId?.trim(); + if (!workspaceId) return null; + const directory = await fetchWorkspaceDirectory().catch(() => ({ + ok: false as const, + items: [], + })); + if (!directory.ok) return null; + const membership = directory.items.find( + (item) => + item.workspaceId === workspaceId + && item.workspaceType === 'team' + && item.memberStatus === 'active' + && item.lifecycleState !== 'deleted', + ); + return membership ? workspaceContextFromDirectoryItem(membership) : null; + }; + + // Collab cloud (C-lane §D2.5/§D4): cross-daemon comment sync + member + // directory. The client is null (all calls degrade to no-op) unless + // OD_COLLAB_CLOUD_URL is set. The service ties it to the one workspace context + // so a single identity drives member registration, comment push, and the + // pull+merge poller. Kept out of collab/runtime.ts to avoid colliding with the + // team-project-catalog work also editing that file. + const collabCloud = collabCloudClient + ? createCollabCloudService({ + client: collabCloudClient, + workspaceContext: collab.workspaceContext, + // Only poll comments for projects the UI is actively viewing — those + // have a live `/api/projects/:id/events` SSE subscriber, so their id is + // a key in activeProjectEventSinks. Polling every local project each 5s + // cycle spawned one `vela collab comment pull` subprocess per project + // and did not scale: a workspace with many shared projects turned every + // tick into a spawn storm that starved the pull the open project was + // waiting on. A member picks up a project's comments when they open it + // (a fresh sink) and stops polling it once they navigate away. + listProjectIds: () => [...activeProjectEventSinks.keys()], + resolveProjectWorkspaceContext: resolveBoundProjectWorkspaceContext, + resolveLocalConversationId: (projectId) => + getLatestConversationIdForProject(db, projectId), + mergeComment: ({ projectId, conversationId, comment }) => + mergeSyncedPreviewComment(db, projectId, conversationId, comment), + onError: (error) => console.warn('[od] collab cloud poll error:', error), + // Collab realtime hop-2 (reference path): when the ~5s comment self-poll + // merges any teammate change into local storage (a new comment, a + // strictly-newer edit/status change, or a delete tombstone all count), + // push a thin `comment-changed` onto the project's existing events SSE. + // The open project view re-fetches the comment list on receipt, so the + // owner sees a member's freshly-synced comment without waiting for the + // web poll tick. + onMerged: ({ projectId }) => + emitProjectEvent(projectId, { + type: 'comment-changed', + projectId, + at: Date.now(), + }), + }) + : null; + // The poller registers each open project's exact bound membership before it + // pulls. There is deliberately no ambient startup registration: no project + // scope exists yet, so active-workspace state is not data-plane authority. + collabCloud?.start(); + // Server-authoritative owner lookup for register-on-pull: read the shared + // project's owner from the team hub (the same list the discovery endpoint + // serves) rather than trusting a client-supplied id, so a pulled project is + // recorded read-only under its true single writer. + const teamProjectsLister = createTeamProjectsLister({ + ...(velaCliTeamProjectCatalog ? { teamProjectCatalog: velaCliTeamProjectCatalog } : {}), + }); + type TeamProjectsDisplayScope = { + workspaceId: string; + workspaceMemberId: string; + }; + const teamProjectsDisplayScopeFromContext = ( + context: WorkspaceCollabContext | null, + ): TeamProjectsDisplayScope | null => { + if ( + !context + || context.workspaceType !== 'team' + || context.memberStatus !== 'active' + || context.lifecycleState === 'deleted' + ) { + return null; + } + const workspaceId = context.workspaceId.trim(); + const workspaceMemberId = context.workspaceMemberId.trim(); + return workspaceId && workspaceMemberId + ? { workspaceId, workspaceMemberId } + : null; + }; + const teamProjectsDisplayScopeKey = ( + scope: TeamProjectsDisplayScope, + ): string => JSON.stringify([scope.workspaceId, scope.workspaceMemberId]); + // Persistent snapshot layer for the display catalog. Each fetcher and digest + // reader closes over one immutable Workspace scope; no await can retarget it + // through a later active-workspace switch. + const teamProjectsCatalogSnapshots = new Map< + string, + ReturnType> + >(); + const teamProjectsCatalogSnapshotFor = ( + scope: TeamProjectsDisplayScope, + ) => { + const key = teamProjectsDisplayScopeKey(scope); + let snapshot = teamProjectsCatalogSnapshots.get(key); + if (!snapshot) { + const capturedScope = { ...scope }; + snapshot = createPersistentSyncCache({ + face: 'catalog', + fetch: () => teamProjectsLister(capturedScope.workspaceId), + readDigest: createSyncDigestReader({ + env: process.env, + getWorkspaceId: () => capturedScope.workspaceId, + onError: (error) => + console.warn('[od] team projects digest error:', error), + }), + store: collabSyncSnapshots, + parseSnapshot: parseTeamProjectSnapshot, + onError: (error) => + console.warn('[od] team projects snapshot cache error:', error), + }); + teamProjectsCatalogSnapshots.set(key, snapshot); + } + return snapshot; + }; + // Short-TTL, single-flight cache for the read-only DISPLAY path + // (GET /api/workspace/projects/team). Each entry is keyed by the explicit, + // immutable workspace + member scope captured for that request, so a later + // active-workspace switch cannot retarget an in-flight read or its cache + // write. Deliberately NOT used by resolveSharedProject below: the pull gate + // and comment/presence relays must observe an unshare immediately, so those + // use the uncached exact lookup. A just-shared/unshared project shows up in + // this list within the TTL. + const teamProjectsDisplayCache = (() => { + const freshMs = 3000; + const lists = new Map< + string, + ReturnType> + >(); + const read = (scope: TeamProjectsDisplayScope) => { + const key = teamProjectsDisplayScopeKey(scope); + let list = lists.get(key); + if (!list) { + const snapshot = teamProjectsCatalogSnapshotFor(scope); + list = createSwrCache( + () => snapshot(), + () => key, + freshMs, + ); + lists.set(key, list); + } + return list(); + }; + return Object.assign(read, { + invalidate(scope?: TeamProjectsDisplayScope) { + if (scope) { + const key = teamProjectsDisplayScopeKey(scope); + lists.get(key)?.invalidate(); + lists.delete(key); + teamProjectsCatalogSnapshots.get(key)?.invalidate(); + teamProjectsCatalogSnapshots.delete(key); + return; + } + for (const list of lists.values()) list.invalidate(); + for (const snapshot of teamProjectsCatalogSnapshots.values()) { + snapshot.invalidate(); + } + lists.clear(); + teamProjectsCatalogSnapshots.clear(); + }, + }); + })(); + /** + * Drop catalog rows this member has already moved back to "personal". + * + * A move to personal deletes the hub catalog row in the same request, but + * every display read above goes through a stale-while-revalidate cache, so + * for up to one TTL the list still carries the row that was just deleted. + * That is long enough to paint the "shared" badge back onto a project the + * user just made private — the unshare looks like it silently reverted. It + * would also let the publish watcher re-adopt the project as owned-and- + * shared and republish it. + * + * `cloudTombstonedAt` on the local workspace row is the truth for "this + * member unshared it"; a re-share clears it (see `workspaceProjectMovePatch` + * in routes/project). The filter runs on the cache OUTPUT, not inside it, so + * a value cached before the unshare is still gated. Owner scoping keeps a + * teammate's own share of the same project id visible. + */ + const withoutLocallyUnsharedProjects = async < + T extends { projectId: string; ownerMemberId: string }, + >( + projects: T[], + explicitScope?: { workspaceId: string; workspaceMemberId: string }, + ): Promise => { + if (!explicitScope || projects.length === 0) return projects; + const { workspaceId, workspaceMemberId: memberId } = explicitScope; + const tombstoned = new Set( + listWorkspaceProjects(db, workspaceId) + .filter((row: any) => row.workspaceVisibility === 'personal' && row.cloudTombstonedAt != null) + .map((row: any) => row.id), + ); + if (tombstoned.size === 0) return projects; + return projects.filter( + (entry) => !(entry.ownerMemberId === memberId && tombstoned.has(entry.projectId)), + ); + }; + const teamProjectsForDisplay = async ( + context: WorkspaceCollabContext | null, + ): Promise => { + const scope = teamProjectsDisplayScopeFromContext(context); + if (!scope) return []; + return withoutLocallyUnsharedProjects( + await teamProjectsDisplayCache(scope), + scope, + ); + }; + const teamProjectsForRequest = async ( + context: WorkspaceCollabContext, + ): Promise => + withoutLocallyUnsharedProjects( + await teamProjectsLister(context.workspaceId), + { + workspaceId: context.workspaceId, + workspaceMemberId: context.workspaceMemberId, + }, + ); + /** + * Non-destructive quarantine marker for a pulled Team mirror. The binding + * state is the central data-plane gate; the project metadata marker also + * protects legacy/raw read surfaces and records why the bytes remain on + * disk. Only a later authorized materialization clears it. + */ + const revokedTeamProjectMirrors = new Set( + listProjects(db) + .filter((project: any) => project?.metadata?.teamMirrorRevokedAt) + .map((project: any) => project.id as string), + ); + const setTeamProjectMirrorRevoked = ( + projectId: string, + revoked: boolean, + ): void => { + const project = getProject(db, projectId); + if (!project) return; + const metadata: Record = { + ...((project.metadata as Record | null) ?? {}), + }; + if (revoked) { + revokedTeamProjectMirrors.add(projectId); + if (metadata.teamMirrorRevokedAt) return; + metadata.teamMirrorRevokedAt = Date.now(); + } else { + revokedTeamProjectMirrors.delete(projectId); + if (!metadata.teamMirrorRevokedAt) return; + delete metadata.teamMirrorRevokedAt; + } + updateProject(db, projectId, { + metadata, + updatedAt: SYNC_KEEPS_UPDATED_AT, + }); + }; + // Collab realtime reconciliation: react to a `team-projects-changed` signal + // (hub push OR the 15s poller's own diff, wired below) by actually + // re-checking this daemon's `workspace_projects` rows against the remote + // catalog, not just refreshing the display cache. See + // `collab/workspace-projects-reconciler.ts` for the full design and its + // relationship to `reconcileUnboundProjectBeforeMove` / + // `reconcileLocalRowWithRemoteTeamAccess` (routes/project/index.ts), which + // this does NOT replace. + const reconcileWorkspaceProjectsFromRemote = ( + requestedWorkspaceId: string, + ) => { + // Capture the trigger's Workspace before the first await. Hub events pass + // their subscribed/event Workspace and pollers pass their persisted exact + // subscription scope. The directory then verifies that identity once, and + // the result is carried through every catalog/list/tombstone step below. + const capturedWorkspaceId = requestedWorkspaceId.trim(); + return reconcileWorkspaceProjectsWithRemote({ + getWorkspaceIdentity: async () => { + if (!capturedWorkspaceId) return null; + const directory = await fetchWorkspaceDirectory().catch(() => ({ + ok: false, + items: [], + })); + if (!directory.ok) return null; + const scope = teamResourceRequestScopeForWorkspaceId( + directory.items, + capturedWorkspaceId, + ); + if (!scope) return null; + return { + workspaceId: capturedWorkspaceId, + workspaceMemberId: scope.principal.memberId, + principal: scope.principal, + }; + }, + // Membership, not display: a catalog row whose latest publish failed is + // still registered to its owner, so it must keep counting as "remote + // lists it" here even though the display list hides it. Judging this + // dep by the display read demoted a teammate's sync-failed mirror into + // a self-attributed personal draft (recvqzjnshIlOe) — see + // `reconcilerRemoteTeamProjects`'s invariant comment. Both sources run + // through `withoutLocallyUnsharedProjects` so a row this member just + // moved back to personal cannot be re-bound out from under the move + // while the hub deletion is still propagating. + // The membership read is deliberately UNCACHED (the raw catalog client, + // not the SWR-wrapped display caches): reconciliation only runs on + // team-projects-changed signals, and a ≤TTL-stale list here is exactly + // the shape that misreads a just-shared row as absent. + listRemoteTeamProjects: async (identity) => { + // An absent row is destructive evidence only when the complete, + // unfiltered catalog was read successfully. The display list hides + // failed/pending publishes, so falling back to it could mistake a + // partial view for a real unshare and revoke a valid mirror. Throwing + // here makes the reconciler fail closed and leave every local binding + // untouched until the authoritative transport is available again. + if (!velaCliWorkspaceTeamProjectCatalog) { + throw new Error('complete team project catalog unavailable'); + } + return reconcilerRemoteTeamProjects({ + listCatalogMembership: async () => + (await withoutLocallyUnsharedProjects( + await velaCliWorkspaceTeamProjectCatalog.list(identity.principal), + { + workspaceId: identity.workspaceId, + workspaceMemberId: identity.workspaceMemberId, + }, + )).map((record) => ({ + projectId: record.projectId, + ownerMemberId: record.ownerMemberId, + })), + listDisplayTeamProjects: async () => { + throw new Error('display team project catalog is not authoritative'); + }, + }); + }, + // Materialization gate for the bind direction — see the dep's doc + // comment in workspace-projects-reconciler.ts. `getProject` is the same + // `projects`-table read `workspace_projects`' FOREIGN KEY points at. + hasLocalProject: (projectId) => getProject(db, projectId) != null, + listLocalTeamRows: (workspaceId): LocalTeamProjectBinding[] => + listWorkspaceProjects(db, workspaceId) + .filter((row: any) => row.workspaceVisibility === 'team') + .map((row: any) => ({ + projectId: row.id, + workspaceId: row.workspaceId, + visibility: row.workspaceVisibility, + resourceState: row.resourceState ?? null, + createdByWorkspaceMemberId: row.createdByWorkspaceMemberId ?? null, + resourceHubResourceId: row.resourceHubResourceId ?? null, + })), + getLocalBinding: (projectId): LocalTeamProjectBinding | null => { + const row = getWorkspaceProjectByProjectId(db, projectId) as any; + if (!row) return null; + return { + projectId, + workspaceId: row.workspaceId, + visibility: row.visibility, + resourceState: row.resourceState ?? null, + createdByWorkspaceMemberId: row.createdByWorkspaceMemberId ?? null, + resourceHubResourceId: row.resourceHubResourceId ?? null, + }; + }, + applyBind: (projectId, patch) => { + // `rebindWorkspaceProject` only corrects an EXISTING row (it never + // inserts — see its own doc comment in db.ts); a project this daemon + // has never locally bound at all needs `ensureWorkspaceProject` + // instead, seeded with the same patch so the fresh row is correct on + // arrival. + // + // Reconciling a binding against B's catalog changes no project content, + // so it must not restamp "last changed" — see SYNC_KEEPS_UPDATED_AT. + const synced = { ...patch, updatedAt: SYNC_KEEPS_UPDATED_AT }; + if (rebindWorkspaceProject(db, projectId, synced)) return; + ensureWorkspaceProject(db, { projectId, ...synced }); + }, + applyDemote: (workspaceId, projectId, patch) => updateWorkspaceProject(db, workspaceId, projectId, { + ...patch, + updatedAt: SYNC_KEEPS_UPDATED_AT, + }), + applyRevoke: (workspaceId, projectId, patch) => { + // Write the binding denial before the metadata marker. A crash between + // the two operations therefore fails closed, never open. The + // transaction keeps the auditable marker and authority state aligned. + db.transaction(() => { + updateWorkspaceProject(db, workspaceId, projectId, { + ...patch, + updatedAt: SYNC_KEEPS_UPDATED_AT, + }); + setTeamProjectMirrorRevoked(projectId, true); + })(); + }, + onError: (error) => console.warn('[od] workspace-projects reconciliation error:', error), + }); + }; + const resolveSharedProject = async ( + projectId: string, + scope?: TeamMirrorPullScope | null, + ) => { + // Catalog reads are data-plane operations: never let the Vela adapter + // substitute the daemon's mutable active Workspace. + if (!scope?.workspaceId || !scope.viewerMemberId) return null; + const project = velaCliTeamProjectCatalog + ? await velaCliTeamProjectCatalog.get(projectId, scope.workspaceId) + : (await teamProjectsLister(scope.workspaceId)) + .find((entry) => entry.projectId === projectId) ?? null; + if (!project) return null; + return (await withoutLocallyUnsharedProjects( + [project], + { + workspaceId: scope.workspaceId, + workspaceMemberId: scope.viewerMemberId, + }, + ))[0] ?? null; + }; + // Security-sensitive ownership decisions stay fresh. Pull, publish, + // presence, and mutation paths all use this exact lookup so an unshare or + // member revocation is observed immediately. + const resolveSharedProjectOwner = async ( + projectId: string, + explicitScope: { workspaceId: string; workspaceMemberId: string }, + ): Promise => { + const list = await withoutLocallyUnsharedProjects( + await teamProjectsLister(explicitScope.workspaceId), + explicitScope, + ); + return list.find((entry) => entry.projectId === projectId)?.ownerMemberId ?? null; + }; + // GET /collab/status is a display read whose request authority has already + // been verified. Reuse the explicit workspace+member catalog cache here so + // repeated project-open polls do not each wait on another Vela list process. + // No security-sensitive caller receives this resolver. + const resolveSharedProjectOwnerForStatus = async ( + projectId: string, + explicitScope: { workspaceId: string; workspaceMemberId: string }, + ): Promise => { + const list = await withoutLocallyUnsharedProjects( + await teamProjectsDisplayCache(explicitScope), + explicitScope, + ); + return list.find((entry) => entry.projectId === projectId)?.ownerMemberId ?? null; + }; + // Presence is project-bound data. Its relay scope comes only from the + // persisted project binding; an ambient active workspace is never a fallback. + const authoritativePresenceWorkspaces = new Set(); + const presenceScopeFor = (projectId: string): string | undefined => + findTeamWorkspaceIdForProject(db, projectId)?.trim() || undefined; + const verifyPresenceWorkspaceRequest = async ( + req: any, + projectId: string, + options: { fresh?: boolean } = {}, + ) => { + const verified = await verifyExplicitWorkspaceRequestContext( + { req }, + options, + ); + if (!verified.ok) return verified; + const binding = getWorkspaceProjectByProjectId(db, projectId); + if ( + binding?.workspaceId + && binding.workspaceId !== verified.context.workspaceId + ) { + return { + ok: false as const, + status: 403 as const, + code: 'WORKSPACE_ACCESS_DENIED' as const, + message: 'the requested workspace does not own this project', + }; + } + return verified; + }; + const presenceRoutes = registerCollabPresenceRoutes(app, { + collab, + // Null when this run has no vela-cli collab transport, which is what keeps + // the process-local presence fallback reachable. See + // `createCollabPresenceCloudClient` for the invariant. + cloud: createCollabPresenceCloudClient(velaCliCollabClient, presenceScopeFor), + verifyWorkspaceRequest: (req, projectId) => + verifyPresenceWorkspaceRequest(req, projectId), + verifyWorkspaceReadRequest: (req, projectId) => + verifyPresenceWorkspaceRequest(req, projectId, { fresh: false }), + isProjectShared: async (projectId, context) => { + const projectContext = + context ?? await resolveBoundProjectWorkspaceContext(projectId); + if (!projectContext || projectContext.workspaceType !== 'team') return false; + return Boolean( + await resolveSharedProjectOwner(projectId, { + workspaceId: projectContext.workspaceId, + workspaceMemberId: projectContext.workspaceMemberId, + }), + ); + }, + cloudAuthorizesProjectPresence: (projectId) => { + const workspaceId = findTeamWorkspaceIdForProject(db, projectId)?.trim(); + return Boolean( + workspaceId && authoritativePresenceWorkspaces.has(workspaceId), + ); + }, + }); + invalidatePresenceReadCache = presenceRoutes.invalidatePresence; + markPresenceReadCacheStale = presenceRoutes.markPresenceStale; + // Author-side publish TRIGGER (C spec §D1): watch the projects THIS daemon's + // member owns + has shared, and coalesce every file edit into a debounced + // publish. The read-only gate (team-shared AND owner === me) means a member's + // pulled copy is never watched, so an inbound pull can't loop into a publish and + // a member can't publish edits to someone else's project. + const collabPublishWatcher = createCollabPublishWatcher({ + notifyChanged: (projectId, principal) => + collab.scheduler.notifyChanged(projectId, 'file-change', principal), + listProjectIds: () => listProjects(db).map((project: { id: string }) => project.id), + shouldPublish: async (projectId) => { + if (projectIsUnmaterializedSharedPlaceholder(projectId)) return false; + const workspaceId = findTeamWorkspaceIdForProject(db, projectId)?.trim(); + if (!workspaceId) return false; + const directory = await fetchWorkspaceDirectory().catch(() => ({ + ok: false as const, + items: [], + })); + if (!directory.ok) return false; + const scope = teamResourceRequestScopeForWorkspaceId( + directory.items, + workspaceId, + ); + if (!scope?.canShare) return false; + const ownerMemberId = await resolveSharedProjectOwner(projectId, { + workspaceId, + workspaceMemberId: scope.principal.memberId, + }); + if (ownerMemberId !== scope.principal.memberId) return false; + collab.rememberTeamShare(projectId, scope.principal); + return scope.principal; + }, + subscribeFiles: (projectId, onChange) => { + const watchProject = getProject(db, projectId); + const sub = subscribeFileEvents(PROJECTS_DIR, projectId, (evt) => { + if (evt.type === 'file-changed') onChange(); + }, { metadata: watchProject?.metadata }); + return { unsubscribe: () => sub.unsubscribe() }; + }, + onError: (error) => console.warn('[od] collab publish watcher error:', error), + }); + collabPublishWatcher.start(); + const sharedProjectPullProfiling = + sharedProjectPullProfileEnabled(process.env); + const verifyProjectWorkspaceContextForRequest = async ( + req: any, + projectId?: string, + options: { fresh?: boolean } = {}, + ) => { + const verified = await verifyExplicitWorkspaceRequestContext( + { req }, + options, + ); + if (!verified.ok) return verified; + if (projectId) { + const binding = getWorkspaceProjectByProjectId(db, projectId); + if ( + binding?.workspaceId + && binding.workspaceId !== verified.context.workspaceId + ) { + return { + ok: false as const, + status: 403 as const, + code: 'WORKSPACE_ACCESS_DENIED' as const, + message: 'the requested workspace does not own this project', + }; + } + } + return verified; + }; + const verifiedWorkspaceContextForRequest = ( + req: any, + projectId?: string, + ) => verifyProjectWorkspaceContextForRequest(req, projectId); + const verifiedWorkspaceReadContextForRequest = ( + req: any, + projectId?: string, + ) => verifyProjectWorkspaceContextForRequest( + req, + projectId, + { fresh: false }, + ); + const resolveProjectCommentWorkspaceContextWith = async ( + req: any, + projectId: string, + verify: ( + req: any, + projectId?: string, + ) => ReturnType, + ) => { + const binding = getWorkspaceProjectByProjectId(db, projectId); + if (revokedTeamProjectMirrors.has(projectId)) { + return { + ok: false as const, + status: 403 as const, + code: 'WORKSPACE_PROJECT_PERMISSION_DENIED', + message: 'workspace project read is not allowed', + }; + } + if (!binding?.workspaceId) { + return { ok: true as const, context: null }; + } + if (binding.resourceState === 'deleted') { + return { + ok: false as const, + status: 403 as const, + code: 'WORKSPACE_PROJECT_PERMISSION_DENIED', + message: 'workspace project read is not allowed', + }; + } + const verified = await verify(req, projectId); + if (!verified.ok) return verified; + return { ok: true as const, context: verified.context }; + }; + const resolveProjectCommentWorkspaceContext = ( + req: any, + projectId: string, + ) => resolveProjectCommentWorkspaceContextWith( + req, + projectId, + verifiedWorkspaceContextForRequest, + ); + const resolveProjectCommentReadWorkspaceContext = ( + req: any, + projectId: string, + ) => resolveProjectCommentWorkspaceContextWith( + req, + projectId, + verifiedWorkspaceReadContextForRequest, + ); + const verifiedTeamMirrorScope = async ( + scope: TeamMirrorPullScope, + ): Promise => { + const directory = await fetchWorkspaceDirectory().catch(() => ({ + ok: false as const, + items: [], + })); + if (!directory.ok) return false; + return directory.items.some( + (item) => + item.workspaceId === scope.workspaceId + && item.workspaceMemberId === scope.viewerMemberId + && item.workspaceType === 'team' + && item.memberStatus === 'active' + && item.lifecycleState === 'active' + && item.workspaceId === scope.resourceTeamId, + ); + }; + const projectContentTransferStates = + createProjectContentTransferStateStore({ + onChange: (scope, state) => { + emitProjectEvent(scope.projectId, { + type: 'project-content-transfer-state', + projectId: scope.projectId, + at: state.updatedAt, + }); + }, + }); + let observeLegacyTeamProjectPull = async ( + _projectId: string, + _scope: TeamMirrorPullScope, + _version: number, + ): Promise => {}; + const collabSyncRoutes = registerCollabSyncRoutes(app, { + collab, + verifyWorkspaceRequest: verifiedWorkspaceContextForRequest, + verifyWorkspaceReadRequest: verifiedWorkspaceReadContextForRequest, + verifyWorkspaceScope: verifiedTeamMirrorScope, + readContentTransferState: (projectId, scope) => + projectContentTransferStates.read({ projectId, ...scope }), + beginContentTransfer: (projectId, scope, version) => + projectContentTransferStates.begin( + { projectId, ...scope }, + version, + ).token, + finishContentTransfer: (projectId, scope, token, version) => { + projectContentTransferStates.finish( + { projectId, ...scope }, + token, + version, + ); + }, + // Register-on-pull: after a member pulls a shared project, insert a local + // project record so it appears in /api/projects and opens read-only (the + // member is not the owner). Idempotent — an already-local project is a no-op. + projectStore: { + get: (projectId) => getProject(db, projectId), + has: (projectId) => getProject(db, projectId) != null, + register: (input) => { + insertProject(db, { + id: input.id, + name: input.name, + skillId: input.skillId, + designSystemId: input.designSystemId, + metadata: input.metadata, + createdAt: input.createdAt, + updatedAt: input.updatedAt, + }); + }, + update: (input) => { + updateProject(db, input.id, { + name: input.name, + skillId: input.skillId, + designSystemId: input.designSystemId, + metadata: input.metadata, + updatedAt: input.updatedAt, + }); + }, + materializeTeamMirror: (input, scope) => materializePulledTeamMirror(db, input, scope), + materializeAuthorizedTeamMirror: (input, scope, receipt) => + materializePulledTeamMirror(db, input, scope, receipt), + }, + resolveProjectDir: async (projectId) => { + const project = getProject(db, projectId); + if (project) await ensureProject(PROJECTS_DIR, projectId, project.metadata); + return resolveProjectShareDir(PROJECTS_DIR, projectId, project, resolveProjectDir); + }, + resolvePullDir: (projectId) => resolveProjectDir(PROJECTS_DIR, projectId), + readMaterializedVersion: (projectId, scope) => { + const authorized = getTeamProjectMaterialization( + db, + scope.workspaceId, + projectId, + ); + return latestTeamProjectMaterializationVersion( + authorized, + teamResourceVersions.get( + scope.workspaceId, + 'project-content', + teamProjectContentResourceId(projectId, scope), + ), + projectId, + scope, + ); + }, + authorizedTeamProjectPull: { + journalDir: teamMirrorPromotionJournalDir, + }, + writeMaterializedVersion: (projectId, scope, version) => + teamResourceVersions.set( + scope.workspaceId, + 'project-content', + teamProjectContentResourceId(projectId, scope), + String(version), + ), + onLegacyPullMaterialized: (projectId, scope, version) => + observeLegacyTeamProjectPull(projectId, scope, version), + resolveSharedProject, + resolveSharedProjectOwner, + resolveSharedProjectOwnerForStatus, + isTeamProjectRevoked: (projectId) => + revokedTeamProjectMirrors.has(projectId), + // Non-destructive revocation flag for a pulled team mirror: the pull gate + // sets it when a project has left the team (files stay on disk but stop + // being served) and clears it on a successful re-pull. Read routes refuse to + // serve a project once this is set. + markTeamProjectRevoked: setTeamProjectMirrorRevoked, + // Set/clear the unmaterialized shared-project placeholder stamp (the + // recvqzaDvUU6B3 fresh-install wipe guard) — same non-destructive + // metadata-flag pattern as markTeamProjectRevoked above. + markSharedProjectPlaceholder: (projectId: string, placeholder: boolean) => { + const project = getProject(db, projectId); + if (!project) return; + const metadata: Record = { ...((project.metadata as Record | null) ?? {}) }; + if (placeholder) { + if (metadata[SHARED_PROJECT_PLACEHOLDER_METADATA_KEY]) return; + metadata[SHARED_PROJECT_PLACEHOLDER_METADATA_KEY] = Date.now(); + } else { + if (!metadata[SHARED_PROJECT_PLACEHOLDER_METADATA_KEY]) return; + delete metadata[SHARED_PROJECT_PLACEHOLDER_METADATA_KEY]; + } + // Raised on placeholder registration and lowered the moment a pull + // materializes real content. Both are sync steps on someone else's + // project — see SYNC_KEEPS_UPDATED_AT. This is the flag that made a + // member's very first open of a shared project read 「刚刚更新」. + updateProject(db, projectId, { metadata, updatedAt: SYNC_KEEPS_UPDATED_AT }); + }, + // Retracted-share heal (飞书 recvqA6qhV7St1): delete a placeholder record + // whose backing hub resource turned out to be tombstoned. Re-checks the + // placeholder stamp HERE — deletion is only ever legal for a record the + // stamp proves contentless, so a pull that materialized real content + // between the heal's decision and this call is never destroyed. The + // `workspace_projects` binding (if any) goes with it via ON DELETE + // CASCADE, and the empty content directory is removed best-effort. + retireUnmaterializedSharedPlaceholder: (projectId: string) => { + const project = getProject(db, projectId); + if (!isUnmaterializedSharedPlaceholder(project)) return; + dbDeleteProject(db, projectId); + void removeProjectDir(PROJECTS_DIR, projectId).catch(() => {}); + }, + invalidateTeamProjectCatalog: () => teamProjectsDisplayCache.invalidate(), + onTeamShareStateChanged: persistWorkspaceProjectVisibility, + // See `notifyFilesChanged`'s doc comment on RegisterCollabSyncRoutesDeps + // (recvq6CIesNvWZ): a pull's directory-replace can silently orphan the + // project's chokidar watcher, so a successful pull notifies any open + // FileViewer directly over the existing `file-changed` SSE channel + // instead of depending on the watcher having survived the swap. + notifyFilesChanged: (projectId: string) => + emitProjectEvent(projectId, { type: 'file-changed', path: '', kind: 'change' }), + // A pull that replaces the "共享项目" placeholder record with the real + // project name (registerPulledProject) changed metadata the web renders + // from its `projects` state; push the existing `project-metadata-changed` + // thin signal so the open view re-fetches the record instead of keeping + // the placeholder title until a page reload (recvqhwv6RPU1j). + notifyProjectMetadataChanged: (projectId: string) => + emitProjectEvent(projectId, { + type: 'project-metadata-changed', + projectId, + at: Date.now(), + }), + ...(sharedProjectPullProfiling + ? { + onPullTiming: emitSharedProjectPullTiming, + } + : {}), + // Resolve the owner's display name + role from the collab-cloud directory so + // /collab/status can hand the client a named "shared project" banner. + ...(collabCloud + ? { + resolveOwnerDisplayName: async ( + memberId: string, + context: WorkspaceCollabContext, + ) => { + const entry = await collabCloud.resolveMember(memberId, context); + return entry ? { displayName: entry.displayName, role: entry.role } : null; + }, + } + : {}), + }); + // Hub push-channel consumer for 'project-content-changed' (recvqmKQRiIlYf): + // when a teammate publishes new content for a shared project, pull it NOW — + // daemon-side, no open tab required — through the SAME flow the member + // web's POST /collab/pull runs (collabSyncRoutes.pullSharedProject, which + // also coalesces the two when they race). Every guard is fail-closed and + // every failure degrades silently to the web's ~5s status polling, which + // stays running untouched as the fallback; see + // collab/proactive-content-pull.ts for the guard boundary (never pull a + // project this member owns; an unbound first share requires an exact + // event-workspace/active-workspace match; dedupe by hub version). + const proactiveTeamProjectMaterializedVersion = ( + target: ProactiveContentPullTarget, + ) => { + const authorized = getTeamProjectMaterialization( + db, + target.workspaceId, + target.projectId, + ); + const version = latestTeamProjectMaterializationVersion( + authorized, + teamResourceVersions.get( + target.workspaceId, + 'project-content', + teamProjectContentResourceId(target.projectId, target), + ), + target.projectId, + target, + ); + return version == null ? null : String(version); + }; + const proactiveContentPull = createProactiveContentPull({ + getLocalBinding: (projectId) => { + const row = getWorkspaceProjectByProjectId(db, projectId) as + | { workspaceId: string; visibility: 'personal' | 'team' } + | null; + if (!row) return null; + return { workspaceId: row.workspaceId, visibility: row.visibility }; + }, + // Resolve the event/binding Workspace itself. Global active Workspace is + // control-plane selection only and cannot retarget or cancel this pull. + getWorkspaceIdentity: async (workspaceId) => + activeTeamWorkspaceIdentity( + await resolveAuthoritativeTeamWorkspaceContext(workspaceId), + ), + // A witness may skip the route's pre-transport catalog gate, so only this + // uncached authoritative lookup is allowed to mint one. The display SWR + // owner cache remains wired everywhere else. + resolveSharedProjectOwner: async (projectId, workspaceId) => { + const context = + await resolveAuthoritativeTeamWorkspaceContext(workspaceId); + const identity = activeTeamWorkspaceIdentity(context); + if (!context || !identity) return null; + return resolveSharedProjectOwner(projectId, { + workspaceId: identity.workspaceId, + workspaceMemberId: identity.workspaceMemberId, + }); + }, + // Catch-up reads the rich catalog exactly once per verified connection + // (or missing-project floor). Re-check the same exact directory scope + // after the CLI await; changing global active Workspace is irrelevant. + listSharedProjects: async (workspaceId) => { + if (!velaCliWorkspaceTeamProjectCatalog) return []; + const beforeContext = + await resolveAuthoritativeTeamWorkspaceContext(workspaceId); + if (!beforeContext) return []; + const principal = contextToResourceHubPrincipal(beforeContext); + if (!principal || principal.teamId !== workspaceId) return []; + const projects = await velaCliWorkspaceTeamProjectCatalog.list(principal); + const afterContext = + await resolveAuthoritativeTeamWorkspaceContext(workspaceId); + if (!afterContext) return []; + const afterPrincipal = contextToResourceHubPrincipal(afterContext); + if ( + !afterPrincipal + || afterPrincipal.teamId !== principal.teamId + || afterPrincipal.memberId !== principal.memberId + ) { + return []; + } + return projects + .filter((project) => project.workspaceId === workspaceId && project.access.canView) + .map((project) => ({ + projectId: project.projectId, + ownerMemberId: project.ownerMemberId, + })); + }, + hasMaterializedProject: async (projectId, target) => { + const project = getProject(db, projectId); + if (!project) return false; + // Authorized Vela mirrors contain the shared project files, not the + // local-only `.open-design/project.json`. Their exact-scope receipt is + // the durable version proof; the live directory proves the promoted + // namespace still exists. Both are required so a deleted tree heals, + // while another workspace/owner's receipt can never satisfy this pull. + if (proactiveTeamProjectMaterializedVersion(target) == null) { + return false; + } + const projectDir = resolveProjectShareDir( + PROJECTS_DIR, + projectId, + project, + resolveProjectDir, + ); + const entry = await fs.promises.lstat(projectDir).catch(() => null); + return Boolean( + entry && + entry.isDirectory() && + !entry.isSymbolicLink(), + ); + }, + materializedVersion: proactiveTeamProjectMaterializedVersion, + // The resource is owner-scoped; the same captured team/owner principal is + // used by the shared pull below. The member session remains the transport + // credential, while Vela authorizes this explicit target principal. + publishedHead: (target) => + collab.publishedHead(target.projectId, { + teamId: target.resourceTeamId, + memberId: target.ownerMemberId, + role: 'member', + lifecycleState: 'active', + workspaceType: 'team', + }), + pullSharedProject: (target, expectedVersion) => + collabSyncRoutes.pullSharedProject(target.projectId, { + workspaceId: target.workspaceId, + resourceTeamId: target.resourceTeamId, + viewerMemberId: target.viewerMemberId, + ownerMemberId: target.ownerMemberId, + }, target.authorizationWitness, expectedVersion, target.authorizedStageInvocation), + // All Projects is a list-level surface and does not subscribe to every + // project-scoped SSE. Once an inbound pull has actually materialized the + // tree, nudge that surface so its failed pre-pull cover scan runs again + // immediately instead of waiting for the 15s refresh floor. + onPulled: async (target, version) => { + emitWorkspaceEvent(target.workspaceId, { + type: 'team-project-content-ready', + projectId: target.projectId, + workspaceId: target.workspaceId, + at: Date.now(), + }); + }, + ...(sharedProjectPullProfiling + ? { + onTiming: emitSharedProjectPullTiming, + } + : {}), + onError: (error) => + console.warn('[od] proactive shared-project pull failed (web polling remains the fallback):', String(error)), + onCatchUp: (event) => { + if ( + event.phase === 'retry-scheduled' || + event.phase === 'retry-exhausted' + ) { + console.info( + `[od] shared-project content catch-up ${event.phase} mode=${event.mode} lane=${event.lane} ` + + `workspaceId=${event.workspaceId ?? 'unknown'} ` + + `projectId=${event.projectId ?? 'all'} attempt=${event.attempt ?? event.failures ?? 0} ` + + `delayMs=${event.delayMs ?? 0}`, + ); + return; + } + if (event.phase === 'skipped') { + console.info( + `[od] shared-project content catch-up skipped mode=${event.mode} lane=${event.lane} reason=${event.reason ?? 'unknown'}`, + ); + return; + } + if (event.phase === 'started') { + console.info( + `[od] shared-project content catch-up started mode=${event.mode} lane=${event.lane} workspaceId=${event.workspaceId ?? 'unknown'}`, + ); + return; + } + console.info( + `[od] shared-project content catch-up completed mode=${event.mode} lane=${event.lane} ` + + `workspaceId=${event.workspaceId ?? 'unknown'} scanned=${event.scanned ?? 0} ` + + `candidates=${event.candidates ?? 0} headChecks=${event.headChecks ?? 0} ` + + `heads=${event.heads ?? 0} ` + + `suppressed=${event.suppressed ?? 0} complete=${event.complete === true}`, + ); + }, + }); + observeLegacyTeamProjectPull = (projectId, scope, version) => + proactiveContentPull.observeMaterialized( + { projectId, ...scope }, + version, + ); + // Stale-while-revalidate the member directory by explicit Workspace scope. + // The web shell re-reads members on every navigation (and several mounted + // consumers fetch it at once); the underlying collab-cloud read is ~1.5s, so + // without this a home/drafts load serialized 5-7 slow member reads behind the + // 6-connection cap. SWR serves the roster instantly after the first load and + // refreshes in the background, so a member who joins still resolves within a + // poll tick. + // Same two-layer split as the catalog above: the persistent snapshot answers + // the cold read (digest token unchanged -> serve the roster off disk), the SWR + // above it answers the burst of consumers one navigation mounts at once. + const teamMembersCache = collabCloud + ? (() => { + const snapshots = new Map< + string, + ReturnType> + >(); + const lists = new Map< + string, + ReturnType> + >(); + const read = ( + context: WorkspaceCollabContext, + ): Promise => { + const scope = teamProjectsDisplayScopeFromContext(context); + if (!scope) return Promise.resolve([]); + const key = teamProjectsDisplayScopeKey(scope); + let snapshot = snapshots.get(key); + if (!snapshot) { + const capturedContext = { ...context }; + snapshot = createPersistentSyncCache({ + face: 'members', + fetch: () => collabCloud.listMembers(capturedContext), + readDigest: createSyncDigestReader({ + env: process.env, + getWorkspaceId: () => scope.workspaceId, + onError: (error) => + console.warn('[od] team members digest error:', error), + }), + store: collabSyncSnapshots, + parseSnapshot: parseMemberDirectorySnapshot, + onError: (error) => + console.warn('[od] team members snapshot cache error:', error), + }); + snapshots.set(key, snapshot); + } + let list = lists.get(key); + if (!list) { + const capturedSnapshot = snapshot; + list = createSwrCache( + () => capturedSnapshot(), + () => key, + 3000, + ); + lists.set(key, list); + } + return list(); + }; + return Object.assign(read, { + invalidate(context?: WorkspaceCollabContext) { + const scope = context + ? teamProjectsDisplayScopeFromContext(context) + : null; + if (scope) { + const key = teamProjectsDisplayScopeKey(scope); + lists.get(key)?.invalidate(); + lists.delete(key); + snapshots.get(key)?.invalidate(); + snapshots.delete(key); + return; + } + for (const list of lists.values()) list.invalidate(); + for (const snapshot of snapshots.values()) snapshot.invalidate(); + lists.clear(); + snapshots.clear(); + }, + }); + })() + : null; + const teamMembersForDisplay = async ( + context: WorkspaceCollabContext | null, + ): Promise => { + if (!teamMembersCache) return []; + return context ? teamMembersCache(context) : []; + }; + let workspaceHubSubscriptions: WorkspaceHubSubscriptionManager | null = null; + const workspaceBillingRuntime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async ({ workspaceId, workspaceMemberId }) => { + const directory = await fetchWorkspaceDirectory(); + if (!directory.ok) { + throw Object.assign(new Error('workspace directory unavailable'), { + code: 'workspace_directory_unavailable', + }); + } + const membership = directory.items.find( + (item) => + item.workspaceId === workspaceId && + item.workspaceMemberId === workspaceMemberId && + item.workspaceType === 'team' && + item.memberStatus === 'active' && + item.lifecycleState === 'active', + ); + if (!membership) throw new WorkspaceBillingAccessRevokedError(); + return fetchVelaWorkspaceBillingProjection(workspaceId); + }, + onStateChange: (state) => { + // The request that created a runtime already receives this state in its + // response. Background catch-up/retry/poll completion needs a thin nudge + // so old and new web clients re-read the same explicit route. + if (!shouldEmitWorkspaceBillingRuntimeNudge(state)) return; + emitWorkspaceEvent(state.workspaceId, { + type: 'billing-changed', + workspaceId: state.workspaceId, + revision: `runtime:${state.revision}`, + at: Date.now(), + }); + }, + onInterestSetChange: (interests) => { + workspaceHubSubscriptions?.setBillingInterests( + interests.map((interest) => interest.workspaceId), + ); + }, + }); + /** + * Warm or revalidate both digest faces for one exact directory-verified + * Workspace/member identity. A UI switch uses the lightweight warm path; + * reconnect/source-gap recovery invalidates only that scope first so a + * still-fresh SWR entry cannot hide changes that happened while disconnected. + */ + const refreshWorkspaceDigestFaces = async ( + workspaceId: string, + options: { revalidate?: boolean; freshAuthority?: boolean } = {}, + ): Promise => { + if (!workspaceId) return; + const context = + await resolveAuthoritativeTeamWorkspaceContext(workspaceId, { + fresh: options.freshAuthority, + }); + if (options.revalidate) { + const scope = teamProjectsDisplayScopeFromContext(context); + if (scope) teamProjectsDisplayCache.invalidate(scope); + teamMembersCache?.invalidate(context ?? undefined); + } + await Promise.all([ + teamProjectsForDisplay(context), + teamMembersForDisplay(context), + ]); + }; + const warmWorkspaceDigestFaces = (workspaceId: string) => { + if (!workspaceId) return; + void refreshWorkspaceDigestFaces(workspaceId, { + freshAuthority: true, + }).catch(() => undefined); + }; + registerCollabContextRoutes(app, { + workspaceContext: collab.workspaceContext, + activeWorkspace, + // A tab-local selection leaves this exact Workspace's scoped caches cold. + // Warm only the directory-verified id announced by that request; the + // daemon-global legacy pin is neither read nor updated. + onWorkspaceSwitched: (workspaceId) => warmWorkspaceDigestFaces(workspaceId), + billingRuntime: workspaceBillingRuntime, + // Same directory read the route would have made on its own, wrapped so every + // workspace type it carries is memoized for the team-share invariant. + listWorkspaceDirectory, + fetchWorkspaceDirectory, + refreshWorkspaceDirectoryAfterMutation: + workspaceDirectoryAuthority.refreshAfterMutation, + // Reuse the shared team-projects lister (which holds the shared vela-cli + // catalog adapter). Without this the endpoint built a fresh adapter per + // request and re-ran the one-off `vela team-projects --help` capability + // probe — an extra CLI spawn (and, on the current CLI, a blocking analytics + // POST) on every workspace projects load. + listTeamProjects: teamProjectsForRequest, + // Expose the collab-cloud member directory so the web client can resolve + // comment authors + owner names to a name + role. + ...(teamMembersCache ? { listMembers: teamMembersForDisplay } : {}), + // Collab realtime hop-2: the workspace-scoped invalidation SSE. The route + // registers/deregisters its sink here; the poller below feeds them. + createSseResponse, + workspaceEventSinks, + }); + // Reconnect/source-gap recovery belongs to the Workspace whose upstream + // subscription observed the gap. Keep one signature state per Workspace so + // recovering subscribed A while B is the UI selection neither compares A + // against B's digest nor drops A's refresh. + const scopedWorkspaceInvalidationPollers = new Map< + string, + ReturnType + >(); + const workspaceInvalidationPollerFor = (workspaceIdInput: string) => { + const workspaceId = workspaceIdInput.trim(); + let poller = scopedWorkspaceInvalidationPollers.get(workspaceId); + if (!poller) { + poller = createWorkspaceInvalidationPoller({ + getWorkspaceContext: async () => { + const context = + await resolveAuthoritativeTeamWorkspaceContext(workspaceId); + workspaceTypes.learn(context); + return context; + }, + listTeamProjects: (context) => teamProjectsForDisplay(context), + listMembers: (context) => teamMembersForDisplay(context), + emit: (payload, context) => { + handlePolledWorkspaceInvalidation( + payload, + (scopedPayload) => + emitWorkspaceEvent(workspaceId, scopedPayload), + () => reconcileWorkspaceProjectsFromRemote( + activeTeamWorkspaceIdentity(context)?.workspaceId ?? workspaceId, + ), + ); + }, + onTeamProjectsObserved: ({ workspaceId: observedWorkspaceId }) => + proactiveContentPull.advanceRecoveryFloor(observedWorkspaceId), + onError: (error) => + console.warn( + `[od] workspace ${workspaceId} invalidation recovery error:`, + error, + ), + }); + scopedWorkspaceInvalidationPollers.set(workspaceId, poller); + } + return poller; + }; + const pollWorkspaceInvalidationForWorkspace = ( + workspaceIdInput: string, + ): Promise => { + const workspaceId = workspaceIdInput.trim(); + if (!workspaceId) return Promise.resolve(); + return workspaceInvalidationPollerFor(workspaceId).pollOnce(); + }; + // Collab realtime hop-1: cloud hub → daemon push channel. The hub emits the + // same thin invalidation signals the web would otherwise discover by + // polling. Every upstream stream comes from an explicit leased Workspace + // interest; reconnect/source-gap handlers run one exact-scope poller cycle + // to close the disconnect gap. + const dirtyCommentProjects = new Set(); + // One hub write can legitimately fan out as two thin events (a display-name + // carrying catalog upsert emits team-projects-changed AND + // project-metadata-changed, ~1ms apart). Both map to the same workspace + // "list changed" signal here, so collapse repeats inside a short window — + // the signal is idempotent, but no reason to make every web client refetch + // twice for one write. + const lastTeamProjectsSignalAt = new Map(); + const emitTeamProjectsChangedDeduped = (workspaceId: string) => { + const now = Date.now(); + const lastSignalAt = lastTeamProjectsSignalAt.get(workspaceId) ?? 0; + if (now - lastSignalAt < 250) return; + lastTeamProjectsSignalAt.set(workspaceId, now); + void resolveAuthoritativeTeamWorkspaceContext(workspaceId) + .then((context) => { + const scope = teamProjectsDisplayScopeFromContext(context); + if (scope) teamProjectsDisplayCache.invalidate(scope); + return teamProjectsForDisplay(context); + }) + .catch(() => undefined); + emitWorkspaceEvent( + workspaceId, + { type: 'team-projects-changed', at: now }, + ); + }; + const startWorkspaceHubSubscriber = (subscribedWorkspaceId: string) => + startHubEventsSubscriber({ + resolveEndpoint: async () => { + // Same gating as the workspace-context provider: only the vela source + // has a hub to subscribe to (dev daemons must not dial production). + if (process.env.OD_WORKSPACE_CONTEXT_SOURCE?.trim() !== 'vela') return null; + const session = readVelaControlApiContext(process.env); + if (!session?.controlKey || !session.apiUrl) return null; + return { + url: new URL('/api/v1/collab/events', session.apiUrl).toString(), + workspaceId: subscribedWorkspaceId, + headers: { + authorization: `Bearer ${session.controlKey}`, + 'x-vela-workspace-id': subscribedWorkspaceId, + }, + }; + }, + onStateChange: (state) => { + if (state === 'disconnected') { + authoritativePresenceWorkspaces.delete(subscribedWorkspaceId); + } + console.info(`[od] hub events channel ${state}`); + }, + onConnect: ({ reconnect, workspaceId, capabilities }) => { + const verifiedWorkspaceId = workspaceId ?? subscribedWorkspaceId; + if (capabilities.includes(AUTHORITATIVE_PROJECT_PRESENCE_CAPABILITY)) { + authoritativePresenceWorkspaces.add(verifiedWorkspaceId); + } else { + authoritativePresenceWorkspaces.delete(verifiedWorkspaceId); + } + console.info( + `[od] hub events workspace verified workspaceId=${workspaceId ?? 'unknown'} reconnect=${reconnect}`, + ); + handleHubVerifiedConnection( + verifiedWorkspaceId, + (exactWorkspaceId) => + proactiveContentPull.catchUpPublishedHeads(exactWorkspaceId), + (exactWorkspaceId) => { + // A reconnect is closed exactly once by onReconnect below. Keep + // this initial-connect hook from scheduling a duplicate catch-up. + if (!reconnect) workspaceBillingRuntime.reconnect(exactWorkspaceId); + }, + ); + }, + onDrop: ({ reason, eventName, expectedWorkspaceId, actualWorkspaceId }) => { + console.warn( + `[od] hub event dropped reason=${reason} event=${eventName} ` + + `expectedWorkspaceId=${expectedWorkspaceId ?? 'unknown'} ` + + `actualWorkspaceId=${actualWorkspaceId ?? 'unknown'}`, + ); + }, + onEvent: (event) => { + const eventWorkspaceId = + event.workspaceId ?? subscribedWorkspaceId; + console.info( + `[od] hub workspace event received type=${event.type} ` + + `workspaceId=${eventWorkspaceId} ` + + `projectId=${event.projectId ?? 'unknown'} version=${event.version ?? 'unknown'}`, + ); + switch (event.type) { + case 'team-projects-changed': { + // Catalog changed (share/unshare). Refresh the display cache and + // signal the web, AND run a real `workspace_projects` + // reconciliation pass — see `collab/workspace-projects-reconciler.ts`. + handleHubTeamProjectsChanged( + () => emitTeamProjectsChangedDeduped( + eventWorkspaceId, + ), + () => reconcileWorkspaceProjectsFromRemote( + eventWorkspaceId, + ), + ); + // Hub catalog writes carry the affected project id on current Vela + // deployments, so keep the latency-sensitive recovery targeted. An + // older/unscoped event still refreshes and reconciles the catalog; + // the poller's throttled 30s bounded full recovery remains its + // safety floor. + if (event.workspaceId && event.projectId) { + void proactiveContentPull.materializeMissingProjects( + event.workspaceId, + event.projectId, + ); + } + break; + } + case 'project-metadata-changed': { + // A rename only — refresh the display cache/signal the web (same + // as team-projects-changed) and additionally ping the open project + // view so its title can follow the rename. No reconciliation pass: + // a rename never changes WHICH projects are team-shared. + emitTeamProjectsChangedDeduped( + eventWorkspaceId, + ); + if (event.projectId) { + emitProjectEvent(event.projectId, { + type: 'project-metadata-changed', + projectId: event.projectId, + at: Date.now(), + }); + } + break; + } + case 'comment-changed': { + const projectId = event.projectId; + if (!projectId) break; + if (activeProjectEventSinks.has(projectId)) { + // Project is open here — pull IT now instead of waiting for the + // next poll tick; the merge emits `comment-changed` to the web. + // A consumed dirty mark is only redeemed by a pull that actually + // ran; on a no-op/failed pull restore it so the next comment read + // retries instead of losing the event outright. + dirtyCommentProjects.delete(projectId); + void resolveBoundProjectWorkspaceContext(projectId) + .then((context) => + context?.workspaceId === eventWorkspaceId + ? collabCloud?.pullProject(projectId, context) ?? false + : false, + ) + .then((pulled) => { + if (!pulled) dirtyCommentProjects.add(projectId); + }) + .catch(() => dirtyCommentProjects.add(projectId)); + } else { + // Closed project: just mark dirty. The open-project path pulls + // immediately, and an unopened project costs zero requests. + dirtyCommentProjects.add(projectId); + } + break; + } + case 'presence-changed': { + if (event.projectId) { + const projectId = event.projectId; + markPresenceReadCacheStale(projectId, eventWorkspaceId); + void resolveBoundProjectWorkspaceContext(projectId) + .then((context) => { + if (context?.workspaceId !== eventWorkspaceId) return; + emitProjectEvent(projectId, { + type: 'presence-changed', + projectId, + at: Date.now(), + }); + }) + .catch(() => undefined); + } + break; + } + case 'project-content-changed': { + // A teammate published a new version. Pull it daemon-side NOW so + // the local mirror stays fresh even with no tab open; after the + // pull lands, the existing post-pull signals (`file-changed` + + // `project-metadata-changed`) reach any open view over the same + // SSE path a web-triggered pull uses. All ownership/binding guards + // live in collab/proactive-content-pull.ts — an owner daemon + // receiving its own publish echo never pulls over its working + // tree, and failures degrade silently to the web's status polling. + if (sharedProjectPullProfiling) { + const profileReceivedAtMs = Date.now(); + emitSharedProjectPullTiming({ + phase: 'event-received', + projectId: event.projectId ?? 'unknown', + ...(event.version != null ? { version: event.version } : {}), + receivedAtMs: profileReceivedAtMs, + atMs: profileReceivedAtMs, + }); + void proactiveContentPull.handleContentChanged({ + ...event, + workspaceId: eventWorkspaceId, + profileReceivedAtMs, + }); + } else { + void proactiveContentPull.handleContentChanged({ + ...event, + workspaceId: eventWorkspaceId, + }); + } + // Keep the thin nudge for an OPEN project view so its status/banner + // refreshes immediately rather than on the next ~5s poll tick. + if (event.projectId && activeProjectEventSinks.has(event.projectId)) { + emitProjectEvent(event.projectId, { + type: 'project-metadata-changed', + projectId: event.projectId, + at: Date.now(), + }); + } + break; + } + case 'workspace-context-changed': + handleHubWorkspaceContextChanged( + eventWorkspaceId, + () => pollWorkspaceInvalidationForWorkspace(subscribedWorkspaceId), + ); + // Revalidate exact membership before the next billing projection. + // A removed/rebound member must clear money and entitlement state, + // even when no billing-specific event accompanies the roster change. + workspaceBillingRuntime.reconnect(subscribedWorkspaceId); + break; + case 'billing-changed': + workspaceBillingRuntime.invalidate({ + domain: 'legacy', + ...(event.workspaceId ? { workspaceId: event.workspaceId } : {}), + ...(event.revision ? { revision: event.revision } : {}), + ...(event.revisionClock ? { revisionClock: event.revisionClock } : {}), + reason: 'vela-billing-changed', + }); + emitWorkspaceEvent(eventWorkspaceId, { + type: 'billing-changed', + workspaceId: eventWorkspaceId, + ...(event.revision ? { revision: event.revision } : {}), + at: Date.now(), + }); + break; + case 'billing-subscription-changed': + if (!event.workspaceId) break; + workspaceBillingRuntime.invalidate({ + domain: 'subscription', + workspaceId: event.workspaceId, + ...(event.revision ? { revision: event.revision } : {}), + ...(event.revisionClock ? { revisionClock: event.revisionClock } : {}), + reason: 'vela-billing-subscription-changed', + }); + emitWorkspaceEvent(event.workspaceId, { + type: 'billing-subscription-changed', + workspaceId: event.workspaceId, + ...(event.revision ? { revision: event.revision } : {}), + at: Date.now(), + }); + break; + case 'wallet-balance-changed': + if (!event.workspaceId || !event.workspaceMemberId) break; + workspaceBillingRuntime.invalidate({ + domain: 'wallet', + workspaceId: event.workspaceId, + workspaceMemberId: event.workspaceMemberId, + ...(event.revision ? { revision: event.revision } : {}), + ...(event.revisionClock ? { revisionClock: event.revisionClock } : {}), + reason: 'vela-wallet-balance-changed', + }); + emitWorkspaceEvent(event.workspaceId, { + type: 'wallet-balance-changed', + workspaceId: event.workspaceId, + workspaceMemberId: event.workspaceMemberId, + ...(event.revision ? { revision: event.revision } : {}), + at: Date.now(), + }); + break; + case 'team-resources-changed': { + // A design-system/plugin/skill resource was shared (moved the + // 'published' ref) or retracted (removed) on the resource hub. + // `resourceKind` routes to just that kind's reconciler instead of + // re-checking all of them on every event — see + // `reconcileTeamResourcesFromRemote` below (declared later in this + // function; referencing it here is safe because this callback only + // ever RUNS once an actual SSE event arrives, well after the rest + // of `startServer`'s synchronous setup — including that + // declaration — has completed). + void reconcileTeamResourcesFromRemote( + event.resourceKind, + eventWorkspaceId, + ).catch(() => undefined); + break; + } + } + }, + onReconnect: () => { + // Close the disconnect gap: one catch-up cycle over the same reads the + // pollers watch, plus a comment pull for open projects. + void refreshWorkspaceDigestFaces( + subscribedWorkspaceId, + { revalidate: true }, + ) + .then(() => + pollWorkspaceInvalidationForWorkspace(subscribedWorkspaceId), + ) + .catch(() => undefined); + void reconcileWorkspaceProjectsFromRemote(subscribedWorkspaceId) + .catch(() => undefined); + void proactiveContentPull.catchUpPublishedHeads(subscribedWorkspaceId) + .catch(() => undefined); + void collabCloud?.pollOnce().catch(() => undefined); + workspaceBillingRuntime.reconnect(subscribedWorkspaceId); + // Same catch-up principle for the design-system/skill resource + // reconciler: a missed 'team-resources-changed' push during the + // disconnect window is closed by one full re-check across every kind + // this daemon drives it for (no resourceKind => reconcile all). + void reconcileTeamResourcesFromRemote(undefined, subscribedWorkspaceId) + .catch(() => undefined); + }, + onSourceGap: ({ workspaceId, listenerEpoch }) => { + console.warn( + `[od] hub source gap detected listenerEpoch=${listenerEpoch} ` + + `workspaceId=${workspaceId ?? 'unknown'}`, + ); + const exactWorkspaceId = workspaceId ?? subscribedWorkspaceId; + void refreshWorkspaceDigestFaces(exactWorkspaceId, { revalidate: true }) + .then(() => pollWorkspaceInvalidationForWorkspace(exactWorkspaceId)) + .catch(() => undefined); + void reconcileWorkspaceProjectsFromRemote(exactWorkspaceId) + .catch(() => undefined); + void proactiveContentPull.catchUpPublishedHeads(exactWorkspaceId) + .catch(() => undefined); + workspaceBillingRuntime.reconnect(exactWorkspaceId); + void collabCloud?.pollOnce().catch(() => undefined); + void reconcileTeamResourcesFromRemote(undefined, exactWorkspaceId) + .catch(() => undefined); + }, + onError: (error) => { + console.warn('[od] hub events channel error (will reconnect):', String(error)); + }, + }); + workspaceHubSubscriptions = createWorkspaceHubSubscriptionManager({ + start: startWorkspaceHubSubscriber, + }); + workspaceHubSubscriptions.setBillingInterests( + workspaceBillingRuntime.interestedKeys().map((interest) => interest.workspaceId), + ); + + registerTeamResourceRoutes(app, { teamResources: collab.teamResources }); + + // Team resource sharing is request-scoped. The browser's explicit Workspace + // headers choose a membership, then the signed-in account's authoritative + // directory supplies the principal and permissions. Never consult the + // daemon-wide active Workspace here: another tab may switch it while this + // request is awaiting the hub. + const rememberedTeamResourceScopes = new Map< + string, + TeamResourceRequestScope + >(); + const rememberTeamResourceScope = ( + scope: TeamResourceRequestScope, + ): TeamResourceRequestScope => { + rememberedTeamResourceScopes.set(scope.principal.teamId, scope); + return scope; + }; + const resolveTeamResourceScope = async (req: any) => { + const verified = await verifyExplicitWorkspaceRequestContext({ + req, + requireTeam: true, + }); + if (!verified.ok) return verified; + const scope = teamResourceRequestScopeFromContext(verified.context); + if (!scope) { + return { + ok: false as const, + status: 403 as const, + code: 'WORKSPACE_ACCESS_DENIED', + message: 'the requested workspace is not available to this member', + }; + } + return { + ok: true as const, + scope: rememberTeamResourceScope(scope), + }; + }; + const resolveTeamResourceScopeForWorkspaceId = async ( + workspaceId: string, + ): Promise => { + const requestedWorkspaceId = workspaceId.trim(); + if (!requestedWorkspaceId) return null; + const directory = await fetchWorkspaceDirectory().catch(() => ({ + ok: false, + items: [], + })); + if (!directory.ok) return null; + const scope = teamResourceRequestScopeForWorkspaceId( + directory.items, + requestedWorkspaceId, + ); + return scope ? rememberTeamResourceScope(scope) : null; + }; + const teamResourceScopeStillAuthorized = async ( + scope: TeamResourceRequestScope, + ): Promise => { + const refreshed = await resolveTeamResourceScopeForWorkspaceId( + scope.principal.teamId, + ); + return Boolean( + refreshed && + refreshed.principal.teamId === scope.principal.teamId && + refreshed.principal.memberId === scope.principal.memberId && + refreshed.principal.lifecycleState === 'active', + ); + }; + const teamResourceStillShared = async ( + kind: 'design_system' | 'plugin' | 'skill', + resource: TeamResourceShareRecord, + scope: TeamResourceRequestScope, + ): Promise => { + const { runVelaResourceCommand } = await import( + './collab/vela-cli-resource-adapter.js' + ); + const stdout = await runVelaResourceCommand( + ['shared', '--json'], + scope.principal.teamId, + ); + const idPrefix = kind === 'design_system' ? 'ds' : kind; + const sanitizeResourceSegment = (value: string) => + value.replace(/[^a-zA-Z0-9_-]/g, '-'); + const expectedHubId = + resource.hubResourceId ?? + `${idPrefix}-${sanitizeResourceSegment(scope.principal.teamId)}-${sanitizeResourceSegment(resource.id)}`; + const parsed = JSON.parse(stdout) as { + resources?: Array<{ + id?: unknown; + kind?: unknown; + deletedAt?: unknown; + metadata?: unknown; + }>; + }; + return (parsed.resources ?? []).some((candidate) => { + if (candidate.kind !== kind || candidate.deletedAt != null) return false; + return candidate.id === expectedHubId; + }); + }; + async function syncSharedTeamPlugin( + resource: TeamResourceShareRecord, + scope: TeamResourceRequestScope, + ): Promise { + const workspaceId = scope.principal.teamId; + const isOwnedByCurrentMember = + typeof resource.ownerMemberId === 'string' && + resource.ownerMemberId === scope.principal.memberId; + if (isOwnedByCurrentMember) return; + const hubResourceId = + resource.hubResourceId ?? + `plugin-${workspaceId.replace(/[^a-zA-Z0-9_-]/g, '-')}-${resource.id.replace(/[^a-zA-Z0-9_-]/g, '-')}`; + const targetDir = teamResourceMaterializationDir( + PLUGIN_REGISTRY_ROOTS.userPluginsRoot, + workspaceId, + resource.id, + resource.id, + ); + const bindingResourceId = workspaceTeamPluginBindingResourceId( + workspaceId, + resource.id, + ); + const captureActivationFence = (): string | null => + workspaceTeamPluginBindingActivationFence(db, workspaceId, resource.id); + const markTeamSynced = (): boolean => { + const existingBinding = getWorkspaceResourceByResourceId( + db, + 'plugin', + bindingResourceId, + ); + if ( + existingBinding && + (existingBinding.workspaceId !== workspaceId || + existingBinding.visibility !== 'team') + ) { + return false; + } + ensureWorkspaceResource(db, 'plugin', workspaceId, bindingResourceId, { + visibility: 'team', + resourceState: 'active', + createdByWorkspaceMemberId: scope.principal.memberId, + updatedByWorkspaceMemberId: scope.principal.memberId, + resourceHubResourceId: hubResourceId, + }); + updateWorkspaceResource(db, 'plugin', workspaceId, bindingResourceId, { + visibility: 'team', + resourceState: 'active', + updatedByWorkspaceMemberId: scope.principal.memberId, + resourceHubResourceId: hubResourceId, + }); + return true; + }; + if ( + fs.existsSync(targetDir) && + resource.versionId && + teamResourceVersions.get(workspaceId, 'plugin', resource.id) === resource.versionId + ) { + await activateWorkspaceTeamPluginIfStillShared({ + captureActivationFence, + stillShared: () => teamResourceStillShared('plugin', resource, scope), + activationFenceIsCurrent: (fence) => captureActivationFence() === fence, + activate: markTeamSynced, + }); + return; + } + const existing = getInstalledPlugin(db, resource.id); + const remoteDescription = typeof resource.description === 'string' ? resource.description.trim() : ''; + const localDescription = typeof existing?.manifest?.description === 'string' + ? existing.manifest.description.trim() + : ''; + if (fs.existsSync(targetDir) && !resource.versionId && (!remoteDescription || localDescription === remoteDescription)) { + await activateWorkspaceTeamPluginIfStillShared({ + captureActivationFence, + stillShared: () => teamResourceStillShared('plugin', resource, scope), + activationFenceIsCurrent: (fence) => captureActivationFence() === fence, + activate: markTeamSynced, + }); + return; + } + + try { + const { runVelaResourceCommand } = await import('./collab/vela-cli-resource-adapter.js'); + const materialized = await materializeWorkspaceScopedTeamResource({ + kindRoot: PLUGIN_REGISTRY_ROOTS.userPluginsRoot, + storageName: resource.id, + identity: { + kind: 'plugin', + workspaceId, + resourceId: resource.id, + hubResourceId, + }, + pullInto: (stagedFolder) => + runVelaResourceCommand([ + 'pull', + 'plugin', + hubResourceId, + stagedFolder, + '--ref', + 'published', + '--json', + ], workspaceId).then(() => undefined), + verifyWorkspaceScope: () => teamResourceScopeStillAuthorized(scope), + verifyStillShared: () => teamResourceStillShared('plugin', resource, scope), + }); + if (materialized.status !== 'committed') return; + const activated = await resolveAndActivateWorkspaceTeamPlugin({ + resolve: async () => { + const resolved = await resolvePluginFolder({ + folder: materialized.targetDir, + folderId: resource.id, + sourceKind: 'user', + source: teamResourceSourceKey({ + kind: 'plugin', + workspaceId, + resourceId: resource.id, + }), + }); + if (!resolved.ok) { + console.warn( + `[team-resources] failed to register shared plugin ${resource.id}: ${resolved.errors.join('; ')}`, + ); + return null; + } + return resolved.record; + }, + captureActivationFence, + stillShared: () => teamResourceStillShared('plugin', resource, scope), + activationFenceIsCurrent: (fence) => captureActivationFence() === fence, + activate: markTeamSynced, + }); + if (!activated) return; + if (resource.versionId) { + await teamResourceVersions.set( + workspaceId, + 'plugin', + resource.id, + resource.versionId, + ); + } + } catch (error) { + console.warn( + `[team-resources] failed to pull shared plugin ${resource.id}:`, + error instanceof Error ? error.message : error, + ); + } + } + async function syncSharedTeamDesignSystem( + resource: TeamResourceShareRecord, + scope: TeamResourceRequestScope, + ): Promise { + const dirId = stripPrefixAndValidateId(resource.id, 'user:'); + if (!dirId) return; + const targetDir = teamResourceMaterializationDir( + USER_DESIGN_SYSTEMS_DIR, + scope.principal.teamId, + resource.id, + dirId, + ); + const isOwnedByCurrentMember = + typeof resource.ownerMemberId === 'string' && + resource.ownerMemberId === scope.principal.memberId; + const workspaceId = scope.principal.teamId; + const hubResourceId = + resource.hubResourceId ?? + `ds-${workspaceId.replace(/[^a-zA-Z0-9_-]/g, '-')}-${resource.id.replace(/[^a-zA-Z0-9_-]/g, '-')}`; + async function markTeamSynced(): Promise { + if (isOwnedByCurrentMember) return; + const metadataPath = path.join(targetDir, 'metadata.json'); + let metadata: Record = {}; + try { + const raw = await fs.promises.readFile(metadataPath, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + metadata = parsed as Record; + } + } catch { + metadata = {}; + } + await fs.promises.writeFile( + metadataPath, + // Claim the pulled copy for the workspace whose hub served it (#145). + // A team-shared system is workspace-owned by construction, so leaving + // it unclaimed would keep leaking one team's library into the next + // workspace the user switches to. + `${JSON.stringify( + { ...metadata, teamSynced: true, ...(workspaceId ? { workspaceId } : {}) }, + null, + 2, + )}\n`, + 'utf8', + ); + // Envelope double-write (spec 9.2): stamp the same claim into + // `workspace_resources` as `visibility: 'team'`, mirroring what + // syncSharedTeamSkill's own markTeamSynced already does for skill. + if (workspaceId) { + ensureWorkspaceResource(db, 'design_system', workspaceId, resource.id, { + visibility: 'team', + resourceState: 'active', + }); + updateWorkspaceResource(db, 'design_system', workspaceId, resource.id, { + visibility: 'team', + resourceState: 'active', + }); + } + } + if (isOwnedByCurrentMember) return; + if ( + fs.existsSync(targetDir) && + workspaceId && + resource.versionId && + teamResourceVersions.get( + workspaceId, + 'design_system', + resource.id, + ) === resource.versionId + ) { + await markTeamSynced(); + return; + } + if (fs.existsSync(targetDir) && !resource.versionId) { + await markTeamSynced(); + return; + } + try { + const { runVelaResourceCommand } = await import('./collab/vela-cli-resource-adapter.js'); + const materialized = await materializeWorkspaceScopedTeamResource({ + kindRoot: USER_DESIGN_SYSTEMS_DIR, + storageName: dirId, + identity: { + kind: 'design_system', + workspaceId, + resourceId: resource.id, + hubResourceId, + }, + pullInto: (stagedFolder) => + runVelaResourceCommand([ + 'pull', + 'design_system', + hubResourceId, + stagedFolder, + '--ref', + 'published', + '--json', + ], workspaceId).then(() => undefined), + verifyWorkspaceScope: () => teamResourceScopeStillAuthorized(scope), + verifyStillShared: () => + teamResourceStillShared('design_system', resource, scope), + }); + if (materialized.status !== 'committed') return; + await markTeamSynced(); + if (workspaceId && resource.versionId) { + await teamResourceVersions.set( + workspaceId, + 'design_system', + resource.id, + resource.versionId, + ); + } + } catch (error) { + console.warn( + `[team-resources] failed to pull shared design system ${resource.id}:`, + error instanceof Error ? error.message : error, + ); + } + } + async function syncSharedTeamSkill( + resource: TeamResourceShareRecord, + scope: TeamResourceRequestScope, + ): Promise { + const dirId = stripPrefixAndValidateId( + resource.id, + resource.id.startsWith('user:') ? 'user:' : '', + ); + if (!dirId) return; + const targetDir = teamResourceMaterializationDir( + USER_SKILLS_DIR, + scope.principal.teamId, + resource.id, + dirId, + ); + const workspaceId = scope.principal.teamId; + const hubResourceId = + resource.hubResourceId ?? + `skill-${workspaceId.replace(/[^a-zA-Z0-9_-]/g, '-')}-${resource.id.replace(/[^a-zA-Z0-9_-]/g, '-')}`; + const isOwnedByCurrentMember = + typeof resource.ownerMemberId === 'string' && + resource.ownerMemberId === scope.principal.memberId; + // Claim the pulled copy for the workspace whose hub served it — a + // team-shared skill is workspace-owned by construction, same rule + // syncSharedTeamDesignSystem's markTeamSynced already ships (#145). + // Fills the gap this resource type previously had no binding row at + // all: `enforceSkillWorkspaceMutation` (routes/static-resource.ts) and + // `listSkills`'s workspace filter (skills.ts) both read this row. + function markTeamSynced(): void { + if (isOwnedByCurrentMember || !workspaceId) return; + ensureWorkspaceResource(db, 'skill', workspaceId, resource.id, { + visibility: 'team', + resourceState: 'active', + }); + updateWorkspaceResource(db, 'skill', workspaceId, resource.id, { + visibility: 'team', + resourceState: 'active', + }); + } + if (isOwnedByCurrentMember) return; + if ( + fs.existsSync(targetDir) && + workspaceId && + resource.versionId && + teamResourceVersions.get(workspaceId, 'skill', resource.id) === resource.versionId + ) { + markTeamSynced(); + return; + } + if (fs.existsSync(targetDir) && !resource.versionId) { + markTeamSynced(); + return; + } + + try { + const { runVelaResourceCommand } = await import('./collab/vela-cli-resource-adapter.js'); + const materialized = await materializeWorkspaceScopedTeamResource({ + kindRoot: USER_SKILLS_DIR, + storageName: dirId, + identity: { + kind: 'skill', + workspaceId, + resourceId: resource.id, + hubResourceId, + }, + pullInto: (stagedFolder) => + runVelaResourceCommand([ + 'pull', + 'skill', + hubResourceId, + stagedFolder, + '--ref', + 'published', + '--json', + ], workspaceId).then(() => undefined), + verifyWorkspaceScope: () => teamResourceScopeStillAuthorized(scope), + verifyStillShared: () => teamResourceStillShared('skill', resource, scope), + }); + if (materialized.status !== 'committed') return; + markTeamSynced(); + if (workspaceId && resource.versionId) { + await teamResourceVersions.set( + workspaceId, + 'skill', + resource.id, + resource.versionId, + ); + } + } catch (error) { + console.warn( + `[team-resources] failed to pull shared skill ${resource.id}:`, + error instanceof Error ? error.message : error, + ); + } + } + // Stale-while-revalidate a kind's `/team` listing (hub read + resource + // materialization) keyed on the verified Workspace/member scope. The + // workspace shell reads all three kinds on navigation; without this each read + // re-hit the hub on the request path (~1.5-2.5s each) and serialized behind + // the browser's 6-connection cap. Materialization still runs, but on the + // background refresh rather than the hot read. + // + // `invalidate()` is consumed by registerTeamResourceShareRoutes' share/ + // unshare handlers below (a local mutation this daemon just made). It has to + // drop TWO layers, not one: this cache's own parsed-and-materialized entry, + // AND `sharedTeamResourcesCommand` underneath it — `share.sharedResources()` + // reads the raw `vela shared --json` listing through that second SWR cache + // (shared by all three kinds), so a bare reset of this layer alone would + // still hand the immediate post-share/unshare refetch the pre-change hub + // listing for up to that cache's own freshMs. + const sharedTeamResourcesCommands = new Map< + string, + ReturnType> + >(); + const sharedTeamResourcesCommand = Object.assign( + async (workspaceId: string): Promise => { + const key = workspaceId.trim(); + if (!key) throw new Error('explicit workspace scope is required'); + let command = sharedTeamResourcesCommands.get(key); + if (!command) { + command = createSwrCache( + async () => { + const { runVelaResourceCommand } = await import('./collab/vela-cli-resource-adapter.js'); + return runVelaResourceCommand(['shared', '--json'], key); + }, + () => key, + 3000, + ); + sharedTeamResourcesCommands.set(key, command); + } + return command(); + }, + { + invalidate(workspaceId: string) { + const key = workspaceId.trim(); + sharedTeamResourcesCommands.get(key)?.invalidate(); + sharedTeamResourcesCommands.delete(key); + }, + }, + ); + const teamResourceScopeKey = (scope: TeamResourceRequestScope): string => + JSON.stringify([ + scope.principal.teamId, + scope.principal.memberId, + scope.principal.role, + scope.principal.lifecycleState, + ]); + const cachedTeamResourceList = ( + share: TeamResourceShareService, + sync?: ( + resource: TeamResourceShareRecord, + scope: TeamResourceRequestScope, + ) => Promise, + ) => { + const listings = new Map< + string, + ReturnType> + >(); + const read = async (scope: TeamResourceRequestScope) => { + const key = teamResourceScopeKey(scope); + let listing = listings.get(key); + if (!listing) { + listing = createSwrCache( + async () => { + const resources = await share.sharedResources(scope); + if (sync) { + await Promise.all(resources.map((resource) => sync(resource, scope))); + } + return { ids: resources.map((resource) => resource.id), resources }; + }, + () => key, + 3000, + ); + listings.set(key, listing); + } + return listing(); + }; + return Object.assign(read, { + invalidate(scope: TeamResourceRequestScope) { + const key = teamResourceScopeKey(scope); + listings.get(key)?.invalidate(); + listings.delete(key); + sharedTeamResourcesCommand.invalidate(scope.principal.teamId); + }, + }); + }; + const runTeamResourceCommand = async ( + args: string[], + workspaceId?: string, + ) => { + if (args.length === 2 && args[0] === 'shared' && args[1] === '--json') { + if (!workspaceId?.trim()) throw new Error('explicit workspace scope is required'); + return sharedTeamResourcesCommand(workspaceId); + } + const { runVelaResourceCommand } = await import('./collab/vela-cli-resource-adapter.js'); + return runVelaResourceCommand(args, workspaceId); + }; + const designSystemsTeamShare = createTeamResourceShareService({ + kind: 'design_system', + idPrefix: 'ds', + resolveDir: (id) => resolveUserDesignSystemShareDirectory(db, id), + describeResource: async (id) => { + const system = (await listAllDesignSystems()).find((candidate) => candidate.id === id); + return { + localId: id, + ...(system?.title ? { title: system.title } : {}), + ...(system?.summary ? { description: system.summary } : {}), + }; + }, + run: runTeamResourceCommand, + }); + const designSystemsTeamList = cachedTeamResourceList( + designSystemsTeamShare, + syncSharedTeamDesignSystem, + ); + registerTeamResourceShareRoutes(app, { + basePath: 'design-systems', + resolveScope: resolveTeamResourceScope, + syncSharedResource: syncSharedTeamDesignSystem, + share: designSystemsTeamShare, + listTeam: designSystemsTeamList, + }); + const pluginsTeamShare = createTeamResourceShareService({ + kind: 'plugin', + idPrefix: 'plugin', + resolveDir: (id) => { + const plugin = getInstalledPlugin(db, id); + if (!plugin || typeof plugin.fsPath !== 'string') throw new Error('plugin not found'); + return plugin.fsPath; + }, + describeResource: (id) => { + const plugin = getInstalledPlugin(db, id); + if (!plugin) return null; + return { + localId: id, + title: plugin.manifest?.title ?? plugin.manifest?.name ?? plugin.title ?? id, + ...(plugin.manifest?.description ? { description: plugin.manifest.description } : {}), + }; + }, + run: runTeamResourceCommand, + }); + const pluginsTeamList = cachedTeamResourceList( + pluginsTeamShare, + syncSharedTeamPlugin, + ); + registerTeamResourceShareRoutes(app, { + basePath: 'plugins', + resolveScope: resolveTeamResourceScope, + syncSharedResource: syncSharedTeamPlugin, + share: pluginsTeamShare, + listTeam: pluginsTeamList, + }); + const skillsTeamShare = createTeamResourceShareService({ + kind: 'skill', + idPrefix: 'skill', + resolveDir: async (id) => { + const skill = findSkillById(await listAllSkills(), id); + if (!skill || typeof skill.dir !== 'string') throw new Error('skill not found'); + return skill.dir; + }, + describeResource: async (id) => { + const skill = findSkillById(await listAllSkills(), id); + if (!skill) return null; + return { + localId: id, + title: skill.name || id, + ...(skill.description ? { description: skill.description } : {}), + }; + }, + run: runTeamResourceCommand, + }); + const skillsTeamList = cachedTeamResourceList( + skillsTeamShare, + syncSharedTeamSkill, + ); + registerTeamResourceShareRoutes(app, { + basePath: 'skills', + resolveScope: resolveTeamResourceScope, + syncSharedResource: syncSharedTeamSkill, + share: skillsTeamShare, + listTeam: skillsTeamList, + }); + const teamResourceListByKind = { + design_system: designSystemsTeamList, + plugin: pluginsTeamList, + skill: skillsTeamList, + }; + + // Collab realtime for design-system/plugin/skill "team resource" sharing: react + // to a `team-resources-changed` signal (hub push, wired above, OR the + // dedicated poll fallback below) by reconciling this workspace's + // `workspace_resources` rows against each kind's live shared listing. See + // `collab/workspace-resources-reconciler.ts` for the full design — + // in particular why retraction marks `resourceState: 'deleted'` and leaves + // `visibility: 'team'` alone, instead of demoting to `visibility: + // 'personal'` the way `workspace-projects-reconciler.ts` does for + // `workspace_projects` (that would misattribute a teammate's pulled copy + // as caller-authored — the exact bug `SkillSummary.teamSynced` exists to + // prevent). + // + const RECONCILED_TEAM_RESOURCE_KINDS = ['design_system', 'plugin', 'skill'] as const; + type ReconciledTeamResourceKind = (typeof RECONCILED_TEAM_RESOURCE_KINDS)[number]; + const teamResourceShareByKind: Record = { + design_system: designSystemsTeamShare, + plugin: pluginsTeamShare, + skill: skillsTeamShare, + }; + const adoptLegacyWorkspaceTeamPluginBindings = async ( + scope: TeamResourceRequestScope, + ): Promise => { + const workspaceId = scope.principal.teamId; + const workspaceRoot = teamResourceWorkspaceRoot( + PLUGIN_REGISTRY_ROOTS.userPluginsRoot, + workspaceId, + ); + let entries: fs.Dirent[] = []; + try { + entries = await fs.promises.readdir(workspaceRoot, { withFileTypes: true }); + } catch { + return; + } + await Promise.all( + entries.filter((entry) => entry.isDirectory()).map(async (entry) => { + const marker = await readTeamResourceMaterialization( + PLUGIN_REGISTRY_ROOTS.userPluginsRoot, + workspaceId, + entry.name, + entry.name, + ); + if (!marker || marker.kind !== 'plugin') return; + ensureWorkspaceResource( + db, + 'plugin', + workspaceId, + workspaceTeamPluginBindingResourceId(workspaceId, marker.resourceId), + { + visibility: 'team', + resourceState: 'active', + createdByWorkspaceMemberId: scope.principal.memberId, + updatedByWorkspaceMemberId: scope.principal.memberId, + resourceHubResourceId: marker.hubResourceId, + }, + ); + }), + ); + }; + const reconcileTeamResourceKind = async ( + resourceType: ReconciledTeamResourceKind, + scope: TeamResourceRequestScope, + ) => { + if (resourceType === 'plugin') { + await adoptLegacyWorkspaceTeamPluginBindings(scope); + } + return reconcileWorkspaceResourcesWithRemote({ + getWorkspaceIdentity: async () => ({ workspaceId: scope.principal.teamId }), + listRemoteTeamResources: async () => + (await teamResourceShareByKind[resourceType].sharedResources(scope)).map((resource) => ({ + resourceId: resource.id, + })), + listLocalActiveTeamRows: (workspaceId): LocalTeamResourceBinding[] => + listWorkspaceResources(db, resourceType, workspaceId) + .filter((row: any) => row.visibility === 'team' && row.resourceState !== 'deleted') + .flatMap((row: any) => { + const logicalResourceId = resourceType === 'plugin' + ? pluginIdFromWorkspaceTeamPluginBinding(workspaceId, row.resourceId) + : row.resourceId; + if (!logicalResourceId) return []; + return [ + { + resourceId: logicalResourceId, + workspaceId: row.workspaceId, + visibility: row.visibility, + resourceState: row.resourceState ?? null, + }, + ]; + }), + applyRetire: (workspaceId, resourceId) => { + const bindingResourceId = resourceType === 'plugin' + ? workspaceTeamPluginBindingResourceId(workspaceId, resourceId) + : resourceId; + updateWorkspaceResource(db, resourceType, workspaceId, bindingResourceId, { + resourceState: 'deleted', + }); + }, + onError: (error) => console.warn(`[od] workspace-resources (${resourceType}) reconciliation error:`, error), + }); + }; + // `resourceKind` scopes the pass to just the kind the event was about; + // omitted (hub reconnect catch-up, the poll fallback) reconciles every + // kind this daemon drives it for. + const reconcileTeamResourcesFromRemote = async ( + resourceKind?: string, + workspaceId?: string, + ): Promise => { + const requestedWorkspaceId = workspaceId?.trim(); + if (!requestedWorkspaceId) return; + // Background events carry only a Workspace id, not an HTTP request. Resolve + // that exact membership from the directory at execution time rather than + // relying on whichever resource request happened to run first in this + // process (or on the daemon's mutable active context). + const scope = await resolveTeamResourceScopeForWorkspaceId(requestedWorkspaceId); + if (!scope) return; + const kinds = resourceKind + ? RECONCILED_TEAM_RESOURCE_KINDS.filter((kind) => kind === resourceKind) + : RECONCILED_TEAM_RESOURCE_KINDS; + // A Team listing has two SWR layers: the per-kind parsed/materialized + // response and the raw shared command underneath it. Drop both before the + // authoritative reconciliation pass so the next UI read cannot keep + // serving a pre-retraction outer response after the binding is tombstoned. + invalidateTeamResourceListingCaches({ + ...(resourceKind ? { resourceKind } : {}), + scope, + providers: teamResourceListByKind, + invalidateSharedCommand: (exactWorkspaceId) => + sharedTeamResourcesCommand.invalidate(exactWorkspaceId), + }); + await Promise.all( + kinds.map((kind) => reconcileTeamResourceKind(kind, scope)), + ); + }; + const teamResourceBackgroundWorkspaceIds = (): string[] => { + const ids = new Set(); + for (const workspaceId of rememberedTeamResourceScopes.keys()) { + if (workspaceId.trim()) ids.add(workspaceId.trim()); } - if (result.warnings.length > 0) { - for (const w of result.warnings) console.warn(`[plugins] bundled warn: ${w}`); + for (const workspaceId of workspaceHubSubscriptions?.activeWorkspaceIds() ?? []) { + if (workspaceId.trim()) ids.add(workspaceId.trim()); } - } catch (err) { - console.warn(`[plugins] bundled registration failed: ${(err)?.message ?? err}`); - } - - try { - const seedDirs = await fs.promises.readdir(PLUGIN_REGISTRY_DIR, { withFileTypes: true }).catch((err) => { - if (err?.code === 'ENOENT') return []; - throw err; - }); - const { ensureMarketplaceManifest } = await import('./plugins/marketplaces.js'); - for (const dirent of seedDirs) { - if (!dirent.isDirectory()) continue; - const id = dirent.name; - const manifestText = await marketplaceSeedManifestText(id, bundledMarketplaceEntries); - if (!manifestText) continue; - const configured = defaultMarketplaceSeedConfig(id); - const result = ensureMarketplaceManifest(db, { - id, - url: configured.url, - trust: configured.trust, - manifestText, - }); - if (result.ok) { - console.log(`[plugins] seeded ${id} registry source (${result.row.manifest.plugins.length} plugin(s))`); - } else { - console.warn(`[plugins] ${id} registry seed failed: ${result.message}`); - } + for (const share of listTeamWorkspaceProjectShares(db)) { + const workspaceId = String(share.workspaceId ?? '').trim(); + if (workspaceId) ids.add(workspaceId); } - } catch (err) { - console.warn(`[plugins] registry seed failed: ${(err)?.message ?? err}`); - } - - // Plan §3.A5 / spec §16 Phase 5 / PB2: periodic snapshot GC. Disabled - // when OD_SNAPSHOT_GC_INTERVAL_MS is 0; otherwise one-time bootstrap - // sweep + interval. The function returns a NOOP_HANDLE when disabled - // so we don't have to branch on the result. - const snapshotGc = startSnapshotGc({ db }); - // One immediate sweep so a daemon that just gained the ALTER doesn't - // wait the full interval before reaping pre-existing expired rows. - try { - const initialSweep = pruneExpiredSnapshots(db); - if (initialSweep.removed > 0) { - console.log(`[plugins] snapshot GC startup sweep removed ${initialSweep.removed} row(s)`); + for (const workspaceId of listTeamWorkspaceResourceWorkspaceIds(db)) { + if (workspaceId.trim()) ids.add(workspaceId.trim()); } - } catch (err) { - console.warn(`[plugins] snapshot GC startup sweep failed: ${(err)?.message ?? err}`); - } - void snapshotGc; // keep handle alive for the daemon's lifetime - - // Memory hygiene: one-time removal of entries the retired chat - // auto-extraction pipelines wrote (regex-pack artifacts + chat-form - // residue in user_profile). Marker-gated inside, so this is a no-op on - // every boot after the first. Best-effort — memory cleanup must never - // block the daemon from serving. - try { - const memoryCleanup = await runAutoExtractionCleanup(RUNTIME_DATA_DIR); - if (memoryCleanup.ran && (memoryCleanup.deletedIds.length > 0 || memoryCleanup.profilePruned)) { - console.log( - `[memory] auto-extraction cleanup removed ${memoryCleanup.deletedIds.length} entr(y/ies)` - + `${memoryCleanup.profilePruned ? ' and pruned user_profile to canonical fields' : ''}`, + return [...ids]; + }; + // Dedicated ~15s poll fallback — the "poll-as-floor" half of the same + // architecture principle `workspaceInvalidationPoller` follows for + // project/member/context signals (push accelerates delivery; the poll + // never stops running). Kept as its own timer rather than folded into that + // poller's deps: `workspaceInvalidationPoller` decides whether to emit by + // diffing a cheap SIGNATURE against the previous one (see its + // `emitIfChanged`), and there is no equivalent cheap "did the team-shared + // resource set change" digest to diff against (vela's own + // `/api/v1/collab/sync-digest` carries no resources token) — so this + // always just re-reads and re-diffs unconditionally on its own cadence + // instead of piggybacking on that poller's change-detection. + const teamResourcesPollTimer = setInterval(() => { + for (const workspaceId of teamResourceBackgroundWorkspaceIds()) { + void reconcileTeamResourcesFromRemote(undefined, workspaceId).catch((error) => + console.warn( + `[od] workspace ${workspaceId} resources poll error:`, + error, + ), ); } - } catch (err) { - console.warn('[memory] auto-extraction cleanup failed:', err); - } - - // Warm agent-capability probes (e.g. whether the installed Claude Code - // build advertises --include-partial-messages) so the first /api/chat - // hits a populated cache even if /api/agents hasn't been called yet. - void readAppConfig(RUNTIME_DATA_DIR) - .then((config) => { - orbitService.configure(config.orbit); - return detectAgents(config.agentCliEnv ?? {}); - }) - .catch(() => detectAgents().catch(() => {})); - - await recoverStaleLiveArtifactRefreshes({ projectsRoot: PROJECTS_DIR }).catch((error) => { - console.warn('[od] Failed to recover stale live artifact refreshes:', error); - }); - - if (fs.existsSync(STATIC_DIR)) { - app.use(express.static(STATIC_DIR)); - } - - // ---- Projects (DB-backed) ------------------------------------------------- - + }, 15_000); + teamResourcesPollTimer.unref?.(); registerMemoryRoutes(app, { http: { createSseResponse, requireLocalDaemonRequest }, @@ -2704,6 +5888,7 @@ export async function startServer({ CRAFT_DIR, SKILLS_DIR, USER_SKILLS_DIR, + SKILL_ROOTS, PROMPT_TEMPLATES_DIR, BUNDLED_PETS_DIR, OD_BIN, @@ -2802,12 +5987,105 @@ export async function startServer({ const uploadDeps = { upload, importUpload, handleProjectUpload }; const projectStoreDeps = { getProject, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + listWorkspaceProjectBindings, + ensureWorkspaceProject, + listWorkspaceProjects, + updateWorkspaceProject, + rebindWorkspaceProject, + deleteWorkspaceProject, + countWorkspaceProjectRefs, insertProject, updateProject, dbDeleteProject, removeProjectDir, + stageProjectDirsForDelete, validateLinkedDirs, }; + const authorizeProjectRequest = createAuthorizeProjectRequest({ + db, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + isProjectRevoked: (_db, projectId) => + revokedTeamProjectMirrors.has(projectId), + verifyWorkspaceReadAuthority, + verifyWorkspaceRequestAuthority, + sendApiError, + }); + const authorizeProjectToolRequest = async ( + res, + projectId, + options, + ) => { + const binding = getWorkspaceProjectByProjectId(db, projectId); + if (!binding?.workspaceId) return true; + + let authority; + if (process.env.OD_WORKSPACE_CONTEXT_SOURCE?.trim() === 'vela') { + const directory = await fetchFreshMutationWorkspaceDirectory().catch( + () => ({ ok: false, items: [] }), + ); + if (!directory.ok) { + sendApiError( + res, + 503, + 'WORKSPACE_AUTHORITY_UNAVAILABLE', + 'workspace membership authority is temporarily unavailable', + { retryable: true }, + ); + return false; + } + const item = directory.items.find( + (candidate) => candidate.workspaceId === binding.workspaceId, + ); + if (!item) { + sendApiError( + res, + 403, + 'WORKSPACE_PROJECT_PERMISSION_DENIED', + 'workspace project access is not allowed', + ); + return false; + } + authority = workspaceContextFromDirectoryItem(item); + } else { + authority = workspaceContextFromDirectoryItem({ + workspaceId: binding.workspaceId, + workspaceName: binding.workspaceId, + workspaceType: 'personal', + workspaceMemberId: + binding.createdByWorkspaceMemberId ?? 'local-user', + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + }); + } + const scopedAuthorize = createAuthorizeProjectRequest({ + db, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + isProjectRevoked: (_db, id) => + revokedTeamProjectMirrors.has(id), + verifyWorkspaceRequestAuthority: async () => ({ + ok: true, + context: authority, + }), + sendApiError, + }); + const request = { + query: {}, + get(name) { + const normalized = name.toLowerCase(); + if (normalized === 'x-od-workspace-id') return authority.workspaceId; + if (normalized === 'x-od-workspace-member-id') { + return authority.workspaceMemberId; + } + return undefined; + }, + }; + return scopedAuthorize(request, res, projectId, options); + }; const projectFileDeps = { ensureProject, listFiles, @@ -2837,8 +6115,11 @@ export async function startServer({ upsertMessage, listPreviewComments, upsertPreviewComment, + getPreviewComment, updatePreviewCommentStatus, + updatePreviewCommentAnchor, deletePreviewComment, + reorderPreviewComment, }; const templateDeps = { getTemplate, listTemplates, deleteTemplate, insertTemplate, findTemplateByNameAndProject, updateTemplate }; const projectStatusDeps = { @@ -2847,6 +6128,7 @@ export async function startServer({ normalizeProjectDisplayStatus, composeProjectDisplayStatus, listProjects, + listUnboundProjects, }; const projectEventDeps = { subscribeFileEvents, activeProjectEventSinks }; const importDeps = { importClaudeDesignZip, projectDir, detectEntryFile }; @@ -3011,6 +6293,7 @@ export async function startServer({ paths: pathDeps, projectStore: projectStoreDeps, projectFiles: projectFileDeps, + authorizeProjectRequest, }); // OD Library — global asset registry (clipper ingest, grid, pairing, apply). registerLibraryRoutes(app, { @@ -3021,6 +6304,8 @@ export async function startServer({ projectFiles: projectFileDeps, conversations: conversationDeps, auth: authDeps, + fetchProjectCreationWorkspaceDirectory, + enforceWorkspaceProjectMutation: enforceAuthoritativeProjectMutation, }); app.post('/api/projects/:id/figma/import', (req, res) => { figmaUpload.single('file')(req, res, async (err) => { @@ -3028,6 +6313,16 @@ export async function startServer({ try { const project = getProject(db, req.params.id); if (!project) return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found'); + if (!await enforceAuthoritativeProjectMutation( + req, + res, + sendApiError, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + db, + project.id, + 'writeFiles', + )) return; const body = req.body && typeof req.body === 'object' ? req.body : {}; const figmaUrl = typeof body.figmaUrl === 'string' ? body.figmaUrl.trim() : ''; @@ -3073,12 +6368,156 @@ export async function startServer({ conversations: conversationDeps, templates: templateDeps, status: projectStatusDeps, + // Same provider `collab` was built with (collab.workspaceContext === + // workspaceContext) — see the mutation-gate cross-check note above. + verifyWorkspaceRequestAuthority, + authorizeProjectRequest, + isProjectRevoked: (projectId) => + revokedTeamProjectMirrors.has(projectId), + fetchWorkspaceDirectory, + fetchProjectCreationWorkspaceDirectory, + createWorkspaceOwnedDesignSystem: createWorkspaceOwnedDesignSystemForContext, events: projectEventDeps, ids: idDeps, telemetry: { reportFinalizedMessage }, appConfig: appConfigDeps, agents: agentDeps, validation: validationDeps, + // C-lane sync seam for D's project-visibility routes: a personal→team move + // calls requestTeamShare on success to publish the project for the team. + collabSync: { + requestTeamShare: async (projectId, ownerMemberId) => { + const result = await collab.requestTeamShare(projectId, ownerMemberId); + // The GET cache also contains the fallback "project is shared" + // verdict when this Workspace has no authoritative presence stream. + // A successful visibility mutation changes that verdict immediately. + invalidatePresenceReadCache(projectId); + return result; + }, + requestTeamUnshare: async (projectId, ownerMemberId) => { + const result = await collab.requestTeamUnshare(projectId, ownerMemberId); + invalidatePresenceReadCache(projectId); + return result; + }, + refreshTeamProjectMetadata: (projectId) => collab.refreshTeamProjectMetadata(projectId), + invalidateTeamProjectCatalog: () => teamProjectsDisplayCache.invalidate(), + }, + ...(workspaceTeamProjectCatalog ? { teamProjectCatalog: workspaceTeamProjectCatalog } : {}), + // Second witness for the team-share invariant: refuse a team share aimed at + // a workspace the directory says is personal, even if the caller's headers + // claim otherwise. See collab/team-share-scope.ts. + workspaceTypes, + // Collab-cloud comment seams (no-op off-team / when unconfigured): stamp the + // server-authoritative author, gate status/delete on the caller vs the + // comment author / project owner, and push the comment lifecycle (create/edit, + // status change, tombstone) to the cross-daemon relay. + resolveWorkspaceContext: resolveProjectCommentWorkspaceContext, + resolveReadWorkspaceContext: resolveProjectCommentReadWorkspaceContext, + resolveProjectOwnerMemberId: async (projectId, context) => { + if (!context || context.workspaceType !== 'team') return null; + return resolveSharedProjectOwner(projectId, { + workspaceId: context.workspaceId, + workspaceMemberId: context.workspaceMemberId, + }); + }, + isSharedProject: async (projectId, context) => { + if (!context || context.workspaceType !== 'team') return false; + return Boolean( + await resolveSharedProjectOwner(projectId, { + workspaceId: context.workspaceId, + workspaceMemberId: context.workspaceMemberId, + }), + ); + }, + shouldSyncProjectComments: async (_authorization, projectId, context) => { + if (!context || context.workspaceType !== 'team') return false; + return Boolean( + await resolveSharedProjectOwner(projectId, { + workspaceId: context.workspaceId, + workspaceMemberId: context.workspaceMemberId, + }), + ); + }, + ...(collabCloud + ? { + onCommentsRead: ( + projectId, + leasedContext, + resolveFreshWorkspaceContext, + ) => { + // Consume the hub push channel's dirty mark: first read after + // opening a project pulls THAT project's missed comments — a + // targeted pull, because the poll loop only covers projects with + // a live events subscriber and this read can arrive before (or + // without) one. + if (dirtyCommentProjects.delete(projectId)) { + // The list response may use a short successful authority lease, + // but the cloud pull mutates local state and therefore must + // independently prove the same exact member and Workspace with + // fresh authority. Any denial, outage, identity drift, no-op, or + // failure restores the dirty mark for a later authorized read. + if (!leasedContext) { + dirtyCommentProjects.add(projectId); + return; + } + void resolveFreshWorkspaceContext() + .then((freshResolution) => { + if (!freshResolution.ok || !freshResolution.context) { + return false; + } + const freshContext = freshResolution.context; + if ( + freshContext.workspaceId !== leasedContext.workspaceId + || freshContext.workspaceMemberId + !== leasedContext.workspaceMemberId + ) { + return false; + } + return collabCloud.pullProject(projectId, freshContext); + }) + .then((pulled) => { + if (!pulled) dirtyCommentProjects.add(projectId); + }) + .catch(() => dirtyCommentProjects.add(projectId)); + } + }, + // Both hooks also reconcile pin_seq (recvq5BVsolIxi): a genuinely + // new comment on a team-shared project is inserted with a + // provisional LOCAL pin_seq (pin_seq_confirmed=0 — see + // upsertPreviewComment); once this push resolves with the + // collab-cloud's globally-serialized seq, confirmPreviewCommentPinSeq + // overwrites it with that authoritative value, which is what keeps + // two devices creating a comment in the same ~5s poll window from + // ever landing on the same number. The guard inside + // confirmPreviewCommentPinSeq makes calling it from BOTH hooks safe: + // it only ever applies once per comment (whichever push resolves + // first wins), so an edit's push resolving here is a no-op once the + // create's already has, and a resilience net when the create's push + // itself failed. + onCommentCreated: (comment, context) => { + if (!context) return; + void collabCloud + .pushComment(comment, context) + .then((result) => { + if (result) confirmPreviewCommentPinSeq(db, comment.projectId, comment.id, result.seq); + }) + .catch(() => {}); + }, + onCommentUpdated: (comment, context) => { + if (!context) return; + void collabCloud + .pushComment(comment, context) + .then((result) => { + if (result) confirmPreviewCommentPinSeq(db, comment.projectId, comment.id, result.seq); + }) + .catch(() => {}); + }, + onCommentDeleted: (comment, context) => { + if (!context) return; + void collabCloud.pushCommentDeletion(comment, context).catch(() => {}); + }, + } + : {}), }); registerTerminalRoutes(app, { db, @@ -3087,6 +6526,7 @@ export async function startServer({ projectStore: projectStoreDeps, projectFiles: projectFileDeps, terminals: terminalService, + authorizeProjectRequest, }); registerImportRoutes(app, { db, @@ -3101,17 +6541,63 @@ export async function startServer({ conversations: conversationDeps, projectFiles: projectFileDeps, validation: validationDeps, + fetchProjectCreationWorkspaceDirectory, + enforceWorkspaceProjectMutation: enforceAuthoritativeProjectMutation, }); + // Whether the caller may mutate (edit / publish-toggle / delete) a design + // system. A system pulled from a teammate's team share (`teamSynced` in its + // metadata.json — see `isTeamSyncedUserDesignSystem`) is only mutable by + // whoever `canManageSharedResource` says may manage the share — the same + // principal check `unshare` already enforces. Anything not teamSynced is + // the caller's own, so it stays unrestricted. + // + // Spec 9.2: on top of that existing rule, a workspace the caller's own + // request marks as locked/deleted (billing lapse, deletion in progress) + // blocks mutation unconditionally — the one real gap design system had + // that project/plugin already closed via `enforceWorkspaceResourceMutation`. + // Reuses that module's own `workspaceResourceContextFromRequest`/ + // `isWorkspaceResourceLocked` rather than re-deriving the header contract + // here. + // + // Hoisted out of `registerDesignSystemRoutes`'s deps (recvqb6mfyqXLD) so + // `registerStaticResourceRoutes`'s design-system LIST route can decorate + // every teamSynced entry with the same verdict — any detail surface a + // design system's summary reaches (not just the single-item GET) can then + // gate its own edit/publish/delete affordances on the authority the + // backend actually enforces, instead of re-deriving (or forgetting to + // derive) an equivalent check per surface. + const canMutateUserDesignSystem = async ( + root: string, + id: string, + req: any, + ): Promise => { + const requestCtx = workspaceResourceContextFromRequest(req); + if (requestCtx && requestCtx !== 'missing' && isWorkspaceResourceLocked(requestCtx)) { + return false; + } + const synced = await isTeamSyncedUserDesignSystem(root, id); + if (!synced) return true; + const resolution = await resolveTeamResourceScope(req); + if (!resolution.ok) return false; + const resources = await designSystemsTeamShare.sharedResources(resolution.scope); + return resources.find((resource) => resource.id === id)?.canUnshare === true; + }; + // Resource catalog registerStaticResourceRoutes(app, { + db, http: httpDeps, paths: pathDeps, + verifyWorkspaceRequestAuthority, + teamResources: collab.teamResources, resources: { listAllSkills, listAllDesignTemplates, listAllSkillLikeEntries, listAllDesignSystems, + resolveWorkspaceScope: resolveDesignSystemWorkspaceScope, + canMutateUserDesignSystem, mimeFor, }, tokenContractRebuild: { @@ -3135,10 +6621,53 @@ export async function startServer({ paths: pathDeps, projectStore: projectStoreDeps, projectFiles: projectFileDeps, + verifyWorkspaceRequestAuthority, + workspaceResources: { getWorkspaceResource, getWorkspaceResourceByResourceId }, designSystems: { buildUserDesignSystemArchive, - createUserDesignSystem, + // Hoisted above (before `registerStaticResourceRoutes`) so the + // design-system LIST route can reuse the exact same verdict. + canMutateUserDesignSystem, + createUserDesignSystem: createWorkspaceOwnedDesignSystem, deleteUserDesignSystem, + // spec 04 §11: unshare `id` from the team hub before DELETE proceeds + // locally, but ONLY when it is on the LIVE team share list — never + // `isTeamSyncedUserDesignSystem` alone. That flag is + // true only for a teammate's PULLED copy; the sharer deleting their own + // original always reads `teamSynced: false`, so a check gated on it + // would keep letting the sharer's own delete sail past unnoticed, which + // is exactly how the hub index used to survive this route untouched + // and `syncSharedTeamDesignSystem` kept re-stamping `markTeamSynced()` + // onto every teammate forever. + unshareTeamDesignSystemIfShared: async (id, req) => { + const verified = await verifyExplicitWorkspaceRequestContext({ + req, + requireTeam: false, + }); + if (!verified.ok) { + throw Object.assign(new Error(verified.message), { + status: verified.status, + code: verified.code, + ...(verified.retryable ? { retryable: true } : {}), + }); + } + // Personal resources have no Team hub partition to retract. Their + // authoritative Personal scope is still verified above, then local + // deletion proceeds without issuing a Team command. + if (verified.context.workspaceType !== 'team') return false; + const scope = teamResourceRequestScopeFromContext(verified.context); + if (!scope) { + throw Object.assign(new Error('the requested workspace is not available to this member'), { + status: 403, + code: 'WORKSPACE_ACCESS_DENIED', + }); + } + return unshareIfCurrentlyShared( + designSystemsTeamShare, + id, + rememberTeamResourceScope(scope), + ); + }, ensureUserDesignSystemWorkspaceProject, listAllDesignSystems, listUserDesignSystemFiles, @@ -3151,14 +6680,17 @@ export async function startServer({ readUserDesignSystemFile, renderDesignSystemPreview, renderDesignSystemShowcase, + syncUserDesignSystemAssetsFromWorkspace, updateUserDesignSystem, updateUserDesignSystemRevisionStatus, }, generationJobs: designSystemGenerationJobs, }); registerBrandRoutes(app, { + resolveCreatedProjectHome, brandsRoot: BRANDS_DIR, userDesignSystemsRoot: USER_DESIGN_SYSTEMS_DIR, + resolveDesignSystemWorkspaceId: resolveDesignSystemWorkspaceScope, projectsRoot: PROJECTS_DIR, skillsRoot: SKILLS_DIR, dataDir: RUNTIME_DATA_DIR, @@ -3198,6 +6730,8 @@ export async function startServer({ auth: authDeps, liveArtifacts: liveArtifactDeps, projectStore: projectStoreDeps, + authorizeProjectRequest, + authorizeProjectToolRequest, }); registerDesignSystemToolRoutes(app, { auth: authDeps, @@ -3217,6 +6751,7 @@ export async function startServer({ ids: idDeps, deploy: deployDeps, projectStore: projectStoreDeps, + authorizeProjectRequest, }); registerFinalizeRoutes(app, { db, @@ -3225,6 +6760,7 @@ export async function startServer({ projectStore: projectStoreDeps, validation: validationDeps, finalize: finalizeDeps, + authorizeProjectRequest, }); registerHandoffRoutes(app, { db, @@ -3234,8 +6770,15 @@ export async function startServer({ conversations: conversationDeps, validation: validationDeps, handoff: handoffDeps, + authorizeProjectRequest, + }); + registerDeploymentCheckRoutes(app, { + db, + http: httpDeps, + deploy: deployDeps, + projectStore: projectStoreDeps, + authorizeProjectRequest, }); - registerDeploymentCheckRoutes(app, { db, http: httpDeps, deploy: deployDeps }); app.use('/frames', express.static(FRAMES_DIR)); registerProjectExportRoutes(app, { db, @@ -3247,6 +6790,7 @@ export async function startServer({ exports: projectExportDeps, projectFiles: projectFileDeps, validation: validationDeps, + authorizeProjectRequest, }); registerProjectFileRoutes(app, { db, @@ -3255,10 +6799,14 @@ export async function startServer({ uploads: uploadDeps, node: nodeDeps, projectStore: projectStoreDeps, + authorizeProjectRequest, + isProjectRevoked: (projectId) => + revokedTeamProjectMirrors.has(projectId), projectFiles: projectFileDeps, documents: { buildDocumentPreview }, artifacts: artifactDeps, projectPreviewScopes, + verifyWorkspaceRequestAuthority, }); registerMediaRoutes(app, { @@ -3276,6 +6824,9 @@ export async function startServer({ projectFiles: projectFileDeps, conversations: conversationDeps, research: researchDeps, + fetchWorkspaceDirectory, + authorizeProjectRequest, + authorizeProjectToolRequest, }); registerVelaRoutes(app, { @@ -3302,7 +6853,7 @@ export async function startServer({ isLocalSameOrigin, resolvedPortRef, pluginShareTaskStore, - installOrUpgradePlugin: async (req, res, mode) => { + installOrUpgradePlugin: async (req, res, mode, installWorkspaceContext) => { const body = req.body && typeof req.body === 'object' ? req.body : {}; const id = req.params.id; let source = ''; @@ -3343,6 +6894,13 @@ export async function startServer({ res.flushHeaders?.(); const writeEvent = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); if (mode === 'upgrade') writeEvent('progress', { kind: 'progress', phase: 'resolving', message: `Upgrading ${id} from ${source} (policy=${body.policy === 'pinned' ? 'pinned' : 'latest'})` }); + // Stamp a fresh INSTALL (not upgrade — upgrading a plugin installed + // before workspace isolation shipped must not retroactively tag it, + // same "no retroactive tagging" rule design-systems already ships) with + // the requesting workspace, mirroring the project-creation route's + // `ensureWorkspaceProject` call. No-op when the caller carries no + // workspace headers (e.g. `od plugin install`, or a not-logged-in web + // session) — the plugin simply stays unbound, visible everywhere. try { const basePlugin = mode === 'upgrade' ? getInstalledPlugin(db, id) : null; for await (const ev of installPlugin(db, { @@ -3360,6 +6918,14 @@ export async function startServer({ lockfilePath: PLUGIN_LOCKFILE_PATH, })) { writeEvent(ev.kind, ev); + if (ev.kind === 'success' && mode === 'install' && installWorkspaceContext && ev.plugin?.id) { + ensureWorkspaceResource(db, 'plugin', installWorkspaceContext.workspaceId, ev.plugin.id, { + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: installWorkspaceContext.workspaceMemberId, + updatedByWorkspaceMemberId: installWorkspaceContext.workspaceMemberId, + }); + } if (ev.kind === 'success' || ev.kind === 'error') break; } } catch (err) { @@ -3376,12 +6942,29 @@ export async function startServer({ const body = req.body && typeof req.body === 'object' ? req.body : {}; const action = normalizePluginShareAction(body.action); if (!action) return sendApiError(res, 400, 'BAD_REQUEST', 'action must be publish-github or contribute-open-design'); + const createWorkspace = await authorizeCreatedProjectWorkspace( + req, + fetchProjectCreationWorkspaceDirectory, + ); + if (!createWorkspace.ok) { + return sendCreatedProjectWorkspaceError(res, createWorkspace); + } const actionPluginId = PLUGIN_SHARE_ACTION_PLUGIN_IDS[action]; const actionPlugin = getInstalledPlugin(db, actionPluginId); if (!actionPlugin) return res.status(409).json({ ok: false, code: 'share-action-plugin-missing', message: `The bundled action plugin "${actionPluginId}" is not installed. Restart the daemon so bundled plugins are registered.` }); const now = Date.now(); const id = randomId(); const cid = randomId(); const sourceSlug = githubRepoNameFromPluginName(sourcePlugin.id); const stagedPath = `plugin-source/${sourceSlug}`; const prompt = renderPluginSharePrompt({ action, sourcePlugin, stagedPath }); const metadata = { kind: 'prototype' }; const projectRoot = await ensureProject(PROJECTS_DIR, id, metadata); await copyPluginFolderForProjectContext(sourcePlugin.fsPath, path.join(projectRoot, 'plugin-source', sourceSlug)); insertProject(db, { id, name: `${PLUGIN_SHARE_ACTION_LABELS[action]}: ${sourcePlugin.title || sourcePlugin.id}`, skillId: null, designSystemId: null, pendingPrompt: prompt, metadata, createdAt: now, updatedAt: now }); insertConversation(db, { id: cid, projectId: id, title: null, createdAt: now, updatedAt: now }); + // The share task IS a chat project — it opens with a seeded prompt the + // user immediately runs. `createPluginShareProject` (apps/web) mints no + // workspace headers at all, so this was permanently unbound, not merely + // racy: the very first turn 403s on the workspace gate. + bindCreatedProjectToWorkspace( + (input) => ensureWorkspaceProject(db, input), + createWorkspace.context, + id, + now, + ); const registry = await loadPluginRegistryView(); const connectorProbe = buildConnectorProbe(connectorService); const resolved = resolvePluginSnapshot({ db, body: { pluginId: actionPluginId, pluginInputs: { source_plugin_id: sourcePlugin.id, source_plugin_title: sourcePlugin.title || sourcePlugin.id, source_plugin_version: sourcePlugin.version, source_plugin_path: sourcePlugin.fsPath, plugin_context_path: stagedPath }, locale: typeof body.locale === 'string' ? body.locale : undefined }, projectId: id, conversationId: cid, registry, connectorProbe }); if (resolved && !resolved.ok) return res.status(resolved.status).json(resolved.body); const project = getProject(db, id); if (!project) return sendApiError(res, 500, 'INTERNAL_ERROR', 'created project could not be loaded'); @@ -3499,15 +7082,126 @@ export async function startServer({ return Array.from(byTaskKind.values()); } + const readWorkspaceTeamPlugin = async ( + workspaceId: string, + pluginId: string, + ) => { + const marker = await readTeamResourceMaterialization( + PLUGIN_REGISTRY_ROOTS.userPluginsRoot, + workspaceId, + pluginId, + pluginId, + ); + if (!marker) return null; + if (!workspaceTeamPluginBindingAllowsRead(db, workspaceId, pluginId)) { + return null; + } + // Upgrade compatibility: materializations created before Team plugins + // joined `workspace_resources` remain readable, but the first exact-scope + // read adopts them so hub SSE/reconnect/poll retractions can tombstone + // them from then on. Never takes over a Personal or other-Workspace row. + const teamBindingResourceId = workspaceTeamPluginBindingResourceId( + workspaceId, + pluginId, + ); + const existingBinding = getWorkspaceResourceByResourceId( + db, + 'plugin', + teamBindingResourceId, + ); + if (!existingBinding) { + ensureWorkspaceResource( + db, + 'plugin', + workspaceId, + teamBindingResourceId, + { + visibility: 'team', + resourceState: 'active', + resourceHubResourceId: marker.hubResourceId, + }, + ); + } + return resolveWorkspaceTeamPluginWithBindingGate({ + bindingAllowsRead: () => + workspaceTeamPluginBindingAllowsRead(db, workspaceId, pluginId), + resolve: async () => { + const resolved = await resolvePluginFolder({ + folder: teamResourceMaterializationDir( + PLUGIN_REGISTRY_ROOTS.userPluginsRoot, + workspaceId, + pluginId, + pluginId, + ), + folderId: pluginId, + sourceKind: 'user', + source: marker.sourceKey, + }); + return resolved.ok ? resolved.record : null; + }, + }); + }; + const listWorkspacePlugins = async ( + dbHandle, + workspaceId?: string | null, + ) => { + const personal = listInstalledPlugins(dbHandle, workspaceId); + const exactWorkspaceId = workspaceId?.trim(); + if (!exactWorkspaceId) return personal; + const workspaceRoot = teamResourceWorkspaceRoot( + PLUGIN_REGISTRY_ROOTS.userPluginsRoot, + exactWorkspaceId, + ); + let entries: fs.Dirent[] = []; + try { + entries = await fs.promises.readdir(workspaceRoot, { withFileTypes: true }); + } catch { + return personal; + } + const team = ( + await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .map((entry) => readWorkspaceTeamPlugin(exactWorkspaceId, entry.name)), + ) + ).filter((plugin): plugin is NonNullable => plugin != null); + const teamIds = new Set(team.map((plugin) => plugin.id)); + return [...team, ...personal.filter((plugin) => !teamIds.has(plugin.id))]; + }; + const getWorkspacePluginForRequest = async ( + dbHandle, + id: string, + workspaceId: string | null, + ) => { + const exactWorkspaceId = workspaceId?.trim(); + if (exactWorkspaceId) { + const team = await readWorkspaceTeamPlugin(exactWorkspaceId, id); + if (team) return team; + } + return listInstalledPlugins(dbHandle, workspaceId).find( + (plugin) => plugin.id === id, + ) ?? null; + }; + registerPluginRoutes(app, { db, + authorizeProjectRequest, + teamResources: collab.teamResources, paths: { PROJECTS_DIR, PLUGIN_REGISTRY_ROOTS, PLUGIN_LOCKFILE_PATH }, ids: idDeps, projectStore: projectStoreDeps, conversations: conversationDeps, + fetchProjectCreationWorkspaceDirectory, + verifyWorkspaceRequestAuthority, + workspaceResources: { + getWorkspaceResource, + getWorkspaceResourceByResourceId, + workspaceTeamPluginBindingAllowsRead, + }, plugins: { - listInstalledPlugins, + listInstalledPlugins: listWorkspacePlugins, getInstalledPlugin, + getWorkspacePlugin: getWorkspacePluginForRequest, installPlugin, isSafePluginId, uninstallPlugin, @@ -3539,6 +7233,8 @@ export async function startServer({ }); registerPluginAssetRoutes(app, { db, + verifyWorkspaceRequestAuthority, + getWorkspacePlugin: getWorkspacePluginForRequest, pluginAssetCache, AssetCacheError, assetCacheRewriteUrl, @@ -3550,10 +7246,12 @@ export async function startServer({ db, design, paths: { PROJECTS_DIR }, + authorizeProjectRequest, }); registerProjectPluginRoutes(app, { db, + authorizeProjectRequest, paths: { PROJECTS_DIR, PLUGIN_REGISTRY_ROOTS, PLUGIN_LOCKFILE_PATH }, ids: idDeps, projectStore: projectStoreDeps, @@ -3587,7 +7285,10 @@ export async function startServer({ node: nodeDeps, paths: { PROJECTS_DIR }, projectStore: projectStoreDeps, + authorizeProjectRequest, + authorizeProjectToolRequest, projectFiles: projectFileDeps, + verifyWorkspaceRequestAuthority, }); const composeDaemonSystemPrompt = async ({ @@ -3963,13 +7664,15 @@ export async function startServer({ } } - const excludedCraft = new Set(designSystemCraftExemptions); - // Web-clone fidelity exemption — see `isWebCloneRun` above. - const requestedCraft = isWebCloneRun - ? [] - : Array.from( - new Set([...skillCraftRequires, ...designSystemCraftApplies]), - ).filter((slug) => !excludedCraft.has(slug)); + const requestedCraft = resolveCraftRequirements({ + isWebCloneRun, + metadataKind: metadata?.kind, + skillModes, + freeformDeckSignal, + skillRequires: skillCraftRequires, + designSystemApplies: designSystemCraftApplies, + designSystemExemptions: designSystemCraftExemptions, + }); if (requestedCraft.length > 0) { const loaded = await loadCraftSections(CRAFT_DIR, requestedCraft); if (loaded.body) { @@ -4346,6 +8049,17 @@ export async function startServer({ if (typeof clientRequestId === 'string' && clientRequestId) run.clientRequestId = clientRequestId; if (typeof agentId === 'string' && agentId) run.agentId = agentId; + // Freeze the billing address once, before the first asynchronous setup + // step. HTTP-created runs already carry the scope captured by the request + // authorization transaction. Internal runs pin here. Retries reuse the + // existing property and therefore never consult a later project rebind. + if (!Object.prototype.hasOwnProperty.call(run, 'workspaceScope')) { + run.workspaceScope = + typeof projectId === 'string' && projectId + ? pinRunWorkspaceScopeForProject(db, projectId) + : null; + design.runs.persistState(run); + } // Stash the original user prompt + per-turn config so the // langfuse-bridge report path can include them without reaching back // into chatBody across the createChatRunService boundary. Each field @@ -4408,7 +8122,7 @@ export async function startServer({ const requestedRuntimeModel = def.id === 'byok-opencode' ? resolvedByokCredential?.profile.model ?? null : model; - // Validate the checked-in `inactivityTimeoutMs` hint immediately + // Validate the checked-in runtime timeout hints immediately // after the runtime def is selected and before any side-effectful // setup (auto-memory extract, `.mcp.json` write/unlink, // composeSystemPrompt, prompt persistence). A bad def value would @@ -4417,7 +8131,7 @@ export async function startServer({ // residue behind (issue #2467 review on PR #2579). // // Catch is intentionally narrowed to `RangeError`, the only kind - // `assertValidRuntimeDefInactivityTimeoutMs` is allowed to throw + // the runtime timeout validators are allowed to throw // for invalid checked-in values. Anything else (a regression that // makes the helper throw on a valid value, an unrelated bug // introduced while touching this path) should bubble up to the @@ -4426,6 +8140,7 @@ export async function startServer({ // "the runtime def is bad" and burying the real failure. try { assertValidRuntimeDefInactivityTimeoutMs(def.inactivityTimeoutMs); + assertValidRuntimeDefFirstOutputTimeoutMs(def.firstOutputTimeoutMs); } catch (err) { if (err instanceof RangeError) { return design.runs.fail(run, 'AGENT_RUNTIME_DEF_INVALID', err.message); @@ -4548,6 +8263,11 @@ export async function startServer({ typeof projectId === 'string' && projectId ? getProject(db, projectId) : null; + const effectiveRunSkillId = resolveSkillId( + typeof skillId === 'string' && skillId + ? skillId + : projectRecord?.skillId, + ); const runContextPrompt = renderRunContextPrompt(context, projectRecord?.metadata); const linkedDirs = (() => { if (!Array.isArray(projectRecord?.metadata?.linkedDirs)) return []; @@ -5641,6 +9361,23 @@ export async function startServer({ agentId: run.agentId, events: run.events, }); + if ( + result === 'failed' && + failure?.failure_category === 'prompt_too_large' && + def.resumesSessionViaAcpLoad === true && + agentResumeCtx.isResuming && + agentResumeCtx.resumeSessionId && + run.conversationId + ) { + clearAgentSession(db, run.conversationId, def.id); + design.runs.emit(run, 'diagnostic', { + type: 'agent_session_cleared_after_prompt_too_large', + agent_id: def.id, + reason: 'prompt_too_large', + previous_session_id: agentResumeCtx.resumeSessionId, + stale_session_cleared: true, + }); + } const sideEffects = { ...runSideEffectsForRun(run), cancelRequested: !!run.cancelRequested, @@ -6434,6 +10171,8 @@ export async function startServer({ // earlier, so we keep only the new `runStartTimeMs` declaration. const runStartTimeMs = Date.now(); const inactivityTimeoutMs = resolveChatRunInactivityTimeoutMs(def.inactivityTimeoutMs); + const firstOutputTimeoutMs = + resolveChatRunFirstOutputTimeoutMs(def.firstOutputTimeoutMs); const artifactQuietPeriodMs = resolveChatRunArtifactQuietPeriodMs(); // Grace before the inactivity watchdog escalates a stalled child from // SIGTERM to SIGKILL. Env-tunable like its OD_CHAT_RUN_* cancel-grace @@ -6443,6 +10182,8 @@ export async function startServer({ return Number.isFinite(raw) && raw > 0 ? raw : 3_000; })(); let inactivityTimer = null; + let firstOutputTimer = null; + let firstOutputSeen = false; let childStdoutSeen = false; let lastAgentEventPhase = 'spawn pending'; let lastToolResultChars = 0; @@ -6499,6 +10240,12 @@ export async function startServer({ inactivityTimer = null; } }; + const clearFirstOutputWatchdog = () => { + if (firstOutputTimer) { + clearTimeout(firstOutputTimer); + firstOutputTimer = null; + } + }; let forcedChildShutdownTimers = []; const clearForcedChildShutdown = () => { for (const timer of forcedChildShutdownTimers) clearTimeout(timer); @@ -6524,9 +10271,10 @@ export async function startServer({ }, inactivityKillGraceMs * 2), ]; }; - const failForInactivity = () => { + const failForInactivity = (reason: 'inactivity' | 'first_output' = 'inactivity') => { if (run.cancelRequested || design.runs.isTerminal(run.status)) return; clearInactivityWatchdog(); + clearFirstOutputWatchdog(); if (artifactRegistered) { // The deliverable already exists. The agent process is either // genuinely idle (claude-code's stream-json child sitting on an @@ -6570,8 +10318,14 @@ export async function startServer({ } } if (!stallPayload) { + const timeoutMs = + reason === 'first_output' ? firstOutputTimeoutMs : inactivityTimeoutMs; + const timeoutDescription = + reason === 'first_output' + ? 'without emitting a first output' + : 'without emitting any new output'; const message = - `Agent stalled without emitting any new output for ${Math.round(inactivityTimeoutMs / 1000)}s. ` + + `Agent stalled ${timeoutDescription} for ${Math.round(timeoutMs / 1000)}s. ` + 'The model or CLI likely hung while generating. ' + `Phase details: spawned agent ${userFacingAgentLabel(agentId, resolvedBin)}; stdout arrived: ${childStdoutSeen ? 'yes' : 'no'}; ` + `last agent event: ${lastAgentEventPhase}; largest tool result observed: ${lastToolResultChars} chars. ` + @@ -6596,6 +10350,33 @@ export async function startServer({ if (child && !child.killed) design.runs.signalChild(run, 'SIGTERM'); scheduleForcedChildShutdown(); }; + const armFirstOutputWatchdog = () => { + if (firstOutputSeen || firstOutputTimer || firstOutputTimeoutMs <= 0) return; + firstOutputTimer = setTimeout( + () => failForInactivity('first_output'), + firstOutputTimeoutMs, + ); + firstOutputTimer.unref?.(); + }; + const noteFirstOutputEvent = (payload) => { + const type = payload?.type ? String(payload.type) : ''; + const statusLabel = + type === 'status' && payload?.label ? String(payload.label) : ''; + const isAcpToolActivity = + statusLabel === 'tool_call' || statusLabel === 'tool_call_update'; + if ( + type !== 'text_delta' && + type !== 'thinking_delta' && + type !== 'tool_use' && + type !== 'tool_result' && + type !== 'artifact' && + !isAcpToolActivity + ) { + return; + } + firstOutputSeen = true; + clearFirstOutputWatchdog(); + }; const activeInactivityTimeoutMs = () => resolveActiveInactivityTimeoutMs({ inactivityTimeoutMs, @@ -6615,6 +10396,8 @@ export async function startServer({ const noteArtifactRegistered = () => { if (artifactRegistered) return; artifactRegistered = true; + firstOutputSeen = true; + clearFirstOutputWatchdog(); // Switch the watchdog to the shorter quiet-period window // immediately so we don't have to wait for the next agent event // before the new ceiling takes effect. Call unconditionally: @@ -6639,6 +10422,7 @@ export async function startServer({ activeChatAgentEventSinks.set(toolTokenGrant.runId, (payload) => { lastAgentEventPhase = summarizeAgentEventForInactivity(payload); noteAgentActivity(); + noteFirstOutputEvent(payload); send('agent', payload); }); activeChatRunHandles.set(toolTokenGrant.runId, { noteArtifactRegistered }); @@ -6752,7 +10536,7 @@ export async function startServer({ ...(mmdRouteLaunchEnv || {}), ...odMediaEnv, ...(byokOpenCodeProvider ? byokOpenCodeProvider.env : {}), - ...openDesignAmrTraceEnv({ + ...await openDesignAmrTraceEnvForRun({ agentId: def.id, runId: run.id, conversationId: run.conversationId, @@ -6760,7 +10544,51 @@ export async function startServer({ retryAttemptCount: run.retryAttemptCount, manualResumeAttemptCount: run.manualResumeAttemptCount, }), + // Vela's workspace-credit isolation reads this env together with the + // signed-in account identity. The run pins the project's exact + // Workspace before its first asynchronous setup step; Vela/AMR + // remains the authority for membership, balance, and billing + // eligibility. Team and Personal bindings are both sent explicitly. + // An unbound project is refused before process spawn. Later project + // rebinds and ambient/current selection never participate. + projectId, + workspaceScope: run.workspaceScope, externalPluginAnalytics: run.externalPluginAnalytics ?? null, + }, { + // Report persisted-binding vs truly-unbound selection to the daemon + // log and telemetry. Ids and the branch name only — + // never member rows or credentials. + onWorkspaceScopeOutcome: (outcome) => { + console.log( + `[od] amr workspace scope ${outcome.kind}` + + ` project=${outcome.projectId}` + + ` workspace=${outcome.workspaceId ?? 'none'}` + + ` run=${run.id}`, + ); + const context = run.analyticsContext ?? null; + if (!context || !design?.analytics?.capture) return; + design.analytics.capture({ + eventName: 'amr_workspace_scope_resolved', + context, + // `design.getAppVersion` is the only app-version accessor this + // scope can see; the identically-named helper inside + // `createFinalizedMessageTelemetryReporter` is a different + // function's local and resolving it here threw a ReferenceError + // out of the spawn path, failing 100% of AMR runs. That helper's + // own last resort is this same accessor, so the value is + // unchanged. + appVersion: design.getAppVersion?.() ?? 'unknown', + properties: { + page_name: 'chat_panel', + area: 'chat_panel', + project_id: outcome.projectId, + conversation_id: run.conversationId ?? null, + run_id: run.id, + workspace_scope_outcome: outcome.kind, + workspace_id: outcome.workspaceId, + }, + }); + }, }), // OpenCode external-MCP injection (issue #2142). Layered AFTER // spawnEnvForAgent / odMediaEnv / configuredAgentEnv so the @@ -6879,7 +10707,14 @@ export async function startServer({ cleanupPromptFile(); revokeToolToken('child_exit'); unregisterChatAgentEventSink(); - send('error', createSseErrorPayload('AGENT_EXECUTION_FAILED', `spawn failed: ${err.message}`)); + send('error', createSseErrorPayload( + err instanceof AmrWorkspaceScopeRequiredError + ? err.code + : 'AGENT_EXECUTION_FAILED', + err instanceof AmrWorkspaceScopeRequiredError + ? err.message + : `spawn failed: ${err.message}`, + )); design.runs.finish(run, 'failed', 1, null); return; } @@ -7107,15 +10942,10 @@ export async function startServer({ artifactId: critiqueRunId, artifactDir: critiqueArtifactDir, adapter: typeof agentId === 'string' ? agentId : 'unknown', - // Codex P2 on PR #1485: thread the resolved skill id into the - // orchestrator so the Phase 12 metrics carry the real label - // instead of falling through to 'unknown' for every live run. - // `effectiveSkillId` was already computed above (line ~2951) as - // the request skillId with a project-row fallback; pass it - // through verbatim, and leave the orchestrator's own default - // of 'unknown' for runs that genuinely have no skill assigned. - skill: typeof effectiveSkillId === 'string' && effectiveSkillId - ? effectiveSkillId + // startChatRun resolves this once after loading the project: + // request-level skill first, persisted project skill second. + skill: typeof effectiveRunSkillId === 'string' && effectiveRunSkillId + ? effectiveRunSkillId : undefined, cfg: critiqueCfg, db, @@ -7261,6 +11091,7 @@ export async function startServer({ function emitGuardedTextDelta(delta: string) { const safe = guardTextDelta(delta); if (safe.length > 0) { + noteFirstOutputEvent({ type: 'text_delta' }); send('agent', { type: 'text_delta', delta: safe }); } if (runGuard.contaminated && !runWarned) { @@ -7404,6 +11235,7 @@ export async function startServer({ // stream BEFORE the send, so run.lastTodoSnapshot / run.truncatedMidTurn are // set by the time finish() derives run.endedWithUnfinishedWork (#1247/#1060). captureRunWorkCompletenessSignals(run, ev); + noteFirstOutputEvent(ev); send('agent', ev); observeToolEventForLoop(ev); } @@ -7718,14 +11550,27 @@ export async function startServer({ : {}), onCliReady: () => noteCliReadyAt(), onSessionInit: () => noteSessionInitDoneAt(), + onPromptComplete: () => clearFirstOutputWatchdog(), send: (event, data) => { if (event === 'error') { + clearFirstOutputWatchdog(); if (run.cancelRequested) return; acpFatalErrorObservedBeforeCancellation = true; run.runtimeFailureObservedBeforeCancellation = true; } if (event === 'agent') { lastAgentEventPhase = summarizeAgentEventForInactivity(data); + if ( + data?.type === 'status' && + data.label === 'waiting_for_first_output' + ) { + armFirstOutputWatchdog(); + } else if (data?.type !== 'text_delta') { + // Raw ACP text may be entirely consumed by title-marker or role + // filtering. Only the guarded non-empty emission below counts + // as substantive first output. + noteFirstOutputEvent(data); + } } noteAgentActivity(); if (event === 'error') flushVisibleAgentStderr(); @@ -7863,6 +11708,7 @@ export async function startServer({ child.on('error', (err) => { clearInactivityWatchdog(); + clearFirstOutputWatchdog(); cleanupPromptFile(); flushVisibleAgentStderr(); revokeToolToken('child_exit'); @@ -7874,6 +11720,7 @@ export async function startServer({ child.on('close', async (code, signal) => { try { clearInactivityWatchdog(); + clearFirstOutputWatchdog(); clearForcedChildShutdown(); flushVisibleAgentStderr(); if (watchdogRetryRestarted) { @@ -8490,6 +12337,7 @@ export async function startServer({ prompt, systemPrompt, template, + workspaceScope, }) => { // Each Orbit run gets its own project so the conversation, messages, and // live artifact are isolated. The handler does the synchronous prep here @@ -8510,6 +12358,8 @@ export async function startServer({ if (!agentId) throw new Error('No available agent is configured for Orbit. Choose an agent in Settings first.'); const now = Date.now(); + const normalizedWorkspaceScope = + normalizePersistedAutomationWorkspaceScope(workspaceScope); const projectId = `orbit-${randomUUID()}`; const conversationId = `orbit-conv-${randomUUID()}`; const assistantMessageId = `orbit-assistant-${randomUUID()}`; @@ -8529,6 +12379,12 @@ export async function startServer({ createdAt: now, updatedAt: now, }); + bindProjectToPersistedAutomationWorkspace( + (input) => ensureWorkspaceProject(db, input), + normalizedWorkspaceScope, + projectId, + now, + ); insertConversation(db, { id: conversationId, projectId, @@ -8667,6 +12523,30 @@ export async function startServer({ pinAssistantMessageOnRunCreate, reconcileAssistantMessageOnRunEnd, }, + // POST /api/runs and POST /api/chat are this file's "create a run" entry + // points — see RegisterRunRoutesDeps.enforceWorkspaceProjectMutation. + // Same provider `collab` was built with (collab.workspaceContext === + // workspaceContext), matching the cross-check `registerProjectRoutes` + // wires up for its own mutation routes above. + enforceWorkspaceProjectMutation: enforceAuthoritativeProjectMutation, + projectStore: { + getWorkspaceProject, + getWorkspaceProjectByProjectId, + ensureWorkspaceProject, + }, + amrWorkspaceScope: { + isSignedIn: async () => { + const appConfig = await readAppConfig(RUNTIME_DATA_DIR).catch( + () => ({}), + ); + return readVelaLoginStatus( + process.env, + agentCliEnvForAgent(appConfig.agentCliEnv, 'amr'), + ).loggedIn; + }, + verifyWorkspaceRequestAuthority, + }, + authorizeProjectRequest, }); // Each routine fire resolves an agent, prepares project/conversation state, @@ -8684,6 +12564,8 @@ export async function startServer({ } const now = startedAt; + const storedRoutineWorkspaceScope = + normalizePersistedAutomationWorkspaceScope(routine.context.workspaceScope); const routineContext = normalizeRunContextSelection(routine.context); const routineSkillId = routine.skillId ?? routineContext.skillIds?.[0] ?? null; const contextMetadata = { @@ -8735,6 +12617,12 @@ export async function startServer({ createdAt: now, updatedAt: now, }); + bindProjectToPersistedAutomationWorkspace( + (input) => ensureWorkspaceProject(db, input), + storedRoutineWorkspaceScope, + projectId, + now, + ); createdProjectId = projectId; }; if (routine.target.mode === 'reuse') { @@ -9029,6 +12917,8 @@ export async function startServer({ uploads: uploadDeps, node: nodeDeps, projectStore: projectStoreDeps, + authorizeProjectRequest, + authorizeProjectToolRequest, projectFiles: projectFileDeps, conversations: conversationDeps, templates: templateDeps, @@ -9082,6 +12972,7 @@ export async function startServer({ db, paths: { RUNTIME_DATA_DIR }, routines: { routineService }, + fetchWorkspaceDirectory, }); // proxy routes (anthropic / openai / azure / google / ollama) live @@ -9096,6 +12987,7 @@ export async function startServer({ db, design, http: httpDeps, + authorizeProjectRequest, paths: pathDeps, chat: { startChatRun }, agents: agentDeps, @@ -9121,6 +13013,10 @@ export async function startServer({ composioConnectorProvider.stopCatalogRefreshLoop(); orbitService.stop(); routineService?.stop(); + clearInterval(teamResourcesPollTimer); + workspaceHubSubscriptions?.dispose(); + workspaceBillingRuntime.dispose(); + proactiveContentPull.dispose(); }; const shutdownDaemonRuns = async () => { if (daemonShutdownStarted) return; diff --git a/apps/daemon/src/services/skill-installation.ts b/apps/daemon/src/services/skill-installation.ts new file mode 100644 index 00000000000..5ab7a6993e2 --- /dev/null +++ b/apps/daemon/src/services/skill-installation.ts @@ -0,0 +1,415 @@ +import fs from 'node:fs'; +import { + cp, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + rename, + rm, +} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { Readable, Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { x as extractTar } from 'tar'; +import { parseFrontmatter } from '../design-systems/frontmatter.js'; +import { resolveGithubRepositoryUrl } from '../github-install-source.js'; +import { safeExternalFetch } from '../plugins/plugin-asset-cache.js'; +import { findSkillById, listSkills, slugifySkillName } from '../skills.js'; + +const DEFAULT_MAX_BYTES = 50 * 1024 * 1024; +const MAX_SKILL_SCAN_DEPTH = 6; +const MAX_SKILL_SCAN_ENTRIES = 10_000; +const GITHUB_SKILL_SOURCE_RE = + /^github:([A-Za-z0-9][A-Za-z0-9._-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)$/; + +export type SkillInstallErrorCode = + | 'BAD_REQUEST' + | 'FETCH_FAILED' + | 'INVALID_ARCHIVE' + | 'INVALID_MANIFEST' + | 'CONFLICT' + | 'INTERNAL_ERROR'; + +export type SkillRemoteInstallResult = + | { ok: true; id: string; dir: string } + | { ok: false; code: SkillInstallErrorCode; error: string }; + +export type SkillArchiveFetcher = (url: string) => Promise<{ + ok: boolean; + status: number; + statusText: string; + body: Readable | null; +}>; + +export interface SkillRemoteInstallOptions { + fetcher?: SkillArchiveFetcher; + maxBytes?: number; +} + +interface ResolvedSkillSource { + fetchUrl: string; + preferredSkillDirectory?: string; +} + +function error( + code: SkillInstallErrorCode, + message: string, +): Extract { + return { ok: false, code, error: message }; +} + +function resolveSkillSource(rawSource: string): ResolvedSkillSource | SkillRemoteInstallResult { + const browserGithub = resolveGithubRepositoryUrl(rawSource); + if (browserGithub.kind === 'invalid') { + return error('BAD_REQUEST', browserGithub.error); + } + const source = browserGithub.kind === 'repository' + ? browserGithub.source + : rawSource.trim(); + const github = GITHUB_SKILL_SOURCE_RE.exec(source); + if (github) { + const owner = github[1]!; + const repo = github[2]!; + if (owner === '.' || owner === '..' || repo === '.' || repo === '..') { + return error('BAD_REQUEST', 'Malformed GitHub source; expected github:owner/repo'); + } + return { + fetchUrl: `https://codeload.github.com/${owner}/${repo}/tar.gz/HEAD`, + preferredSkillDirectory: repo, + }; + } + if (source.startsWith('github:')) { + return error('BAD_REQUEST', 'Malformed GitHub source; expected github:owner/repo'); + } + + let url: URL; + try { + url = new URL(source); + } catch { + return error( + 'BAD_REQUEST', + 'Unsupported skill source; expected github:owner/repo or an HTTPS .tar.gz/.tgz URL', + ); + } + if (url.protocol !== 'https:') { + return error('BAD_REQUEST', 'Skill archive URLs must use HTTPS'); + } + if (url.username || url.password) { + return error('BAD_REQUEST', 'Skill archive URLs must not contain credentials'); + } + if (!/\.(?:tar\.gz|tgz)$/i.test(url.pathname)) { + return error('BAD_REQUEST', 'Only HTTPS .tar.gz or .tgz skill archives are supported'); + } + return { fetchUrl: url.toString() }; +} + +/** + * Archive entry invariant shared by extraction and tests. Treat backslashes as + * separators too so a Windows traversal payload cannot become safe merely + * because extraction is running on POSIX. + */ +export function isSafeSkillArchivePath(rawPath: string): boolean { + if (!rawPath || rawPath.includes('\0')) return false; + const normalized = rawPath.replace(/\\/g, '/'); + if (normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized)) return false; + return !normalized.split('/').some((segment) => segment === '..'); +} + +async function defaultFetcher( + url: string, +): Promise>> { + const response = await safeExternalFetch(url); + return { + ok: response.ok, + status: response.status, + statusText: response.statusText, + body: response.body ? Readable.fromWeb(response.body as never) : null, + }; +} + +async function writeBoundedArchive( + body: Readable, + archivePath: string, + maxBytes: number, +): Promise { + let bytes = 0; + const limiter = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + bytes += chunk.length; + if (bytes > maxBytes) { + callback(new Error(`downloaded archive exceeds ${maxBytes} bytes`)); + return; + } + callback(null, chunk); + }, + }); + await pipeline(body, limiter, fs.createWriteStream(archivePath)); +} + +async function measureSafeTree(root: string, maxBytes: number): Promise { + let total = 0; + async function walk(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const target = path.join(dir, entry.name); + const stats = await lstat(target); + if (stats.isSymbolicLink()) { + throw new Error('archive contains a symbolic link'); + } + if (stats.isDirectory()) { + await walk(target); + continue; + } + if (!stats.isFile()) { + throw new Error(`archive contains unsupported entry type: ${entry.name}`); + } + total += stats.size; + if (total > maxBytes) { + throw new Error(`extracted archive exceeds ${maxBytes} bytes`); + } + } + } + await walk(root); + return total; +} + +async function findSkillRoot( + extractRoot: string, + preferredSkillDirectory?: string, +): Promise { + const rootManifest = path.join(extractRoot, 'SKILL.md'); + if (await lstat(rootManifest).then((stats) => stats.isFile()).catch(() => false)) { + return extractRoot; + } + const candidates: string[] = []; + let scannedEntries = 0; + async function scan(dir: string, depth: number): Promise { + if (depth > MAX_SKILL_SCAN_DEPTH) return; + const entries = await readdir(dir, { withFileTypes: true }); + scannedEntries += entries.length; + if (scannedEntries > MAX_SKILL_SCAN_ENTRIES) { + throw new Error( + `Skill archive contains more than ${MAX_SKILL_SCAN_ENTRIES} entries while locating SKILL.md`, + ); + } + if (entries.some((entry) => entry.isFile() && entry.name === 'SKILL.md')) { + candidates.push(dir); + return; + } + for (const entry of entries) { + if (entry.isDirectory()) await scan(path.join(dir, entry.name), depth + 1); + } + } + try { + await scan(extractRoot, 0); + } catch (cause) { + return error( + 'INVALID_MANIFEST', + cause instanceof Error ? cause.message : String(cause), + ); + } + if (candidates.length === 0) { + return error('INVALID_MANIFEST', 'Skill archive does not contain a SKILL.md file'); + } + if (preferredSkillDirectory) { + const expectedSuffix = `/skills/${preferredSkillDirectory.toLowerCase()}`; + const preferred = candidates.filter((candidate) => { + const relative = path.relative(extractRoot, candidate).split(path.sep).join('/').toLowerCase(); + return relative === expectedSuffix.slice(1) || relative.endsWith(expectedSuffix); + }); + if (preferred.length === 1) return preferred[0]!; + } + if (candidates.length > 1) { + const examples = candidates + .slice(0, 5) + .map((candidate) => path.relative(extractRoot, candidate).split(path.sep).join('/')) + .join(', '); + return error( + 'INVALID_MANIFEST', + `Skill repository contains multiple SKILL.md files (${examples}) and has no unique ` + + `skills/${preferredSkillDirectory ?? ''}/SKILL.md default; ` + + 'upload an archive containing exactly one skill', + ); + } + return candidates[0]!; +} + +async function readSkillIdentity( + skillRoot: string, +): Promise<{ id: string; slug: string } | SkillRemoteInstallResult> { + try { + const raw = await readFile(path.join(skillRoot, 'SKILL.md'), 'utf8'); + const parsed = parseFrontmatter(raw) as { + data?: { name?: unknown }; + body?: string; + }; + const id = typeof parsed.data?.name === 'string' ? parsed.data.name.trim() : ''; + if (!id) { + return error('INVALID_MANIFEST', 'SKILL.md frontmatter must contain a non-empty name'); + } + if (typeof parsed.body !== 'string' || !parsed.body.trim()) { + return error('INVALID_MANIFEST', 'SKILL.md must contain workflow instructions'); + } + const slug = slugifySkillName(id); + if (!slug) { + return error('INVALID_MANIFEST', 'SKILL.md name must produce a valid skill slug'); + } + return { id, slug }; + } catch (cause) { + return error( + 'INVALID_MANIFEST', + `Could not read SKILL.md: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } +} + +/** + * Install one public remote skill as a self-contained user skill. + * + * The accepted source grammar intentionally matches Plugin URL import: + * `github:owner/repo` or a public HTTPS `.tar.gz`/`.tgz` archive. Downloads + * reuse the plugin subsystem's SSRF-safe fetcher, while extraction rejects + * traversal and links and enforces the same 50 MiB default cap. Installation + * is an atomic, fail-closed rename and never overwrites an existing skill. + */ +export async function installSkillFromRemoteSource( + userSkillsRoot: string, + rawSource: string, + options: SkillRemoteInstallOptions = {}, +): Promise { + if (typeof rawSource !== 'string' || !rawSource.trim()) { + return error('BAD_REQUEST', 'skill source is required'); + } + const resolved = resolveSkillSource(rawSource); + if ('ok' in resolved) return resolved; + + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + const fetcher = options.fetcher ?? defaultFetcher; + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'od-skill-archive-')); + const archivePath = path.join(tempRoot, 'archive.tgz'); + const extractRoot = path.join(tempRoot, 'extract'); + let installStageRoot: string | undefined; + try { + let response: Awaited>; + try { + response = await fetcher(resolved.fetchUrl); + } catch (cause) { + return error( + 'FETCH_FAILED', + `Skill download failed: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + if (!response.ok || !response.body) { + return error( + 'FETCH_FAILED', + `Skill download failed: ${response.status} ${response.statusText}`.trim(), + ); + } + try { + await writeBoundedArchive(response.body, archivePath, maxBytes); + } catch (cause) { + return error( + 'INVALID_ARCHIVE', + `Skill archive download failed: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + + await mkdir(extractRoot, { recursive: true }); + let unsafeEntry: string | undefined; + try { + await pipeline( + fs.createReadStream(archivePath), + extractTar({ + cwd: extractRoot, + strict: true, + filter: (entryPath, entry) => { + if (!isSafeSkillArchivePath(entryPath)) { + unsafeEntry = 'path traversal'; + return false; + } + const type = (entry as { type?: string }).type; + if (type === 'SymbolicLink' || type === 'Link') { + unsafeEntry = 'symbolic or hard link'; + return false; + } + if (type && !['File', 'OldFile', 'Directory', 'GNUDumpDir'].includes(type)) { + unsafeEntry = `unsupported entry type "${type}"`; + return false; + } + return true; + }, + }) as NodeJS.WritableStream, + ); + } catch (cause) { + return error( + 'INVALID_ARCHIVE', + `Skill archive extraction failed: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + if (unsafeEntry) { + return error('INVALID_ARCHIVE', `Skill archive contains an unsafe ${unsafeEntry}`); + } + try { + await measureSafeTree(extractRoot, maxBytes); + } catch (cause) { + return error( + 'INVALID_ARCHIVE', + cause instanceof Error ? cause.message : String(cause), + ); + } + + const skillRoot = await findSkillRoot( + extractRoot, + resolved.preferredSkillDirectory, + ); + if (typeof skillRoot !== 'string') return skillRoot; + const identity = await readSkillIdentity(skillRoot); + if ('ok' in identity) return identity; + + await mkdir(userSkillsRoot, { recursive: true }); + const installedSkills = await listSkills(userSkillsRoot); + if (findSkillById(installedSkills, identity.id)) { + return error('CONFLICT', `A skill named "${identity.id}" is already installed`); + } + const destination = path.join(userSkillsRoot, identity.slug); + if (await lstat(destination).then(() => true).catch(() => false)) { + return error('CONFLICT', `A skill named "${identity.id}" is already installed`); + } + + installStageRoot = await mkdtemp( + path.join(path.dirname(userSkillsRoot), '.od-skill-install-'), + ); + const stagedSkill = path.join(installStageRoot, 'skill'); + await cp(skillRoot, stagedSkill, { + recursive: true, + errorOnExist: true, + force: false, + }); + try { + await rename(stagedSkill, destination); + } catch (cause) { + const code = + cause && typeof cause === 'object' && 'code' in cause + ? String((cause as NodeJS.ErrnoException).code) + : ''; + if (code === 'EEXIST' || code === 'ENOTEMPTY') { + return error('CONFLICT', `A skill named "${identity.id}" is already installed`); + } + throw cause; + } + return { ok: true, id: identity.id, dir: destination }; + } catch (cause) { + return error( + 'INTERNAL_ERROR', + `Skill install failed: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } finally { + await rm(tempRoot, { recursive: true, force: true }).catch(() => undefined); + if (installStageRoot) { + await rm(installStageRoot, { recursive: true, force: true }).catch(() => undefined); + } + } +} diff --git a/apps/daemon/src/skills.ts b/apps/daemon/src/skills.ts index ae497d5c02c..77630b0ae14 100644 --- a/apps/daemon/src/skills.ts +++ b/apps/daemon/src/skills.ts @@ -9,9 +9,13 @@ import type { Dirent } from "node:fs"; import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; +import type Database from "better-sqlite3"; import { parseFrontmatter } from "./design-systems/frontmatter.js"; import type { SkillCritiquePolicy } from "./critique/rollout.js"; import { skillCwdAliasSegment, SKILLS_CWD_ALIAS } from "./cwd-aliases.js"; +import { getWorkspaceResourceByResourceId } from "./db.js"; + +type SqliteDb = Database.Database; // Persisted skill ids on existing projects can outlive a folder rename. // listSkills() derives the id from the SKILL.md frontmatter `name`, so once @@ -100,6 +104,19 @@ export interface SkillInfo { critiquePolicy: SkillCritiquePolicy; body: string; dir: string; + /** + * True for a skill materialized locally from a TEAMMATE's team share — the + * puller's copy, never the sharer's own (mirrors design-systems' + * `metadata.json` `teamSynced` flag; see `isTeamSyncedUserDesignSystem` in + * design-systems/index.ts). Sourced from the generic `workspace_resources` + * binding's `visibility` column (`'team'` — written by `syncSharedTeamSkill`'s + * `markTeamSynced` in server.ts), so it only appears when the caller asked to + * be workspace-scoped (`db` + `workspaceId` both passed to `listSkills`). + * Without this, a skill pulled from the team was indistinguishable from one + * the caller authored, and unsharing it team-side let it silently reappear + * in "Personal" instead of just leaving the Team scope. + */ + teamSynced?: boolean; } interface DerivedExample { @@ -136,6 +153,66 @@ export function findSkillById(skills: unknown, id: unknown): SkillInfo | undefin return (skills as SkillInfo[]).find((s) => s.id === canonical); } +export interface ListSkillsOptions { + /** + * Narrow the listing to skills visible from this workspace (see + * `workspace_resources` in `db.ts`). Requires `db` — both are optional so + * every existing caller (system-prompt composition, id resolution, + * install/import lookups, the bundled-scenario scan) keeps getting the + * unscoped catalog unchanged. Only `GET /api/skills` passes both. + */ + db?: SqliteDb; + workspaceId?: string | null; +} + +/** + * Is this skill visible from `scope` (the requesting workspace)? + * + * Same one-way rule design-systems (`designSystemVisibleFromWorkspace`, + * design-systems/index.ts) and plugins (`pluginVisibleFromWorkspace`, + * plugins/registry.ts) already ship, applied to the generic + * `workspace_resources` table: a skill CLAIMED by another workspace (a + * binding row whose `workspace_id` differs) is hidden, and an UNCLAIMED + * skill (no binding row — every skill imported before workspace isolation + * shipped looks like this) stays visible everywhere. Only a skill imported + * AFTER this shipped, into a specific workspace, can be hidden from a + * different one. + * + * `scope === undefined` is a separate signal from `null`/`''`: undefined + * means the caller (system-prompt composition, id resolution, install/import + * lookups) never asked to be scoped at all, so nothing is filtered by + * ownership. `null`/`''` means a caller DID ask to be scoped but has no + * workspace identity to offer (signed-out client, headerless `curl`) — spec + * 04 §10: that must hide a CLAIMED skill, not show it, or "no scope" quietly + * becomes "trust everything". + */ +function skillVisibleFromWorkspace( + db: SqliteDb, + skillId: string, + scope: string | null | undefined, +): boolean { + return skillVisibleFromBinding(getWorkspaceResourceByResourceId(db, "skill", skillId), scope); +} + +/** + * Pure counterpart of {@link skillVisibleFromWorkspace} that takes an + * already-fetched binding row instead of looking it up again — lets + * `listSkills`'s final scoping pass reuse the one binding read for both the + * visibility check and the `teamSynced` annotation below, instead of hitting + * `workspace_resources` twice per entry. + */ +function skillVisibleFromBinding( + binding: ReturnType, + scope: string | null | undefined, +): boolean { + const ownerId = typeof binding?.workspaceId === "string" ? binding.workspaceId.trim() : ""; + if (scope === undefined) return true; + const scopeId = scope?.trim(); + if (!scopeId) return !ownerId; + if (!ownerId) return true; + return ownerId === scopeId; +} + // Accept either a single root path or an array. When given multiple roots, // the first one wins on id collisions so user-imported skills under // USER_SKILLS_DIR can shadow a built-in skill of the same name without @@ -144,6 +221,7 @@ export function findSkillById(skills: unknown, id: unknown): SkillInfo | undefin // UI can render an origin pill and gate the delete control. export async function listSkills( skillsRoots: string | readonly string[], + options: ListSkillsOptions = {}, ): Promise { const roots = Array.isArray(skillsRoots) ? skillsRoots : [skillsRoots]; const out: SkillInfo[] = []; @@ -310,7 +388,35 @@ export async function listSkills( } } } - return out; + // `options.workspaceId === undefined` (key omitted entirely) is the + // "never asked to be scoped" case every non-`GET /api/skills` caller uses. + // A caller that DID pass the key — even as `null`, which `GET /api/skills` + // does whenever the request carries no `x-od-workspace-id` header — must + // still go through `skillVisibleFromWorkspace` below so a claimed skill is + // hidden from a headerless reader instead of silently passing through here. + if (!options.db || options.workspaceId === undefined) return out; + const scopeDb = options.db; + const scopeId = options.workspaceId; + // A derived `:` example card has no `workspace_resources` + // row of its own — only the parent skill is ever bound (see + // `importUserSkill`'s caller) — so resolve derived ids back to their + // parent before checking visibility. + return out + .map((entry) => { + const derived = splitDerivedSkillId(entry.id); + const bindingId = derived ? derived.parentId : entry.id; + return { entry, binding: getWorkspaceResourceByResourceId(scopeDb, "skill", bindingId) }; + }) + .filter(({ binding }) => skillVisibleFromBinding(binding, scopeId)) + // A binding whose visibility is `'team'` is the puller's copy of a + // teammate's share (see `syncSharedTeamSkill`'s `markTeamSynced` in + // server.ts) — never the sharer's own skill, which is never bound this + // way. Surface it so the UI can keep a team-synced skill out of + // "Personal" once it stops being actively shared (see `SkillSummary. + // teamSynced`'s doc comment for the full rationale). + .map(({ entry, binding }) => + binding?.visibility === "team" ? { ...entry, teamSynced: true } : entry, + ); } // Discover example artifacts that live alongside SKILL.md under diff --git a/apps/daemon/src/tool-tokens.ts b/apps/daemon/src/tool-tokens.ts index 6376ea3c4d3..a60247be237 100644 --- a/apps/daemon/src/tool-tokens.ts +++ b/apps/daemon/src/tool-tokens.ts @@ -2,6 +2,10 @@ import { createHash, randomBytes } from 'node:crypto'; export const DEFAULT_TOOL_TOKEN_TTL_MS = 15 * 60 * 1000; +// Capability key for the parameterized media wait route. Token grants cannot +// enumerate a task id that is created after the grant is minted. +export const MEDIA_TASK_WAIT_TOOL_ENDPOINT = '/api/media/tasks/:id/wait'; + export const CHAT_TOOL_ENDPOINTS = [ '/api/tools/live-artifacts/create', '/api/tools/live-artifacts/list', @@ -11,6 +15,7 @@ export const CHAT_TOOL_ENDPOINTS = [ '/api/tools/connectors/execute', '/api/tools/design-systems/read', '/api/tools/media/generate', + MEDIA_TASK_WAIT_TOOL_ENDPOINT, '/api/tools/library/search', '/api/tools/library/apply', ] as const; diff --git a/apps/daemon/tests/acp.test.ts b/apps/daemon/tests/acp.test.ts index 31420ff0c3a..bd9b7833511 100644 --- a/apps/daemon/tests/acp.test.ts +++ b/apps/daemon/tests/acp.test.ts @@ -311,6 +311,36 @@ test('attachAcpSession keeps incremental ACP message chunks unchanged', () => { assert.deepEqual(textDeltas, ['Agent Haven', ' — managed AI agents']); }); +test('attachAcpSession forwards ACP status message details', () => { + const child = new FakeAcpChild(); + const events: Array<{ event: string; payload: unknown }> = []; + + attachAcpSession({ + child: child as never, + prompt: 'describe the project', + cwd: '/tmp/od-project', + model: null, + mcpServers: [], + send: (event, payload) => events.push({ event, payload }), + }); + + writeAcpResult(child, 1, {}); + writeAcpResult(child, 2, { sessionId: 'session-1' }); + writeAcpUpdate(child, { + sessionUpdate: 'context_compaction', + status: 'in_progress', + message: 'Compacting conversation history after a context-length error', + }); + writeAcpResult(child, 3, { usage: { inputTokens: 1, outputTokens: 2 } }); + + const status = events + .filter((entry) => entry.event === 'agent') + .map((entry) => entry.payload as { type?: string; label?: string; detail?: string }) + .find((payload) => payload.type === 'status' && payload.label === 'context_compaction'); + + assert.equal(status?.detail, 'Compacting conversation history after a context-length error'); +}); + test('attachAcpSession suppresses split duplicate DSML artifact text and preserves trailing prose', () => { const child = new FakeAcpChild(); const events: Array<{ event: string; payload: unknown }> = []; @@ -2485,6 +2515,7 @@ test('successful session/prompt with open concrete tool flushes clean (not no-ou test('attachAcpSession still fails an AMR turn that produces no text and no tool calls', () => { const child = new FakeAcpChild(); const events: Array<{ event: string; payload: unknown }> = []; + const onPromptComplete = vi.fn(); attachAcpSession({ child: child as never, @@ -2493,6 +2524,7 @@ test('attachAcpSession still fails an AMR turn that produces no text and no tool model: null, mcpServers: [], modelUnavailableErrorCode: 'AMR_MODEL_UNAVAILABLE', + onPromptComplete, send: (event, payload) => events.push({ event, payload }), }); @@ -2506,6 +2538,38 @@ test('attachAcpSession still fails an AMR turn that produces no text and no tool (errorEvents[0]?.payload as { message?: string }).message ?? '', /without producing any assistant text/, ); + assert.equal(onPromptComplete.mock.calls.length, 0); +}); + +test('attachAcpSession reports clean empty completion exactly once without usage', () => { + const child = new FakeAcpChild(); + const events: Array<{ event: string; payload: unknown }> = []; + const onPromptComplete = vi.fn(); + + attachAcpSession({ + child: child as never, + prompt: 'hello', + cwd: '/tmp/od-project', + model: null, + mcpServers: [], + onPromptComplete, + send: (event, payload) => events.push({ event, payload }), + }); + + writeAcpResult(child, 1, {}); + writeAcpResult(child, 2, { sessionId: 'session-1' }); + writeAcpResult(child, 3, {}); + writeAcpResult(child, 3, {}); + + assert.equal(onPromptComplete.mock.calls.length, 1); + assert.equal( + events.filter((entry) => + entry.event === 'agent' && + (entry.payload as { type?: string }).type === 'usage' + ).length, + 0, + ); + assert.deepEqual(events.filter((entry) => entry.event === 'error'), []); }); test('attachAcpSession promotes allowlisted OpenCode role-marker ACP errors', () => { @@ -2600,6 +2664,46 @@ test('attachAcpSession preserves structured OpenCode session error details from }); }); +test('attachAcpSession marks OpenCode upstream idle session errors retryable', () => { + const child = new FakeAcpChild(); + const events: Array<{ event: string; payload: unknown }> = []; + + attachAcpSession({ + child: child as never, + prompt: 'hello', + cwd: '/tmp/od-project', + model: null, + mcpServers: [], + send: (event, payload) => events.push({ event, payload }), + }); + + const details = { + kind: 'opencode_prompt_error', + phase: 'event_stream', + runtime: 'opencode', + openCodeSessionId: 'ses_test', + lastEventType: 'tool_call_update', + lastToolKind: 'todowrite', + }; + + writeAcpResult(child, 1, {}); + writeAcpResult(child, 2, { sessionId: 'session-1' }); + writeAcpError(child, 3, { + code: -32600, + message: + 'opencode event stream: {"type":"session.error","properties":{"error":{"data":{"message":"[code=upstream_error] stream idle timeout: no data received within configured window"}}}}', + data: details, + }); + + const errorEvents = events.filter((entry) => entry.event === 'error'); + assert.equal(errorEvents.length, 1); + const payload = errorEvents[0]?.payload as { + error?: { retryable?: unknown; details?: unknown }; + }; + assert.equal(payload.error?.retryable, true); + assert.deepEqual(payload.error?.details, details); +}); + test('attachAcpSession resumes via session/load when resumeSessionId is set', () => { const child = new FakeAcpChild(); const writes: string[] = []; diff --git a/apps/daemon/tests/amr-acp-integration.test.ts b/apps/daemon/tests/amr-acp-integration.test.ts index dc20429008b..d0e83085971 100644 --- a/apps/daemon/tests/amr-acp-integration.test.ts +++ b/apps/daemon/tests/amr-acp-integration.test.ts @@ -23,7 +23,10 @@ import { describe, expect, it } from 'vitest'; import { attachAcpSession, detectAcpModels } from '../src/agent-protocol/index.js'; import { acpTelemetryToolCallId } from '../src/agent-protocol/acp/updates.js'; -import { classifyAmrAccountFailure } from '../src/integrations/vela-errors.js'; +import { + DEFAULT_AMR_RECHARGE_URL, + classifyAmrAccountFailure, +} from '../src/integrations/vela-errors.js'; import { AmrModelLoadingCache } from '../src/runtimes/amr-model-cache.js'; import { amrAgentDef, @@ -960,7 +963,7 @@ describe('AMR ACP transport — end-to-end against fake vela stub', () => { expect(classifyAmrAccountFailure(message)).toMatchObject({ code: 'AMR_INSUFFICIENT_BALANCE', action: 'recharge', - actionUrl: 'https://open-design.ai/amr/wallet?source=open_design', + actionUrl: DEFAULT_AMR_RECHARGE_URL, }); }); @@ -1003,6 +1006,7 @@ describe('AMR ACP transport — end-to-end against fake vela stub', () => { expect(payload?.error?.details).toMatchObject({ kind: 'amr_account', action: 'recharge', + actionUrl: DEFAULT_AMR_RECHARGE_URL, }); expect(String(payload?.message ?? '')).toContain('AMR Cloud reported insufficient balance'); }); @@ -1054,6 +1058,7 @@ describe('AMR ACP transport — end-to-end against fake vela stub', () => { expect(payload?.error?.details).toMatchObject({ kind: 'amr_account', action: 'recharge', + actionUrl: DEFAULT_AMR_RECHARGE_URL, promoted_by: 'open_design_acp_retry_status', }); expect(String(payload?.message ?? '')).toContain('AMR Cloud reported insufficient balance'); @@ -1093,6 +1098,7 @@ describe('AMR ACP transport — end-to-end against fake vela stub', () => { expect(payload?.error?.details).toMatchObject({ kind: 'amr_account', action: 'recharge', + actionUrl: DEFAULT_AMR_RECHARGE_URL, promoted_by: 'open_design_acp_stderr_retry_status', }); expect(String(payload?.message ?? '')).toContain('AMR Cloud reported insufficient balance'); @@ -1133,6 +1139,7 @@ describe('AMR ACP transport — end-to-end against fake vela stub', () => { expect(payload?.error?.details).toMatchObject({ kind: 'amr_account', action: 'recharge', + actionUrl: DEFAULT_AMR_RECHARGE_URL, promoted_by: 'open_design_acp_stderr_retry_status', }); }); diff --git a/apps/daemon/tests/amr-session-resume.test.ts b/apps/daemon/tests/amr-session-resume.test.ts index 0c29b1fc350..54924f4f621 100644 --- a/apps/daemon/tests/amr-session-resume.test.ts +++ b/apps/daemon/tests/amr-session-resume.test.ts @@ -201,6 +201,50 @@ describe('AMR (vela) ACP session resume — full server cycle', () => { expect(await readInvocations(logPath)).toEqual(['new', 'new']); }); + it('clears a resumed AMR session after request_too_large so the next turn starts fresh', async () => { + binDir = await mkdtemp(path.join(os.tmpdir(), 'od-amr-largecontext-bin-')); + const logPath = path.join(binDir, 'invocations.jsonl'); + const bin = await writeVelaWrapper(binDir, 'vela-largecontext', { + logPath, + promptErrorOnLoad: '[code=request_too_large] request body exceeds configured limit', + }); + + clearTelemetryEnv(); + started = (await startServer({ port: 0, returnServer: true })) as StartedServer; + await putConfig(started.url, { + agentId: 'amr', + agentCliEnv: { amr: { VELA_BIN: bin } }, + telemetry: { metrics: true, content: false, artifactManifest: false }, + privacyDecisionAt: Date.now(), + }); + + const conversationId = await createConversation(started.url); + + // Turn 1 captures the durable upstream OpenCode handle. + expect((await sendRunAndWait(started.url, conversationId, 'first request')).status) + .toBe('succeeded'); + + // Turn 2 resumes that handle, but the upstream request is now too large. + // The run should fail honestly, but the daemon must clear the handle so + // the next user retry does not load the same overgrown native session. + const turn2 = await sendRunAndWait(started.url, conversationId, 'second request'); + expect(turn2.status).toBe('failed'); + expect(turn2.error ?? '').toMatch(/request body exceeds configured limit/i); + + const turn2Events = await readRunEvents(turn2.eventsLogPath); + expect(hasDiagnostic(turn2Events, { + type: 'agent_session_cleared_after_prompt_too_large', + reason: 'prompt_too_large', + stale_session_cleared: true, + })).toBe(true); + + // Turn 3 proves the stale handle was discarded: it opens session/new and + // succeeds instead of session/load-ing the same oversized upstream session. + expect((await sendRunAndWait(started.url, conversationId, 'third request')).status) + .toBe('succeeded'); + expect(await readInvocations(logPath)).toEqual(['new', 'load', 'new']); + }); + it('persists the concrete resolved model for a default turn (equivalent explicit follow-up resumes)', async () => { binDir = await mkdtemp(path.join(os.tmpdir(), 'od-amr-defaultmodel-bin-')); const logPath = path.join(binDir, 'invocations.jsonl'); @@ -392,6 +436,7 @@ async function writeVelaWrapper( logPath: string; resumeFailed?: boolean; omitHandle?: boolean; + promptErrorOnLoad?: string; logSetModel?: boolean; requireSetModel?: boolean; modelPresetJson?: string; @@ -410,6 +455,9 @@ async function writeVelaWrapper( } if (opts.resumeFailed) lines.push('export FAKE_VELA_RESUME_FAILED=1'); if (opts.omitHandle) lines.push('export FAKE_VELA_OMIT_OPENCODE_SESSION_ID=1'); + if (opts.promptErrorOnLoad) { + lines.push(`export FAKE_VELA_PROMPT_ERROR_ON_LOAD=${JSON.stringify(opts.promptErrorOnLoad)}`); + } if (opts.logSetModel) lines.push('export FAKE_VELA_LOG_SET_MODEL=1'); if (opts.modelPresetJson) { lines.push(`export FAKE_VELA_MODEL_PRESET_JSON=${JSON.stringify(opts.modelPresetJson)}`); @@ -520,9 +568,20 @@ async function putConfig(url: string, patch: Record): Promise { const projectId = `amr_resume_${randomUUID()}`; + const workspaceId = `amr_resume_personal_${projectId}`; + const workspaceMemberId = `amr_resume_owner_${projectId}`; + const workspaceHeaders = { + 'x-od-workspace-id': workspaceId, + 'x-od-workspace-type': 'personal', + 'x-od-workspace-member-id': workspaceMemberId, + 'x-od-workspace-role': 'owner', + }; const projectResponse = await fetch(`${url}/api/projects`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { + 'content-type': 'application/json', + ...workspaceHeaders, + }, body: JSON.stringify({ id: projectId, name: 'AMR resume smoke', @@ -532,7 +591,12 @@ async function createConversation(url: string): Promise { }); expect(projectResponse.status).toBe(200); const projectBody = (await projectResponse.json()) as { conversationId: string; id: string }; - return `${projectId}::${projectBody.conversationId}`; + return [ + projectId, + projectBody.conversationId, + workspaceId, + workspaceMemberId, + ].join('::'); } async function sendRunAndWait( @@ -541,7 +605,11 @@ async function sendRunAndWait( message: string, model?: string, ): Promise { - const [projectId, conversationId] = encoded.split('::'); + const [projectId, conversationId, workspaceId, workspaceMemberId] = + encoded.split('::'); + if (!projectId || !conversationId || !workspaceId || !workspaceMemberId) { + throw new Error(`invalid AMR resume fixture identity: ${encoded}`); + } const assistantMessageId = `assistant_amr_${randomUUID()}`; const runResponse = await fetch(`${url}/api/runs`, { method: 'POST', @@ -550,6 +618,10 @@ async function sendRunAndWait( 'x-od-analytics-device-id': 'amr-resume-test', 'x-od-analytics-session-id': 'amr-resume-session', 'x-od-analytics-client-type': 'web', + 'x-od-workspace-id': workspaceId, + 'x-od-workspace-type': 'personal', + 'x-od-workspace-member-id': workspaceMemberId, + 'x-od-workspace-role': 'owner', }, body: JSON.stringify({ projectId, @@ -562,15 +634,31 @@ async function sendRunAndWait( ...(model ? { model } : {}), }), }); - expect(runResponse.status).toBe(202); - const body = (await runResponse.json()) as { runId: string }; - return await waitForRun(url, body.runId); + const body = (await runResponse.json()) as { + runId?: string; + error?: { code?: string; message?: string }; + }; + expect(runResponse.status, JSON.stringify(body)).toBe(202); + expect(body.runId).toBeTypeOf('string'); + return await waitForRun(url, body.runId!, { + 'x-od-workspace-id': workspaceId, + 'x-od-workspace-type': 'personal', + 'x-od-workspace-member-id': workspaceMemberId, + 'x-od-workspace-role': 'owner', + }); } -async function waitForRun(url: string, runId: string): Promise { +async function waitForRun( + url: string, + runId: string, + headers: Record, +): Promise { const startedAt = Date.now(); while (Date.now() - startedAt < 15_000) { - const response = await fetch(`${url}/api/runs/${encodeURIComponent(runId)}`); + const response = await fetch( + `${url}/api/runs/${encodeURIComponent(runId)}`, + { headers }, + ); expect(response.status).toBe(200); const run = (await response.json()) as RunStatus; if (run.status === 'failed' || run.status === 'succeeded' || run.status === 'canceled') { diff --git a/apps/daemon/tests/app-config.test.ts b/apps/daemon/tests/app-config.test.ts index b1f46ca1643..a1387f6fb54 100644 --- a/apps/daemon/tests/app-config.test.ts +++ b/apps/daemon/tests/app-config.test.ts @@ -161,6 +161,90 @@ describe('app-config', () => { expect(cfg.orbit).not.toHaveProperty('templateSkillId'); }); + it('preserves only the minimal persisted Orbit Workspace identity', async () => { + await writeFile( + path.join(dataDir, 'app-config.json'), + JSON.stringify({ + orbit: { + enabled: true, + time: '09:30', + workspaceScope: { + workspaceId: ' workspace-a ', + workspaceMemberId: ' member-a ', + role: 'owner', + }, + }, + }), + ); + + const cfg = await readAppConfig(dataDir); + + expect(cfg.orbit?.workspaceScope).toEqual({ + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }); + }); + + it('keeps scoped Orbit identity when an older client updates Orbit without that field', async () => { + await writeAppConfig(dataDir, { + orbit: { + enabled: true, + time: '09:30', + workspaceScope: { + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }, + }, + }); + + await writeAppConfig(dataDir, { + orbit: { + enabled: false, + time: '10:15', + }, + }); + + await expect(readAppConfig(dataDir)).resolves.toMatchObject({ + orbit: { + enabled: false, + time: '10:15', + workspaceScope: { + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }, + }, + }); + }); + + it('allows an explicit null to clear a persisted Orbit Workspace identity', async () => { + await writeAppConfig(dataDir, { + orbit: { + enabled: true, + time: '09:30', + workspaceScope: { + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }, + }, + }); + + await writeAppConfig(dataDir, { + orbit: { + enabled: false, + time: '10:15', + workspaceScope: null, + }, + }); + + await expect(readAppConfig(dataDir)).resolves.toMatchObject({ + orbit: { + enabled: false, + time: '10:15', + workspaceScope: null, + }, + }); + }); + it('falls back to default orbit time for out-of-range stored values', async () => { await writeFile( path.join(dataDir, 'app-config.json'), diff --git a/apps/daemon/tests/automations/workspace-scope.test.ts b/apps/daemon/tests/automations/workspace-scope.test.ts new file mode 100644 index 00000000000..cd8785d89ec --- /dev/null +++ b/apps/daemon/tests/automations/workspace-scope.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { WorkspaceDirectoryItem } from '@open-design/contracts'; +import { + authorizePersistedAutomationWorkspaceScope, + authorizePersistedProjectWorkspace, + bindProjectToPersistedAutomationWorkspace, + normalizePersistedAutomationWorkspaceScope, +} from '../../src/automations/workspace-scope.js'; + +function directoryItem( + workspaceId: string, + workspaceMemberId: string, + overrides: Partial = {}, +): WorkspaceDirectoryItem { + return { + workspaceId, + workspaceName: workspaceId, + workspaceType: 'team', + workspaceMemberId, + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + ...overrides, + }; +} + +describe('persisted automation Workspace scope', () => { + it('binds the exact persisted billing address without consulting membership authority', () => { + const ensureWorkspaceProject = vi.fn(); + + bindProjectToPersistedAutomationWorkspace( + ensureWorkspaceProject, + { + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }, + 'project-a', + 123, + ); + + expect(ensureWorkspaceProject).toHaveBeenCalledWith({ + projectId: 'project-a', + workspaceId: 'workspace-a', + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: 'member-a', + updatedByWorkspaceMemberId: 'member-a', + syncState: 'local_only', + resourceHubResourceId: null, + cloudTombstonedAt: null, + createdAt: 123, + updatedAt: 123, + }); + }); + + it('keeps the configured A identity after the directory also exposes B', async () => { + const fetchDirectory = vi.fn(async () => ({ + ok: true, + items: [ + directoryItem('workspace-b', 'member-b'), + directoryItem('workspace-a', 'member-a'), + ], + })); + + await expect( + authorizePersistedAutomationWorkspaceScope( + { workspaceId: 'workspace-a', workspaceMemberId: 'member-a' }, + fetchDirectory, + ), + ).resolves.toMatchObject({ + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }); + }); + + it('fails closed on removal and authority outage', async () => { + await expect( + authorizePersistedAutomationWorkspaceScope( + { workspaceId: 'workspace-a', workspaceMemberId: 'member-a' }, + async () => ({ + ok: true, + items: [ + directoryItem('workspace-a', 'member-a', { memberStatus: 'removed' }), + ], + }), + ), + ).rejects.toMatchObject({ code: 'WORKSPACE_ACCESS_DENIED', retryable: false }); + + await expect( + authorizePersistedAutomationWorkspaceScope( + { workspaceId: 'workspace-a', workspaceMemberId: 'member-a' }, + async () => ({ ok: false, items: [] }), + ), + ).rejects.toMatchObject({ + code: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + retryable: true, + }); + }); + + it('re-reads authority for every trigger and rejects a member removed after configuration', async () => { + let removed = false; + const fetchDirectory = vi.fn(async () => ({ + ok: true, + items: [ + directoryItem('workspace-a', 'member-a', { + memberStatus: removed ? 'removed' : 'active', + }), + ], + })); + + await expect( + authorizePersistedAutomationWorkspaceScope( + { workspaceId: 'workspace-a', workspaceMemberId: 'member-a' }, + fetchDirectory, + ), + ).resolves.toMatchObject({ workspaceId: 'workspace-a' }); + + removed = true; + await expect( + authorizePersistedAutomationWorkspaceScope( + { workspaceId: 'workspace-a', workspaceMemberId: 'member-a' }, + fetchDirectory, + ), + ).rejects.toMatchObject({ code: 'WORKSPACE_ACCESS_DENIED' }); + expect(fetchDirectory).toHaveBeenCalledTimes(2); + }); + + it('keeps historical no-scope automation records truly unbound', () => { + expect(normalizePersistedAutomationWorkspaceScope(undefined)).toBeNull(); + expect(normalizePersistedAutomationWorkspaceScope(null)).toBeNull(); + expect(normalizePersistedAutomationWorkspaceScope({})).toBeNull(); + }); + + it('uses a reused project binding instead of another selected Workspace', async () => { + await expect( + authorizePersistedProjectWorkspace( + 'workspace-a', + async () => ({ + ok: true, + items: [ + directoryItem('workspace-b', 'member-b'), + directoryItem('workspace-a', 'member-a'), + ], + }), + ), + ).resolves.toMatchObject({ + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }); + }); +}); diff --git a/apps/daemon/tests/brand-routes.test.ts b/apps/daemon/tests/brand-routes.test.ts index 1f17d74020b..8e9dfcc0734 100644 --- a/apps/daemon/tests/brand-routes.test.ts +++ b/apps/daemon/tests/brand-routes.test.ts @@ -6,7 +6,16 @@ import os from 'node:os'; import path from 'node:path'; import { registerBrandRoutes, type BrandRoutesDeps } from '../src/brand-routes.js'; -import { closeDatabase, insertConversation, insertProject, listMessages, openDatabase, upsertMessage } from '../src/db.js'; +import { createCreatedProjectWorkspaceResolver } from '../src/collab/created-project-workspace.js'; +import { + closeDatabase, + getWorkspaceProjectByProjectId, + insertConversation, + insertProject, + listMessages, + openDatabase, + upsertMessage, +} from '../src/db.js'; import type { PrefetchResult } from '../src/brands/prefetch.js'; const NO_LOGO_FALLBACK = async () => ({ changed: false }); @@ -532,6 +541,66 @@ describe('brand routes', () => { } }); + it('binds a freshly extracted brand project into the caller\'s ACTIVE team workspace', async () => { + // Red-spec for the gap `d7f3546d8`'s commit message already named but did + // not close: `startBrandExtraction` (brands/index.ts) inserts its backing + // project row directly and never calls `ensureWorkspaceProject`. Before + // this fix, that left the project with NO `workspace_projects` row even + // when the request that created it plainly named a team workspace member — + // and POST /api/runs + POST /api/chat's workspace mutation gate + // (`enforceWorkspaceResourceMutation`) unconditionally denies a run against + // ANY unbound project once the caller's client sends workspace headers at + // all (`row === null` short-circuits `canMutate` to false regardless of + // who created it). A team member's own just-created design system could + // never get its first agent turn to run, so the agent could never write + // the `assets/logo.svg` spec 04 §9.3's sync depends on. + const server = await startBrandServer({ + logoFallback: NO_LOGO_FALLBACK, + imageryFallback: NO_IMAGERY_FALLBACK, + }); + try { + const started = await server.requestJson('/api/brands', { + method: 'POST', + body: { url: 'https://example.com', description: 'Aurora Grove is a minimal interior-design studio.' }, + headers: { + 'x-od-workspace-id': 'ws-team-1', + 'x-od-workspace-member-id': 'member-owner', + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'member', + 'x-od-workspace-member-status': 'active', + }, + }); + expect(started.status).toBe(200); + + const binding = getWorkspaceProjectByProjectId(db, started.body.projectId); + expect(binding).toMatchObject({ + workspaceId: 'ws-team-1', + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: 'member-owner', + }); + } finally { + await server.close(); + } + }); + + it('leaves a signed-out / single-player brand extraction unbound, exactly as before this fix', async () => { + const server = await startBrandServer({ + logoFallback: NO_LOGO_FALLBACK, + imageryFallback: NO_IMAGERY_FALLBACK, + }); + try { + const started = await server.requestJson('/api/brands', { + method: 'POST', + body: { url: 'https://example.com', description: 'Signed-out single-player brand.' }, + }); + expect(started.status).toBe(200); + expect(getWorkspaceProjectByProjectId(db, started.body.projectId)).toBeUndefined(); + } finally { + await server.close(); + } + }); + it('continues extraction against the retry conversation instead of a stale terminal run', async () => { writeBrandFixture('brand-retry-route', { projectId: 'project-retry-route', @@ -1280,6 +1349,7 @@ describe('brand routes', () => { type RequestOptions = { method?: string; body?: unknown; + headers?: Record; }; async function startBrandServer(extraDeps: Partial = {}) { @@ -1293,6 +1363,13 @@ describe('brand routes', () => { skillsRoot, dataDir, db, + // The same production seam `server.ts` builds. Constructed with no + // membership-directory fetcher, which is + // `authorizeCreatedProjectWorkspace`'s documented local/dev + // compatibility configuration — so a verified header identity still + // binds here, while the resolver's verify-then-degrade contract is + // covered directly in `tests/collab/created-project-workspace.test.ts`. + resolveCreatedProjectHome: createCreatedProjectWorkspaceResolver({}), ...extraDeps, }); const server = http.createServer(app); @@ -1300,9 +1377,9 @@ describe('brand routes', () => { const address = server.address(); if (!address || typeof address === 'string') throw new Error('server did not bind to a TCP port'); const requestTextFromServer = async (route: string, options: RequestOptions = {}) => { - const init: RequestInit = { method: options.method ?? 'GET' }; + const init: RequestInit = { method: options.method ?? 'GET', headers: { ...options.headers } }; if (Object.hasOwn(options, 'body')) { - init.headers = { 'content-type': 'application/json' }; + init.headers = { ...init.headers, 'content-type': 'application/json' }; init.body = JSON.stringify(options.body); } const response = await fetch(`http://127.0.0.1:${address.port}${route}`, init); diff --git a/apps/daemon/tests/chat-project-authority.test.ts b/apps/daemon/tests/chat-project-authority.test.ts new file mode 100644 index 00000000000..facbe298587 --- /dev/null +++ b/apps/daemon/tests/chat-project-authority.test.ts @@ -0,0 +1,342 @@ +import http from 'node:http'; +import express from 'express'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { registerChatRoutes } from '../src/routes/chat.js'; + +let server: http.Server | null = null; + +afterEach(async () => { + if (!server) return; + const toClose = server; + server = null; + await new Promise((resolve) => toClose.close(() => resolve())); +}); + +async function startChatServer(options: { + authorizeProjectRequest: any; + run?: { + id: string; + projectId: string | null; + conversationId: string | null; + assistantMessageId: string | null; + } | null; + reportFeedback?: any; + onArtifact?: any; + onInterrupt?: any; +}) { + const app = express(); + app.use(express.json()); + const reportFeedback = + options.reportFeedback ?? + vi.fn(async () => ({ status: 'accepted' as const })); + const onArtifact = options.onArtifact ?? vi.fn(); + const onInterrupt = options.onInterrupt ?? vi.fn(); + registerChatRoutes(app, { + db: {}, + design: { + runs: { + get: () => options.run ?? null, + }, + }, + http: { + createSseResponse: () => ({ + send: () => true, + end: () => undefined, + }), + sendApiError: ( + res: express.Response, + status: number, + code: string, + message: string, + details?: Record, + ) => res.status(status).json({ error: code, message, ...details }), + }, + paths: {}, + chat: {}, + agents: {}, + critique: { + critiqueArtifactsRoot: '/tmp/unused', + critiqueResponseCapBytes: 1024, + critiqueRunRegistry: {}, + handleCritiqueArtifact: () => (_req: express.Request, res: express.Response) => { + onArtifact(); + res.status(200).send('artifact'); + }, + handleCritiqueInterrupt: () => (_req: express.Request, res: express.Response) => { + onInterrupt(); + res.status(202).json({ accepted: true }); + }, + }, + appConfig: { readAppConfig: async () => ({}) }, + validation: {}, + lifecycle: { isDaemonShuttingDown: () => false }, + telemetry: { reportFeedback }, + authorizeProjectRequest: options.authorizeProjectRequest, + } as any); + + server = http.createServer(app); + await new Promise((resolve) => server!.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('server did not bind'); + return { + baseUrl: `http://127.0.0.1:${address.port}`, + reportFeedback, + onArtifact, + onInterrupt, + }; +} + +describe('chat-owned project route authority', () => { + it.each([ + { + label: 'SenseAudio', + path: '/api/proxy/senseaudio/stream', + }, + { + label: 'AIHubMix', + path: '/api/proxy/aihubmix/stream', + }, + ])('denies the $label BYOK tool loop before provider work for a non-creator', async ({ + path, + }) => { + const authorizeProjectRequest = vi.fn( + async (_req, res: express.Response) => { + res.status(403).json({ error: 'WORKSPACE_PROJECT_PERMISSION_DENIED' }); + return false; + }, + ); + const api = await startChatServer({ authorizeProjectRequest }); + + const response = await fetch(`${api.baseUrl}${path}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + apiKey: 'test-key', + model: 'test-model', + projectId: 'project-a', + // Authority must run before even provider URL validation: resolving a + // valid URL may touch DNS, and a later valid request would spend the + // caller's key and write the generated file. + baseUrl: 'not-a-url', + messages: [], + }), + }); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + error: 'WORKSPACE_PROJECT_PERMISSION_DENIED', + }); + expect(authorizeProjectRequest).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'project-a', + { mode: 'write', capability: 'writeFiles' }, + ); + }); + + it.each([ + { + label: 'SenseAudio', + path: '/api/proxy/senseaudio/stream', + }, + { + label: 'AIHubMix', + path: '/api/proxy/aihubmix/stream', + }, + ])('keeps the $label BYOK tool loop available when the unified gate accepts the creator or an unbound local project', async ({ + path, + }) => { + const authorizeProjectRequest = vi.fn(async () => true); + const api = await startChatServer({ authorizeProjectRequest }); + + const response = await fetch(`${api.baseUrl}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + apiKey: 'test-key', + model: 'test-model', + projectId: 'legacy-project', + baseUrl: 'not-a-url', + messages: [], + }), + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: 'BAD_REQUEST' }); + expect(authorizeProjectRequest).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'legacy-project', + { mode: 'write', capability: 'writeFiles' }, + ); + }); + + it('authorizes artifact navigation through the unified project read gate', async () => { + const authorizeProjectRequest = vi.fn( + async (_req, res: express.Response) => { + res.status(403).json({ error: 'WORKSPACE_PROJECT_PERMISSION_DENIED' }); + return false; + }, + ); + const api = await startChatServer({ authorizeProjectRequest }); + + const response = await fetch( + `${api.baseUrl}/api/projects/project-a/critique/run-a/artifact` + + '?workspaceId=workspace-a&workspaceMemberId=member-a', + ); + + expect(response.status).toBe(403); + expect(api.onArtifact).not.toHaveBeenCalled(); + expect(authorizeProjectRequest).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'project-a', + { mode: 'read', allowNavigationQuery: true }, + ); + }); + + it('authorizes interrupts through the unified project write gate before mutation', async () => { + const authorizeProjectRequest = vi.fn( + async (_req, res: express.Response) => { + res.status(403).json({ error: 'WORKSPACE_PROJECT_PERMISSION_DENIED' }); + return false; + }, + ); + const api = await startChatServer({ authorizeProjectRequest }); + + const response = await fetch( + `${api.baseUrl}/api/projects/project-a/critique/run-a/interrupt`, + { + method: 'POST', + headers: { + 'x-od-workspace-id': 'workspace-a', + 'x-od-workspace-member-id': 'member-a', + }, + }, + ); + + expect(response.status).toBe(403); + expect(api.onInterrupt).not.toHaveBeenCalled(); + expect(authorizeProjectRequest).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'project-a', + { mode: 'write', capability: 'writeFiles' }, + ); + }); + + it('authorizes feedback against the run authoritative project before telemetry', async () => { + const authorizeProjectRequest = vi.fn( + async (_req, res: express.Response) => { + res.status(403).json({ error: 'WORKSPACE_PROJECT_PERMISSION_DENIED' }); + return false; + }, + ); + const api = await startChatServer({ + authorizeProjectRequest, + run: { + id: 'run-a', + projectId: 'project-a', + conversationId: 'conversation-a', + assistantMessageId: 'message-a', + }, + }); + + const response = await fetch(`${api.baseUrl}/api/runs/run-a/feedback`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-od-workspace-id': 'workspace-a', + 'x-od-workspace-member-id': 'member-a', + }, + body: JSON.stringify({ + rating: 'positive', + reasonCodes: ['matched_request'], + hasCustomReason: false, + customReason: '', + }), + }); + + expect(response.status).toBe(403); + expect(api.reportFeedback).not.toHaveBeenCalled(); + expect(authorizeProjectRequest).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'project-a', + { mode: 'write', capability: 'writeFiles' }, + ); + }); + + it('rejects caller-owned feedback identity fields instead of accepting spoofed metadata', async () => { + const authorizeProjectRequest = vi.fn(async () => true); + const api = await startChatServer({ + authorizeProjectRequest, + run: { + id: 'run-a', + projectId: 'project-a', + conversationId: 'conversation-a', + assistantMessageId: 'message-a', + }, + }); + + const response = await fetch(`${api.baseUrl}/api/runs/run-a/feedback`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + projectId: 'project-b', + conversationId: 'conversation-b', + assistantMessageId: 'message-b', + rating: 'negative', + reasonCodes: ['missed_request'], + hasCustomReason: false, + customReason: '', + }), + }); + + expect(response.status).toBe(400); + expect(api.reportFeedback).not.toHaveBeenCalled(); + }); + + it('derives feedback metadata from the run after exact authorization', async () => { + const authorizeProjectRequest = vi.fn(async () => true); + const api = await startChatServer({ + authorizeProjectRequest, + run: { + id: 'run-a', + projectId: 'project-a', + conversationId: 'conversation-a', + assistantMessageId: 'message-a', + }, + }); + + const response = await fetch(`${api.baseUrl}/api/runs/run-a/feedback`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-od-workspace-id': 'workspace-a', + 'x-od-workspace-member-id': 'member-a', + }, + body: JSON.stringify({ + rating: 'positive', + reasonCodes: ['matched_request'], + hasCustomReason: true, + customReason: 'clear result', + }), + }); + + expect(response.status).toBe(202); + expect(api.reportFeedback).toHaveBeenCalledWith(expect.objectContaining({ + runId: 'run-a', + scoreMetadata: { + projectId: 'project-a', + conversationId: 'conversation-a', + assistantMessageId: 'message-a', + hasCustomReason: true, + customReason: 'clear result', + }, + })); + }); +}); diff --git a/apps/daemon/tests/chat-project-skill-critique-label.test.ts b/apps/daemon/tests/chat-project-skill-critique-label.test.ts new file mode 100644 index 00000000000..fafe4dbcc85 --- /dev/null +++ b/apps/daemon/tests/chat-project-skill-critique-label.test.ts @@ -0,0 +1,118 @@ +import type http from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { delimiter, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +describe('project skill critique label', () => { + let server: http.Server; + let baseUrl: string; + let fakeBinDir: string; + const originalPath = process.env.PATH; + const originalCritiqueEnabled = process.env.OD_CRITIQUE_ENABLED; + + beforeAll(async () => { + process.env.OD_CRITIQUE_ENABLED = '1'; + fakeBinDir = await mkdtemp(join(tmpdir(), 'od-project-skill-critique-')); + const fakeQwenPath = join(fakeBinDir, 'qwen'); + await writeFile( + fakeQwenPath, + `#!/usr/bin/env node +process.stdin.resume(); +process.stdout.write(\` + + + fixture + ]]> + + ok + ok + ok + ok + + Ship fixture. + + + + ]]> + Shipped. + + +\`); +setTimeout(() => process.exit(0), 250); +`, + { mode: 0o755 }, + ); + process.env.PATH = `${fakeBinDir}${delimiter}${originalPath ?? ''}`; + + const { startServer } = await import('../src/server.js'); + const started = await startServer({ port: 0, returnServer: true }) as { + url: string; + server: http.Server; + }; + baseUrl = started.url; + server = started.server; + }); + + afterAll(async () => { + if (server) { + await new Promise((resolve) => server.close(() => resolve())); + } + await rm(fakeBinDir, { recursive: true, force: true }); + if (originalPath == null) delete process.env.PATH; + else process.env.PATH = originalPath; + if (originalCritiqueEnabled == null) delete process.env.OD_CRITIQUE_ENABLED; + else process.env.OD_CRITIQUE_ENABLED = originalCritiqueEnabled; + }); + + it('labels critique with the canonical project skill when the request omits skillId', async () => { + const projectId = `project-skill-label-${randomUUID()}`; + const createResponse = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: projectId, + name: 'Project skill critique label fixture', + skillId: 'open-design-landing-deck', + designSystemId: 'sleek', + metadata: { critiqueTheaterEnabled: true }, + }), + }); + expect(createResponse.ok).toBe(true); + + // Simulate a legacy project row that predates skill-id canonicalization. + // The chat request intentionally carries no request-level skillId. + const dataDir = process.env.OD_DATA_DIR; + if (!dataDir) throw new Error('OD_DATA_DIR is required for this fixture'); + const { openDatabase } = await import('../src/db.js'); + const db = openDatabase(process.cwd(), { dataDir }); + db.prepare('UPDATE projects SET skill_id = ? WHERE id = ?') + .run('editorial-collage-deck', projectId); + + const { __resetCritiqueMetricsForTests } = await import('../src/metrics/index.js'); + __resetCritiqueMetricsForTests(); + + const chatResponse = await fetch(`${baseUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agentId: 'qwen', + projectId, + designSystemId: 'sleek', + message: 'Create the landing page.', + }), + }); + expect(chatResponse.ok).toBe(true); + const chatBody = await chatResponse.text(); + expect(chatBody).toContain('critique.run_started'); + + const metricsResponse = await fetch(`${baseUrl}/api/metrics`); + const metrics = await metricsResponse.text(); + expect(metrics).toContain( + 'open_design_critique_runs_total{status="shipped",adapter="qwen",skill="open-design-landing-deck"} 1', + ); + expect(metrics).not.toContain('skill="unknown"'); + expect(metrics).not.toContain('skill="editorial-collage-deck"'); + }); +}); diff --git a/apps/daemon/tests/chat-route.test.ts b/apps/daemon/tests/chat-route.test.ts index 9ba3d06a15c..0364418c27d 100644 --- a/apps/daemon/tests/chat-route.test.ts +++ b/apps/daemon/tests/chat-route.test.ts @@ -34,7 +34,7 @@ import { skillCwdAliasSegment } from '../src/cwd-aliases.js'; import { getAgentDef } from '../src/agents.js'; import { readAppConfig, writeAppConfig } from '../src/app-config.js'; import { readMemoryConfig, writeMemoryConfig } from '../src/memory.js'; -import { upsertMessage } from '../src/db.js'; +import { ensureWorkspaceProject, upsertMessage } from '../src/db.js'; import { renderCodexImagegenOverride } from '../src/prompts/system.js'; import { ByokCredentialService, @@ -103,6 +103,43 @@ describe('/api/chat', () => { const originalAgentHome = process.env.OD_AGENT_HOME; const tempDirs: string[] = []; + async function createPersonalWorkspaceBoundProjectFixture(label: string) { + if (!process.env.OD_DATA_DIR) { + throw new Error('OD_DATA_DIR is required for AMR Workspace scope tests'); + } + const projectId = `proj-${randomUUID()}`; + const workspaceId = `personal-ws-${randomUUID()}`; + const workspaceMemberId = `personal-member-${randomUUID()}`; + const createProjectResponse = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: projectId, name: label }), + }); + expect(createProjectResponse.ok).toBe(true); + + const sqlite = new Database(resolve(process.env.OD_DATA_DIR, 'app.sqlite')); + try { + ensureWorkspaceProject(sqlite as never, { + projectId, + workspaceId, + visibility: 'personal', + createdByWorkspaceMemberId: workspaceMemberId, + }); + } finally { + sqlite.close(); + } + + return { + projectId, + headers: { + 'x-od-workspace-id': workspaceId, + 'x-od-workspace-member-id': workspaceMemberId, + 'x-od-workspace-type': 'personal', + 'x-od-workspace-role': 'owner', + }, + }; + } + async function createPluginFixture(args: { pluginId: string; dirName: string; @@ -905,6 +942,8 @@ process.exit(1); // Unique key so the shared model cache key is unique per test run. process.env.VELA_RUNTIME_KEY = `fake-runtime-key-${randomUUID()}`; process.env.VELA_LINK_URL = 'https://amr-link.open-design.ai/v1'; + const workspaceFixture = + await createPersonalWorkspaceBoundProjectFixture('Transient AMR catalog fixture'); await withFakeAgent( 'vela', @@ -937,9 +976,13 @@ child.on('exit', (code, signal) => { async () => { const response = await fetch(`${baseUrl}/api/chat`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...workspaceFixture.headers, + }, body: JSON.stringify({ agentId: 'amr', + projectId: workspaceFixture.projectId, message: 'hello', model: 'deepseek-v3.2', }), @@ -951,6 +994,7 @@ child.on('exit', (code, signal) => { expect(body).toContain('"type":"text_delta","delta":"vela."'); expect(body).not.toContain('model_catalog_unavailable'); expect(body).not.toContain('AMR_MODEL_UNAVAILABLE'); + expect(body).not.toContain('AMR_WORKSPACE_SCOPE_REQUIRED'); // The catalog probe runs at least once (remote attempted, then the // run proceeds from the preset seed). We no longer assert an exact // synchronous retry count: the remote retry/backoff now happens in @@ -988,6 +1032,8 @@ child.on('exit', (code, signal) => { // cached remote catalog. process.env.VELA_RUNTIME_KEY = `fake-runtime-key-${randomUUID()}`; process.env.VELA_LINK_URL = 'https://amr-link.open-design.ai/v1'; + const workspaceFixture = + await createPersonalWorkspaceBoundProjectFixture('Cached AMR catalog fixture'); await withFakeAgent( 'vela', @@ -1015,9 +1061,13 @@ child.on('exit', (code, signal) => { async () => { const response = await fetch(`${baseUrl}/api/chat`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...workspaceFixture.headers, + }, body: JSON.stringify({ agentId: 'amr', + projectId: workspaceFixture.projectId, message: 'hello', // Present in the preset seed (DEFAULT_MODEL_PRESET_JSON) but the // live `model list` is unavailable, so only the preset path can @@ -1032,6 +1082,7 @@ child.on('exit', (code, signal) => { expect(body).not.toContain('AMR_MODEL_UNAVAILABLE'); expect(body).not.toContain('model_catalog_unavailable'); expect(body).not.toContain('is not available from Vela'); + expect(body).not.toContain('AMR_WORKSPACE_SCOPE_REQUIRED'); // It must actually proceed into the ACP run and stream assistant text. expect(body).toContain('"type":"text_delta","delta":"Hello from fake "'); expect(body).toContain('"type":"text_delta","delta":"vela."'); diff --git a/apps/daemon/tests/cli-skills-install.test.ts b/apps/daemon/tests/cli-skills-install.test.ts new file mode 100644 index 00000000000..6e0692a5436 --- /dev/null +++ b/apps/daemon/tests/cli-skills-install.test.ts @@ -0,0 +1,120 @@ +import http from 'node:http'; +import { execFile } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve as pathResolve } from 'node:path'; +import { promisify } from 'node:util'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +const execFileP = promisify(execFile); +const currentDir = dirname(fileURLToPath(import.meta.url)); +const daemonRoot = pathResolve(currentDir, '..'); +const repoRoot = pathResolve(currentDir, '../../..'); +const cliSource = pathResolve(currentDir, '../src/cli.ts'); +const tsxCli = pathResolve(repoRoot, 'node_modules/tsx/dist/cli.mjs'); + +interface CapturedRequest { + method: string; + url: string; + body: string; +} + +describe('od skill install CLI', () => { + const requests: CapturedRequest[] = []; + let server: http.Server; + let baseUrl: string; + + beforeAll(async () => { + server = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + requests.push({ method: req.method ?? '', url: req.url ?? '', body }); + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ + skill: { + id: 'remote-skill', + name: 'remote-skill', + source: 'user', + }, + })); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('stub server has no address'); + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }); + + beforeEach(() => { + requests.length = 0; + }); + + async function runCli(args: string[]) { + try { + const { stdout, stderr } = await execFileP( + process.execPath, + [tsxCli, cliSource, ...args], + { + cwd: daemonRoot, + env: { ...process.env, NODE_OPTIONS: undefined }, + timeout: 15_000, + }, + ); + return { code: 0, stdout, stderr }; + } catch (error) { + const failed = error as { code?: number; stdout?: string; stderr?: string }; + return { + code: failed.code ?? 1, + stdout: failed.stdout ?? '', + stderr: failed.stderr ?? '', + }; + } + } + + it('POSTs the plugin-compatible source and prints the response as JSON', async () => { + const result = await runCli([ + 'skill', + 'install', + 'github:owner/skill-repo', + '--daemon-url', + baseUrl, + '--json', + ]); + + expect(result.code).toBe(0); + expect(requests).toEqual([{ + method: 'POST', + url: '/api/skills/install', + body: JSON.stringify({ source: 'github:owner/skill-repo' }), + }]); + expect(JSON.parse(result.stdout)).toMatchObject({ + skill: { id: 'remote-skill' }, + }); + }); + + it('passes a browser GitHub repository URL to the shared daemon installer', async () => { + const result = await runCli([ + 'skill', + 'install', + 'https://github.com/leonxlnx/taste-skill', + '--daemon-url', + baseUrl, + '--json', + ]); + + expect(result.code).toBe(0); + expect(requests).toEqual([{ + method: 'POST', + url: '/api/skills/install', + body: JSON.stringify({ source: 'https://github.com/leonxlnx/taste-skill' }), + }]); + }); +}); diff --git a/apps/daemon/tests/collab-cli.test.ts b/apps/daemon/tests/collab-cli.test.ts new file mode 100644 index 00000000000..b912f8bf5d9 --- /dev/null +++ b/apps/daemon/tests/collab-cli.test.ts @@ -0,0 +1,256 @@ +import { execFile } from 'node:child_process'; +import http from 'node:http'; +import { dirname, resolve as pathResolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { afterEach, describe, expect, it } from 'vitest'; + +const execFileP = promisify(execFile); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DAEMON_ROOT = pathResolve(__dirname, '..'); +const REPO_ROOT = pathResolve(__dirname, '../../..'); +const CLI_SRC = pathResolve(__dirname, '../src/cli.ts'); +const TSX_CLI = pathResolve(REPO_ROOT, 'node_modules/tsx/dist/cli.mjs'); + +interface CapturedRequest { + method: string; + url: string; + body: string; + headers: http.IncomingHttpHeaders; +} + +interface StubServer { + baseUrl: string; + requests: CapturedRequest[]; + close: () => Promise; +} + +let stub: StubServer | null = null; + +afterEach(async () => { + if (stub) await stub.close(); + stub = null; +}); + +// Mirrors the daemon's collab presence + sync routes so the CLI exercises the +// real SUBCOMMAND_MAP dispatch and request shaping against a live socket. +async function startCollabStubServer(): Promise { + const requests: CapturedRequest[] = []; + const server = http.createServer((req, res) => { + let raw = ''; + req.on('data', (chunk) => { + raw += chunk; + }); + req.on('end', () => { + requests.push({ + method: req.method ?? '', + url: req.url ?? '', + body: raw, + headers: req.headers, + }); + res.setHeader('content-type', 'application/json'); + const { method, url } = { method: req.method ?? '', url: req.url ?? '' }; + if (method === 'GET' && url === '/api/projects/p1/presence') { + res.end(JSON.stringify({ present: [{ memberId: 'm-42', name: 'Ma Shu', role: 'member' }] })); + return; + } + if (method === 'POST' && url === '/api/projects/p1/presence/heartbeat') { + res.end(JSON.stringify({ present: [{ memberId: 'm-42', name: 'Ma Shu', role: 'member' }] })); + return; + } + if (method === 'GET' && url === '/api/projects/p1/collab/status') { + res.end(JSON.stringify({ + publishedVersion: 7, + materializedVersion: 6, + syncState: 'synced', + })); + return; + } + if (method === 'POST' && url === '/api/projects/p1/collab/publish') { + res.end(JSON.stringify({ ok: true })); + return; + } + if (method === 'POST' && url === '/api/projects/p1/collab/sync-intent') { + res.end(JSON.stringify({ ok: true, syncState: 'pending_upload' })); + return; + } + if (method === 'POST' && url === '/api/projects/p1/collab/pull') { + res.end(JSON.stringify({ ok: true, version: 3 })); + return; + } + res.statusCode = 404; + res.end(JSON.stringify({ error: { code: 'unexpected-request', message: url } })); + }); + }); + await new Promise((resolveListen) => server.listen(0, '127.0.0.1', resolveListen)); + const addr = server.address(); + if (!addr || typeof addr === 'string') throw new Error('stub server has no address'); + return { + baseUrl: `http://127.0.0.1:${addr.port}`, + requests, + close: () => + new Promise((resolveClose, rejectClose) => { + server.close((err) => (err ? rejectClose(err) : resolveClose())); + }), + }; +} + +async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; code: number | null }> { + const env: NodeJS.ProcessEnv = { ...process.env }; + delete env.NODE_OPTIONS; + try { + const { stdout, stderr } = await execFileP(process.execPath, [TSX_CLI, CLI_SRC, ...args], { + cwd: DAEMON_ROOT, + env, + timeout: 15_000, + maxBuffer: 4 * 1024 * 1024, + }); + return { stdout, stderr, code: 0 }; + } catch (err) { + const failed = err as { stdout?: string; stderr?: string; code?: number | null }; + return { stdout: failed.stdout ?? '', stderr: failed.stderr ?? '', code: failed.code ?? 1 }; + } +} + +describe('od collab CLI', () => { + it('lists the present member set as JSON', async () => { + stub = await startCollabStubServer(); + const result = await runCli([ + 'collab', 'presence', 'p1', + '--workspace', 'team-1', '--workspace-member', 'm-42', + '--json', '--daemon-url', stub.baseUrl, + ]); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + present: [{ memberId: 'm-42', name: 'Ma Shu', role: 'member' }], + }); + expect(stub.requests).toHaveLength(1); + expect(stub.requests[0]).toMatchObject({ method: 'GET', url: '/api/projects/p1/presence' }); + expect(stub.requests[0]?.headers).toMatchObject({ + 'x-od-workspace-id': 'team-1', + 'x-od-workspace-member-id': 'm-42', + }); + }); + + it('sends a heartbeat with the member identity in the body', async () => { + stub = await startCollabStubServer(); + const result = await runCli([ + 'collab', 'heartbeat', 'p1', + '--workspace', 'team-1', '--workspace-member', 'm-42', + '--member', 'm-42', '--name', 'Ma Shu', '--role', 'member', + '--daemon-url', stub.baseUrl, + ]); + expect(result.code).toBe(0); + expect(stub.requests).toHaveLength(1); + expect(stub.requests[0]).toMatchObject({ method: 'POST', url: '/api/projects/p1/presence/heartbeat' }); + expect(JSON.parse(stub.requests[0]!.body)).toEqual({ memberId: 'm-42', name: 'Ma Shu', role: 'member' }); + }); + + it('requests a publish and prints the published version', async () => { + stub = await startCollabStubServer(); + const publish = await runCli([ + 'collab', 'publish', 'p1', + '--workspace', 'team-1', '--workspace-member', 'm-42', + '--json', '--daemon-url', stub.baseUrl, + ]); + expect(publish.code).toBe(0); + expect(JSON.parse(publish.stdout)).toEqual({ ok: true }); + + const status = await runCli([ + 'collab', 'status', 'p1', + '--workspace', 'team-1', '--workspace-member', 'm-42', + '--daemon-url', stub.baseUrl, + ]); + expect(status.code).toBe(0); + expect(status.stdout).toContain('publishedVersion\t7'); + expect(status.stdout).toContain('materializedVersion\t6'); + expect(stub.requests.map((r) => `${r.method} ${r.url}`)).toEqual([ + 'POST /api/projects/p1/collab/publish', + 'GET /api/projects/p1/collab/status', + ]); + for (const request of stub.requests) { + expect(request?.headers).toMatchObject({ + 'x-od-workspace-id': 'team-1', + 'x-od-workspace-member-id': 'm-42', + }); + } + }); + + it('pulls through the explicitly scoped project route', async () => { + stub = await startCollabStubServer(); + + const result = await runCli([ + 'collab', + 'pull', + 'p1', + '--workspace', + 'team-1', + '--workspace-member', + 'm-42', + '--json', + '--daemon-url', + stub.baseUrl, + ]); + + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ ok: true, version: 3 }); + expect(stub.requests.map((request) => + `${request.method} ${request.url}`)).toEqual([ + 'POST /api/projects/p1/collab/pull', + ]); + expect(stub.requests[0]?.headers).toMatchObject({ + 'x-od-workspace-id': 'team-1', + 'x-od-workspace-member-id': 'm-42', + }); + }); + + it('sends the visibility-to-sync team-share intent and reports the sync state', async () => { + stub = await startCollabStubServer(); + const share = await runCli([ + 'collab', 'share', 'p1', + '--workspace', 'team-1', '--workspace-member', 'm-42', + '--json', '--daemon-url', stub.baseUrl, + ]); + expect(share.code).toBe(0); + expect(JSON.parse(share.stdout)).toEqual({ ok: true, syncState: 'pending_upload' }); + expect(stub.requests).toHaveLength(1); + expect(stub.requests[0]).toMatchObject({ method: 'POST', url: '/api/projects/p1/collab/sync-intent' }); + expect(JSON.parse(stub.requests[0]!.body)).toEqual({ + event: 'project_team_share_requested', + projectId: 'p1', + }); + }); + + it('surfaces the sync state in status output', async () => { + stub = await startCollabStubServer(); + const status = await runCli([ + 'collab', 'status', 'p1', + '--workspace', 'team-1', '--workspace-member', 'm-42', + '--daemon-url', stub.baseUrl, + ]); + expect(status.code).toBe(0); + expect(status.stdout).toContain('synced'); + }); + + it('rejects a heartbeat with no --member', async () => { + stub = await startCollabStubServer(); + const result = await runCli([ + 'collab', 'heartbeat', 'p1', + '--workspace', 'team-1', '--workspace-member', 'm-42', + '--daemon-url', stub.baseUrl, + ]); + expect(result.code).toBe(2); + expect(result.stderr).toContain('--member'); + expect(stub.requests).toHaveLength(0); + }); + + it('rejects project collaboration without explicit workspace identity', async () => { + stub = await startCollabStubServer(); + const result = await runCli([ + 'collab', 'status', 'p1', '--daemon-url', stub.baseUrl, + ]); + expect(result.code).toBe(1); + expect(result.stderr).toContain('--workspace and --workspace-member '); + expect(stub.requests).toHaveLength(0); + }); +}); diff --git a/apps/daemon/tests/collab-cloud.test.ts b/apps/daemon/tests/collab-cloud.test.ts new file mode 100644 index 00000000000..ce5fb30b543 --- /dev/null +++ b/apps/daemon/tests/collab-cloud.test.ts @@ -0,0 +1,896 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + buildWorkspacePermissions, + buildWorkspaceSeatSummary, + type CollabCloudComment, + type WorkspaceCollabContext, +} from '@open-design/contracts'; +import { + closeDatabase, + deleteSyncedPreviewComment, + ensureProjectCommentAnchorConversation, + getLatestConversationIdForProject, + insertConversation, + insertProject, + listConversations, + listMessages, + listPreviewComments, + mergeSyncedPreviewComment, + openDatabase, + upsertPreviewComment, +} from '../src/db.js'; +import { createCollabCloudClient, type CollabCloudClient } from '../src/integrations/collab-cloud.js'; +import { + createCollabCloudService, + previewCommentToCloud, +} from '../src/collab/collab-cloud-service.js'; +import { + createVelaCliCollabClient, + shouldUseVelaCliCollabTransport, +} from '../src/collab/vela-cli-collab-client.js'; +import type { WorkspaceContextProvider } from '../src/collab/workspace-context.js'; + +let tempDir: string | null = null; + +afterEach(() => { + closeDatabase(); + if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; +}); + +function cloudComment(id: string, patch: Partial = {}): CollabCloudComment { + return { + id, + projectId: 'p1', + conversationId: 'conv-remote', + memberId: 'm-author', + seq: 0, + note: `note ${id}`, + filePath: 'index.html', + elementId: 'hero', + selector: '[data-od-id="hero"]', + label: 'h1.hero', + text: 'Hero', + htmlHint: '

', + position: { x: 1, y: 2, width: 3, height: 4 }, + status: 'open', + createdAt: 100, + updatedAt: 100, + ...patch, + }; +} + +function seededDb() { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-collab-cloud-')); + const db = openDatabase(tempDir); + insertProject(db, { id: 'p1', name: 'Project', createdAt: 1, updatedAt: 1 }); + insertConversation(db, { id: 'conv-local', projectId: 'p1', title: 'Chat', createdAt: 1, updatedAt: 1 }); + return db; +} + +function teamContext(patch: Partial = {}): WorkspaceCollabContext { + return { + workspaceId: 'ws-1', + workspaceType: 'team', + workspaceMemberId: 'm-self', + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + billingState: 'active', + planId: null, + providerMode: 'platform_credits', + seatSummary: buildWorkspaceSeatSummary({ seatLimit: 5, usedSeats: 1 }), + permissions: buildWorkspacePermissions({ role: 'owner', lifecycleState: 'active' }), + teamId: 'team-1', + displayName: '琼羽', + ...patch, + }; +} + +function fixedContextProvider(context: WorkspaceCollabContext | null): WorkspaceContextProvider { + return { current: async () => context }; +} + +// —— previewCommentToCloud mapping ———————————————————————————————————————————— + +describe('previewCommentToCloud', () => { + it('uses the comment author as memberId and carries the anchor/drift fields', () => { + const cloud = previewCommentToCloud( + { + id: 'c1', + projectId: 'p1', + conversationId: 'conv-local', + filePath: 'index.html', + elementId: 'hero', + selector: '[data-od-id="hero"]', + label: 'h1.hero', + text: 'Hero', + position: { x: 1, y: 2, width: 3, height: 4 }, + htmlHint: '

', + note: 'looks off', + status: 'open', + createdAt: 10, + updatedAt: 20, + authorMemberId: 'm-author', + anchorState: 'reanchored', + anchoredVersion: 7, + lastGoodPosition: { x: 5, y: 6, width: 7, height: 8 }, + } as any, + 'm-fallback', + ); + expect(cloud.memberId).toBe('m-author'); + expect(cloud.anchorState).toBe('reanchored'); + expect(cloud.anchoredVersion).toBe(7); + expect(cloud.lastGoodPosition).toEqual({ x: 5, y: 6, width: 7, height: 8 }); + expect(cloud.seq).toBe(0); + }); + + it('falls back to the sharing member when the comment has no author', () => { + const cloud = previewCommentToCloud( + { + id: 'c1', + projectId: 'p1', + conversationId: 'conv-local', + filePath: 'index.html', + elementId: 'hero', + selector: 's', + label: 'l', + text: 't', + position: { x: 0, y: 0, width: 0, height: 0 }, + htmlHint: '', + note: 'n', + status: 'open', + createdAt: 1, + updatedAt: 1, + } as any, + 'm-fallback', + ); + expect(cloud.memberId).toBe('m-fallback'); + }); +}); + +// —— mergeSyncedPreviewComment idempotency (real db) —————————————————————————— + +describe('mergeSyncedPreviewComment', () => { + it('inserts once and is a no-op on re-merge of the same id', () => { + const db = seededDb(); + const comment = cloudComment('c1', { + memberId: 'm-author', + anchorState: 'anchored', + anchoredVersion: 3, + }); + expect(mergeSyncedPreviewComment(db, 'p1', 'conv-local', comment)).toBe(true); + // Re-pull of the same cloud comment (same id) must not double-insert. + expect(mergeSyncedPreviewComment(db, 'p1', 'conv-local', comment)).toBe(false); + + const stored = listPreviewComments(db, 'p1', 'conv-local'); + expect(stored).toHaveLength(1); + expect(stored[0]!.id).toBe('c1'); + // The AUTHOR is preserved for cross-member attribution. + expect(stored[0]!.authorMemberId).toBe('m-author'); + expect(stored[0]!.anchorState).toBe('anchored'); + expect(stored[0]!.anchoredVersion).toBe(3); + }); + + it('lands under the LOCAL conversation, not the cloud comment conversationId', () => { + const db = seededDb(); + // comment.conversationId is 'conv-remote' (a foreign daemon's id) — merge must + // re-home it onto the local conversation to satisfy the FK + be queryable. + mergeSyncedPreviewComment(db, 'p1', 'conv-local', cloudComment('c1')); + expect(listPreviewComments(db, 'p1', 'conv-local')).toHaveLength(1); + expect(listPreviewComments(db, 'p1', 'conv-remote')).toHaveLength(0); + }); + + // —— multi-author coexistence on the SAME element (the顶掉 root cause) ———————— + + it('keeps two members\' comments on the same element as distinct rows', () => { + const db = seededDb(); + // A member's comment on `hero` is synced in... + mergeSyncedPreviewComment(db, 'p1', 'conv-local', cloudComment('c-member', { memberId: 'm-member' })); + // ...and the local user (a different author) comments on the SAME element. + const own = upsertPreviewComment(db, 'p1', 'conv-local', { + target: { + filePath: 'index.html', + elementId: 'hero', + selector: '[data-od-id="hero"]', + label: 'h1.hero', + text: 'Hero', + htmlHint: '

', + position: { x: 0, y: 0, width: 0, height: 0 }, + }, + note: 'owner note', + authorMemberId: 'm-owner', + }); + expect(own).not.toBeNull(); + const stored = listPreviewComments(db, 'p1', 'conv-local'); + // Both coexist — the local upsert did NOT clobber the synced member comment. + expect(stored).toHaveLength(2); + expect(stored.find((c) => c.authorMemberId === 'm-member')?.note).toBe('note c-member'); + expect(stored.find((c) => c.authorMemberId === 'm-owner')?.note).toBe('owner note'); + }); + + it('merges two different-author comments on the same element without a collision', () => { + const db = seededDb(); + expect( + mergeSyncedPreviewComment(db, 'p1', 'conv-local', cloudComment('cA', { memberId: 'm-a' })), + ).toBe(true); + // Same element, different author + different id → a new distinct row (not IGNOREd). + expect( + mergeSyncedPreviewComment(db, 'p1', 'conv-local', cloudComment('cB', { memberId: 'm-b' })), + ).toBe(true); + expect(listPreviewComments(db, 'p1', 'conv-local')).toHaveLength(2); + }); + + // —— edit sync (UPSERT by updatedAt) —————————————————————————————————————————— + + it('applies a strictly-newer edit in place and ignores a stale one', () => { + const db = seededDb(); + mergeSyncedPreviewComment(db, 'p1', 'conv-local', cloudComment('c1', { note: 'v1', updatedAt: 100 })); + // Newer updatedAt → update in place. + expect( + mergeSyncedPreviewComment(db, 'p1', 'conv-local', cloudComment('c1', { note: 'v2', updatedAt: 200 })), + ).toBe(true); + expect(listPreviewComments(db, 'p1', 'conv-local')[0]!.note).toBe('v2'); + // Stale updatedAt → no-op, the fresher local content wins. + expect( + mergeSyncedPreviewComment(db, 'p1', 'conv-local', cloudComment('c1', { note: 'v0', updatedAt: 150 })), + ).toBe(false); + expect(listPreviewComments(db, 'p1', 'conv-local')[0]!.note).toBe('v2'); + // A re-pull at the same updatedAt is also a no-op (still one row). + expect( + mergeSyncedPreviewComment(db, 'p1', 'conv-local', cloudComment('c1', { note: 'v2', updatedAt: 200 })), + ).toBe(false); + expect(listPreviewComments(db, 'p1', 'conv-local')).toHaveLength(1); + }); + + // —— delete sync (tombstone) —————————————————————————————————————————————————— + + it('deletes the local comment on an inbound tombstone', () => { + const db = seededDb(); + mergeSyncedPreviewComment(db, 'p1', 'conv-local', cloudComment('c1')); + expect(listPreviewComments(db, 'p1', 'conv-local')).toHaveLength(1); + // Tombstone removes it (delete wins regardless of updatedAt). + expect( + mergeSyncedPreviewComment(db, 'p1', 'conv-local', cloudComment('c1', { deleted: true, updatedAt: 1 })), + ).toBe(true); + expect(listPreviewComments(db, 'p1', 'conv-local')).toHaveLength(0); + // A repeated tombstone is a no-op. + expect( + mergeSyncedPreviewComment(db, 'p1', 'conv-local', cloudComment('c1', { deleted: true })), + ).toBe(false); + }); + + it('deleteSyncedPreviewComment removes by id, scoped to the project', () => { + const db = seededDb(); + mergeSyncedPreviewComment(db, 'p1', 'conv-local', cloudComment('c1')); + expect(deleteSyncedPreviewComment(db, 'other-project', 'c1')).toBe(false); + expect(deleteSyncedPreviewComment(db, 'p1', 'c1')).toBe(true); + expect(listPreviewComments(db, 'p1', 'conv-local')).toHaveLength(0); + }); +}); + +// —— createCollabCloudService poll + merge idempotency (fake client) ————————— + +/** An in-memory fake collab-cloud client honoring sinceSeq, matching the real + * client's method shape so the service runs unchanged against it. */ +function fakeClient() { + const comments: CollabCloudComment[] = []; + const registered: Array<{ teamId: string; memberId: string; displayName: string; role: string }> = []; + let seq = 0; + const client = { + isConfigured: () => true, + registerMember: async (teamId: string, memberId: string, input: { displayName: string; role: string }) => { + registered.push({ teamId, memberId, ...input }); + return { memberId, displayName: input.displayName, role: input.role as any }; + }, + listMembers: async () => registered.map((r) => ({ memberId: r.memberId, displayName: r.displayName, role: r.role as any })), + pushComment: async (_teamId: string, _projectId: string, comment: CollabCloudComment) => { + seq += 1; + comments.push({ ...comment, seq }); + return { seq }; + }, + pullComments: async (_teamId: string, _projectId: string, sinceSeq: number) => { + const next = comments.filter((c) => c.seq > sinceSeq).sort((a, b) => a.seq - b.seq); + return { comments: next, latestSeq: seq, notModified: false, etag: `W/"seq-${seq}"` }; + }, + }; + return { client: client as unknown as CollabCloudClient, seed: (c: CollabCloudComment) => { seq += 1; comments.push({ ...c, seq }); }, registered }; +} + +describe('createCollabCloudService', () => { + it('re-homes remote comments onto a stable empty local anchor', async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-collab-cloud-anchor-')); + const db = openDatabase(tempDir); + insertProject(db, { + id: 'p1', + name: 'Pulled Team mirror', + createdAt: 1, + updatedAt: 1, + }); + expect(getLatestConversationIdForProject(db, 'p1')).toBeNull(); + + const firstAnchor = ensureProjectCommentAnchorConversation(db, 'p1', 2); + expect(firstAnchor?.created).toBe(true); + expect(ensureProjectCommentAnchorConversation(db, 'p1', 3)).toEqual({ + conversationId: firstAnchor?.conversationId, + created: false, + }); + + const { client, seed } = fakeClient(); + seed(cloudComment('remote-comment', { + conversationId: 'owner-private-conversation', + note: 'shared comment only', + })); + const service = createCollabCloudService({ + client, + workspaceContext: fixedContextProvider(teamContext({ role: 'member' })), + listProjectIds: () => [], + resolveLocalConversationId: (projectId) => + getLatestConversationIdForProject(db, projectId), + mergeComment: ({ projectId, conversationId, comment }) => + mergeSyncedPreviewComment(db, projectId, conversationId, comment), + }); + + await expect( + service.pullProject('p1', teamContext({ role: 'member' })), + ).resolves.toBe(true); + + const conversations = listConversations(db, 'p1'); + expect(conversations.map((conversation) => conversation.id)).toEqual([ + firstAnchor?.conversationId, + ]); + expect(listMessages(db, firstAnchor!.conversationId)).toEqual([]); + expect( + listPreviewComments(db, 'p1', firstAnchor!.conversationId), + ).toEqual([ + expect.objectContaining({ + id: 'remote-comment', + conversationId: firstAnchor?.conversationId, + note: 'shared comment only', + }), + ]); + service.dispose(); + }); + + it('polls, merges new comments once, and advances the cursor (no re-merge)', async () => { + const { client, seed } = fakeClient(); + seed(cloudComment('c1')); + seed(cloudComment('c2')); + + const merged = new Map(); + let mergeCalls = 0; + + const service = createCollabCloudService({ + client, + workspaceContext: fixedContextProvider(teamContext()), + listProjectIds: () => ['p1'], + resolveProjectWorkspaceContext: async () => teamContext(), + resolveLocalConversationId: () => 'conv-local', + mergeComment: ({ comment }) => { + mergeCalls += 1; + if (merged.has(comment.id)) return false; + merged.set(comment.id, comment); + return true; + }, + }); + + await service.pollOnce(); + expect([...merged.keys()]).toEqual(['c1', 'c2']); + expect(mergeCalls).toBe(2); + + // Second poll: sinceSeq is at the head → nothing new pulled → no more merges. + await service.pollOnce(); + expect(merged.size).toBe(2); + expect(mergeCalls).toBe(2); + service.dispose(); + }); + + it('stays fully dormant in a personal workspace (team-only resources plane)', async () => { + // B's resources/collab plane rejects personal-workspace principals with + // 403 missing_principal BY DESIGN. A personal context must therefore + // yield no collab identity at all — no register, no pulls — instead of + // hammering B with doomed CLI calls on every poll tick (observed as an + // infinite `vela collab member register` 403 loop for fresh users). + const { client, registered } = fakeClient(); + let pulls = 0; + const wrapped = { + ...(client as unknown as Record), + pullComments: async (...args: unknown[]) => { + pulls += 1; + return (client as unknown as { pullComments: (...a: unknown[]) => unknown }).pullComments(...args); + }, + } as unknown as CollabCloudClient; + const personal = teamContext({ + workspaceType: 'personal', + workspaceId: 'ws-personal', + }); + delete (personal as Partial).teamId; + delete (personal as Partial).teamName; + const service = createCollabCloudService({ + client: wrapped, + workspaceContext: fixedContextProvider(personal), + listProjectIds: () => ['p1'], + resolveProjectWorkspaceContext: async () => personal, + resolveLocalConversationId: () => 'conv-local', + mergeComment: () => false, + }); + await service.pollOnce(); + await service.pollOnce(); + expect(registered.length).toBe(0); + expect(pulls).toBe(0); + service.dispose(); + }); + + it('registers the member once across polls, not on every cycle', async () => { + const { client, registered } = fakeClient(); + const service = createCollabCloudService({ + client, + workspaceContext: fixedContextProvider(teamContext({ displayName: '琼羽', role: 'owner' })), + listProjectIds: () => ['p1'], + resolveProjectWorkspaceContext: async () => + teamContext({ displayName: '琼羽', role: 'owner' }), + resolveLocalConversationId: () => null, + mergeComment: () => false, + }); + // The identity is stable across cycles, so we must register exactly once + // instead of spawning a `vela member register` process on every 5s tick. + await service.pollOnce(); + await service.pollOnce(); + await service.pollOnce(); + expect(registered).toEqual([ + { teamId: 'team-1', memberId: 'm-self', displayName: '琼羽', role: 'owner' }, + ]); + service.dispose(); + }); + + it('skips a project with no local conversation to attach to', async () => { + const { client, seed } = fakeClient(); + seed(cloudComment('c1')); + let mergeCalls = 0; + const service = createCollabCloudService({ + client, + workspaceContext: fixedContextProvider(teamContext()), + listProjectIds: () => ['p1'], + resolveProjectWorkspaceContext: async () => teamContext(), + resolveLocalConversationId: () => null, // member pulled the project, no chat yet + mergeComment: () => { mergeCalls += 1; return true; }, + }); + await service.pollOnce(); + expect(mergeCalls).toBe(0); + service.dispose(); + }); + + it('pushes a tombstone (deleted: true) for a comment deletion', async () => { + const { client } = fakeClient(); + const service = createCollabCloudService({ + client, + workspaceContext: fixedContextProvider(teamContext()), + listProjectIds: () => ['p1'], + resolveLocalConversationId: () => 'conv-local', + mergeComment: () => false, + }); + await service.pushCommentDeletion({ + id: 'c1', + projectId: 'p1', + conversationId: 'conv-local', + filePath: 'index.html', + elementId: 'hero', + selector: 's', + label: 'l', + text: 't', + position: { x: 0, y: 0, width: 0, height: 0 }, + htmlHint: '', + note: 'n', + status: 'open', + createdAt: 1, + updatedAt: 1, + authorMemberId: 'm-self', + } as any, teamContext()); + const pulled = await client.pullComments('team-1', 'p1', 0); + const tomb = pulled.comments.find((c) => c.id === 'c1'); + expect(tomb?.deleted).toBe(true); + service.dispose(); + }); + + it('is a full no-op off-team (no team context)', async () => { + const { client, registered } = fakeClient(); + let mergeCalls = 0; + const service = createCollabCloudService({ + client, + workspaceContext: fixedContextProvider(null), + listProjectIds: () => ['p1'], + resolveProjectWorkspaceContext: async () => null, + resolveLocalConversationId: () => 'conv-local', + mergeComment: () => { mergeCalls += 1; return true; }, + }); + await service.pollOnce(); + expect(registered).toHaveLength(0); + expect(mergeCalls).toBe(0); + expect( + await service.listMembers(teamContext({ workspaceType: 'personal' })), + ).toEqual([]); + service.dispose(); + }); + + it('lists members from the explicit request workspace instead of ambient context', async () => { + const { client } = fakeClient(); + const teamIds: string[] = []; + const scopedClient = { + ...client, + listMembers: async (teamId: string) => { + teamIds.push(teamId); + return []; + }, + } as unknown as CollabCloudClient; + const service = createCollabCloudService({ + client: scopedClient, + workspaceContext: fixedContextProvider( + teamContext({ workspaceId: 'team-b', teamId: 'team-b' }), + ), + listProjectIds: () => [], + resolveLocalConversationId: () => null, + mergeComment: () => false, + }); + + await service.listMembers( + teamContext({ workspaceId: 'team-a', teamId: 'team-a' }), + ); + + expect(teamIds).toEqual(['team-a']); + service.dispose(); + }); + + it('keeps project comment operations on their explicit scope after ambient moves to B', async () => { + const { client } = fakeClient(); + const calls: Array<{ + operation: string; + teamId: string; + memberId?: string; + }> = []; + const scopedClient = { + ...client, + pushComment: async ( + teamId: string, + _projectId: string, + comment: CollabCloudComment, + ) => { + calls.push({ operation: comment.deleted ? 'delete' : 'push', teamId, memberId: comment.memberId }); + return { seq: calls.length }; + }, + pullComments: async (teamId: string) => { + calls.push({ operation: 'pull', teamId }); + return { comments: [], latestSeq: 0, etag: null, notModified: true }; + }, + listMembers: async (teamId: string) => { + calls.push({ operation: 'resolve-member', teamId }); + return [{ memberId: 'owner-a', displayName: 'Owner A', role: 'owner' as const }]; + }, + } as unknown as CollabCloudClient; + const service = createCollabCloudService({ + client: scopedClient, + workspaceContext: fixedContextProvider( + teamContext({ + workspaceId: 'workspace-b', + workspaceMemberId: 'member-b', + teamId: 'workspace-b', + }), + ), + listProjectIds: () => [], + resolveLocalConversationId: () => 'conv-local', + mergeComment: () => false, + }); + const projectContext = teamContext({ + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + teamId: 'workspace-a', + }); + const comment = { + id: 'c-scoped', + projectId: 'p1', + conversationId: 'conv-local', + filePath: 'index.html', + elementId: 'hero', + selector: 's', + label: 'l', + text: 't', + position: { x: 0, y: 0, width: 0, height: 0 }, + htmlHint: '', + note: 'n', + status: 'open', + createdAt: 1, + updatedAt: 1, + } as any; + + await service.pushComment(comment, projectContext); + await service.pushCommentDeletion(comment, projectContext); + await service.pullProject('p1', projectContext); + await expect( + service.resolveMember('owner-a', projectContext), + ).resolves.toMatchObject({ displayName: 'Owner A' }); + + expect(calls).toEqual([ + { operation: 'push', teamId: 'workspace-a', memberId: 'member-a' }, + { operation: 'delete', teamId: 'workspace-a', memberId: 'member-a' }, + { operation: 'pull', teamId: 'workspace-a' }, + { operation: 'resolve-member', teamId: 'workspace-a' }, + ]); + service.dispose(); + }); + + it('partitions pull cursors by workspace and member for the same project id', async () => { + const sinceCalls: Array<{ + teamId: string; + sinceSeq: number; + etag: string | null | undefined; + }> = []; + const client = { + pullComments: async ( + teamId: string, + _projectId: string, + sinceSeq: number, + etag?: string | null, + ) => { + sinceCalls.push({ teamId, sinceSeq, etag }); + return { + comments: [], + latestSeq: 7, + etag: `etag-${teamId}`, + notModified: false, + }; + }, + } as unknown as CollabCloudClient; + const service = createCollabCloudService({ + client, + listProjectIds: () => [], + resolveLocalConversationId: () => 'conv-local', + mergeComment: () => false, + }); + const workspaceA = teamContext({ + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + teamId: 'workspace-a', + }); + const workspaceB = teamContext({ + workspaceId: 'workspace-b', + workspaceMemberId: 'member-b', + teamId: 'workspace-b', + }); + + await service.pullProject('same-project', workspaceA); + await service.pullProject('same-project', workspaceB); + await service.pullProject('same-project', workspaceA); + + expect(sinceCalls).toEqual([ + { teamId: 'workspace-a', sinceSeq: 0, etag: undefined }, + { teamId: 'workspace-b', sinceSeq: 0, etag: undefined }, + { teamId: 'workspace-a', sinceSeq: 7, etag: 'etag-workspace-a' }, + ]); + service.dispose(); + }); + + // —— pullProject redemption contract —————————————————————————————————————— + // The hub `comment-changed` handler and `onCommentsRead` both DELETE the + // project's dirty mark before firing this targeted pull. A single comment + // only ever emits one hub event, so a pull that silently no-ops or fails + // must report it (resolve false) — the caller restores the mark and the + // next read retries. Without that signal the mark is burned for nothing + // and the comment stays invisible until the project gains a live events + // subscriber. + + it('pullProject resolves true when the targeted pull actually ran (mark redeemed)', async () => { + const { client, seed } = fakeClient(); + seed(cloudComment('c1')); + const merged: string[] = []; + const service = createCollabCloudService({ + client, + workspaceContext: fixedContextProvider(teamContext()), + listProjectIds: () => [], + resolveLocalConversationId: () => 'conv-local', + mergeComment: ({ comment }) => { merged.push(comment.id); return true; }, + }); + await expect(service.pullProject('p1', teamContext())).resolves.toBe(true); + expect(merged).toEqual(['c1']); + // An up-to-date follow-up pull (nothing new) still counts as redeemed. + await expect(service.pullProject('p1', teamContext())).resolves.toBe(true); + service.dispose(); + }); + + it('pullProject resolves false when there is no local conversation to merge into', async () => { + const { client, seed } = fakeClient(); + seed(cloudComment('c1')); + const service = createCollabCloudService({ + client, + workspaceContext: fixedContextProvider(teamContext()), + listProjectIds: () => [], + resolveLocalConversationId: () => null, + mergeComment: () => true, + }); + await expect(service.pullProject('p1', teamContext())).resolves.toBe(false); + service.dispose(); + }); + + it('pullProject resolves false when the relay pull fails (error to onError, no throw)', async () => { + const { client } = fakeClient(); + (client as { pullComments: unknown }).pullComments = async () => { + throw new Error('relay unavailable'); + }; + const errors: unknown[] = []; + const service = createCollabCloudService({ + client, + workspaceContext: fixedContextProvider(teamContext()), + listProjectIds: () => [], + resolveLocalConversationId: () => 'conv-local', + mergeComment: () => true, + onError: (error) => errors.push(error), + }); + await expect(service.pullProject('p1', teamContext())).resolves.toBe(false); + expect(errors).toHaveLength(1); + service.dispose(); + }); + + it('pullProject resolves false off-team (no identity, nothing pulled)', async () => { + const { client } = fakeClient(); + const service = createCollabCloudService({ + client, + workspaceContext: fixedContextProvider(null), + listProjectIds: () => [], + resolveLocalConversationId: () => 'conv-local', + mergeComment: () => true, + }); + await expect( + service.pullProject( + 'p1', + teamContext({ workspaceType: 'personal', workspaceId: 'personal' }), + ), + ).resolves.toBe(false); + service.dispose(); + }); +}); + +// —— client wire behavior (injected fetch) ———————————————————————————————————— + +describe('collab-cloud client', () => { + function jsonResponse(status: number, body: unknown, etag?: string): Response { + const headers: Record = { 'content-type': 'application/json' }; + if (etag) headers.etag = etag; + return new Response(JSON.stringify(body), { status, headers }); + } + + it('attaches a bearer token and returns the pushed seq', async () => { + const calls: Array<{ url: string; method: string; auth: string | null; body: unknown }> = []; + const client = createCollabCloudClient({ + config: { baseUrl: 'http://cloud.local', token: 'secret' }, + fetch: (async (input: any, init: any) => { + const req = new Request(input, init); + calls.push({ + url: req.url, + method: req.method, + auth: req.headers.get('authorization'), + body: init?.body ? JSON.parse(init.body) : undefined, + }); + return jsonResponse(200, { ok: true, seq: 5 }); + }) as unknown as typeof fetch, + }); + const result = await client.pushComment('team-1', 'p1', cloudComment('c1')); + expect(result.seq).toBe(5); + expect(calls[0]!.auth).toBe('Bearer secret'); + expect(calls[0]!.method).toBe('POST'); + expect(calls[0]!.url).toBe('http://cloud.local/teams/team-1/projects/p1/comments'); + expect((calls[0]!.body as any).comment.id).toBe('c1'); + }); + + it('sends If-None-Match and treats a 304 as "not modified"', async () => { + let seenIfNoneMatch: string | null = null; + const client = createCollabCloudClient({ + config: { baseUrl: 'http://cloud.local', token: 'secret' }, + fetch: (async (input: any, init: any) => { + const req = new Request(input, init); + seenIfNoneMatch = req.headers.get('if-none-match'); + return new Response(null, { status: 304, headers: { etag: 'W/"seq-2"' } }); + }) as unknown as typeof fetch, + }); + const result = await client.pullComments('team-1', 'p1', 2, 'W/"seq-2"'); + expect(seenIfNoneMatch).toBe('W/"seq-2"'); + expect(result.notModified).toBe(true); + expect(result.comments).toEqual([]); + expect(result.latestSeq).toBe(2); + }); +}); + +describe('VelaCliCollabClient', () => { + it('uses the CLI transport when team/resource sync is already Vela-backed', () => { + expect(shouldUseVelaCliCollabTransport({ OD_COLLAB_TRANSPORT: 'vela-cli' })).toBe(true); + expect(shouldUseVelaCliCollabTransport({ OD_COLLAB_TRANSPORT: 'sdk' })).toBe(false); + expect(shouldUseVelaCliCollabTransport({ OD_WORKSPACE_CONTEXT_SOURCE: 'vela' })).toBe(true); + expect(shouldUseVelaCliCollabTransport({ + OD_WORKSPACE_CONTEXT_SOURCE: 'vela', + OD_COLLAB_CLOUD_URL: 'http://legacy-fixture.local', + })).toBe(true); + expect(shouldUseVelaCliCollabTransport({ OD_TEAM_PROJECTS_TRANSPORT: 'vela-cli' })).toBe(true); + expect(shouldUseVelaCliCollabTransport({ OD_RESOURCE_TRANSPORT: 'vela-cli' })).toBe(true); + expect(shouldUseVelaCliCollabTransport({ OD_COLLAB_CLOUD_URL: 'http://fixture.local' })).toBe(false); + expect(shouldUseVelaCliCollabTransport({})).toBe(false); + }); + + it('uses vela collab commands for comments, directory, and presence', async () => { + const calls: string[][] = []; + const workspaces: Array = []; + const client = createVelaCliCollabClient({ + run: async (args, workspaceId) => { + calls.push(args); + workspaces.push(workspaceId); + if (args[0] === 'member' && args[1] === 'register') { + return JSON.stringify({ member: { memberId: 'm-self', displayName: '麻薯', role: 'owner' } }); + } + if (args[0] === 'comment' && args[1] === 'push') { + return JSON.stringify({ seq: 7 }); + } + if (args[0] === 'comment' && args[1] === 'pull') { + return JSON.stringify({ latestSeq: 7, comments: [cloudComment('c1', { seq: 7 })] }); + } + if (args[0] === 'presence' && args[1] === 'heartbeat') { + return JSON.stringify({ + viewers: [ + { + memberId: 'm-self', + displayName: '麻薯', + role: 'owner', + filePath: 'Typography', + activity: { label: '正在评论 Typography' }, + heartbeatAt: '2026-07-10T00:00:00.000Z', + }, + ], + }); + } + return JSON.stringify({}); + }, + }); + + await expect(client.registerMember('team-1', 'm-self', { + displayName: '麻薯', + role: 'owner', + })).resolves.toEqual({ memberId: 'm-self', displayName: '麻薯', role: 'owner' }); + await expect(client.pushComment('team-1', 'p1', cloudComment('c1'))).resolves.toEqual({ seq: 7 }); + await expect(client.pullComments('team-1', 'p1', 0)).resolves.toMatchObject({ + latestSeq: 7, + comments: [{ id: 'c1' }], + }); + await expect(client.heartbeatPresence('p1', { + member: { memberId: 'm-self', name: '麻薯', role: 'owner' }, + clientId: 'client-1', + filePath: 'Typography', + activity: { label: '正在评论 Typography' }, + }, 'team-1')).resolves.toEqual([ + { + memberId: 'm-self', + name: '麻薯', + role: 'owner', + filePath: 'Typography', + activity: { label: '正在评论 Typography' }, + heartbeatAt: '2026-07-10T00:00:00.000Z', + }, + ]); + + expect(calls[0]).toEqual(['member', 'register', '--display-name', '麻薯', '--role', 'owner']); + expect(calls[1]?.slice(0, 3)).toEqual(['comment', 'push', 'p1']); + expect(JSON.parse(calls[1]![4]!)).toMatchObject({ id: 'c1' }); + expect(calls[2]).toEqual(['comment', 'pull', 'p1', '--since-seq', '0']); + expect(calls[3]).toEqual([ + 'presence', + 'heartbeat', + 'p1', + '--client-id', + 'client-1', + '--display-name', + '麻薯', + '--file-path', + 'Typography', + '--activity-json', + JSON.stringify({ label: '正在评论 Typography' }), + ]); + expect(workspaces).toEqual(['team-1', 'team-1', 'team-1', 'team-1']); + }); +}); diff --git a/apps/daemon/tests/collab-context-routes.test.ts b/apps/daemon/tests/collab-context-routes.test.ts new file mode 100644 index 00000000000..c851b0d4ff9 --- /dev/null +++ b/apps/daemon/tests/collab-context-routes.test.ts @@ -0,0 +1,1235 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import express from 'express'; +import http from 'node:http'; +import { + buildWorkspacePermissions, + buildWorkspaceSeatSummary, + type WorkspaceCollabContext, +} from '@open-design/contracts'; +import { + registerCollabContextRoutes, + type RegisterCollabContextRoutesDeps, +} from '../src/routes/collab-context.js'; +import { + createDevWorkspaceContextProvider, + parseWorkspaceCollabContext, +} from '../src/collab/workspace-context.js'; +import { createWorkspaceBillingRuntimeCoordinator } from '../src/collab/workspace-billing-runtime.js'; + +let server: http.Server | null = null; + +afterEach(async () => { + if (server) { + const toClose = server; + server = null; + await new Promise((resolve) => toClose.close(() => resolve())); + } +}); + +/** The minimal payload a dev/demo run PUTs — only enum + identity fields. */ +const TEAM_CONTEXT = { + workspaceType: 'team', + workspaceMemberId: 'wm-1', + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + displayName: 'Ma Shu', +}; + +const ADMIN_CONTEXT = { + ...TEAM_CONTEXT, + role: 'admin', +}; + +const TEAM_DIRECTORY_ITEM = { + workspaceId: 'wm-1', + workspaceName: 'Workspace 1', + workspaceType: 'team' as const, + workspaceMemberId: 'wm-1', + role: 'member' as const, + memberStatus: 'active' as const, + lifecycleState: 'active' as const, +}; + +const TEAM_HEADERS = { + 'x-od-workspace-id': 'wm-1', + 'x-od-workspace-member-id': 'wm-1', +}; + +/** What `parseWorkspaceCollabContext` returns: the minimal input enriched with the + * fields it derives — workspaceId fallback, provider/billing defaults, and the + * permissions + seat summary derived through B's shared helpers. */ +const TEAM_CONTEXT_PARSED: WorkspaceCollabContext = { + workspaceId: 'wm-1', + workspaceType: 'team', + workspaceMemberId: 'wm-1', + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + billingState: 'active', + planId: null, + providerMode: 'platform_credits', + seatSummary: buildWorkspaceSeatSummary({ seatLimit: 5, usedSeats: 1 }), + permissions: buildWorkspacePermissions({ role: 'member', lifecycleState: 'active' }), + // Invariant: a team context always carries teamId (the workspace IS the + // team scope) — collab gates on it, so the parser pins it when omitted. + teamId: 'wm-1', + displayName: 'Ma Shu', +}; + +async function startContextServer( + overrides: Partial> = {}, +) { + const app = express(); + app.use(express.json()); + registerCollabContextRoutes(app, { + workspaceContext: createDevWorkspaceContextProvider(), + ...overrides, + }); + server = http.createServer(app); + await new Promise((resolve) => server!.listen(0, resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('server did not bind to a TCP port'); + const base = `http://127.0.0.1:${address.port}`; + return { + async req( + route: string, + options: { method?: string; body?: unknown; headers?: Record } = {}, + ) { + const init: RequestInit = { method: options.method ?? 'GET' }; + if (options.headers) init.headers = options.headers; + if (options.body !== undefined) { + init.headers = { ...options.headers, 'content-type': 'application/json' }; + init.body = JSON.stringify(options.body); + } + const response = await fetch(`${base}${route}`, init); + return { status: response.status, body: (await response.json()) as Record }; + }, + }; +} + +describe('parseWorkspaceCollabContext', () => { + it('accepts a well-formed team context and derives permissions/seats', () => { + expect(parseWorkspaceCollabContext(TEAM_CONTEXT)).toEqual(TEAM_CONTEXT_PARSED); + }); + + it('rejects a bad enum or a missing member id', () => { + expect(parseWorkspaceCollabContext({ ...TEAM_CONTEXT, role: 'viewer' })).toBeNull(); + expect(parseWorkspaceCollabContext({ ...TEAM_CONTEXT, lifecycleState: 'frozen' })).toBeNull(); + expect(parseWorkspaceCollabContext({ ...TEAM_CONTEXT, workspaceMemberId: '' })).toBeNull(); + }); +}); + +describe('collab context routes', () => { + it('requires an explicit workspace/member pair before any context is set', async () => { + const api = await startContextServer(); + const response = await api.req('/api/workspace/context'); + expect(response.status).toBe(400); + expect(response.body.error).toBe('WORKSPACE_CONTEXT_REQUIRED'); + }); + + it('round-trips a context set via the dev PUT for an explicit directory membership', async () => { + const api = await startContextServer({ + fetchWorkspaceDirectory: async () => ({ + ok: true, + items: [TEAM_DIRECTORY_ITEM], + }), + }); + const put = await api.req('/api/workspace/context', { method: 'PUT', body: TEAM_CONTEXT }); + expect(put.status).toBe(200); + expect(put.body).toEqual({ context: TEAM_CONTEXT_PARSED }); + expect((await api.req('/api/workspace/context', { + headers: TEAM_HEADERS, + })).body).toEqual({ context: TEAM_CONTEXT_PARSED }); + }); + + it('clears dev enrichment but retains directory-authorized exact context', async () => { + const api = await startContextServer({ + fetchWorkspaceDirectory: async () => ({ + ok: true, + items: [TEAM_DIRECTORY_ITEM], + }), + }); + await api.req('/api/workspace/context', { method: 'PUT', body: TEAM_CONTEXT }); + const cleared = await api.req('/api/workspace/context', { method: 'PUT', body: {} }); + expect(cleared.body).toEqual({ context: null }); + const exact = await api.req('/api/workspace/context', { + headers: TEAM_HEADERS, + }); + expect(exact.status).toBe(200); + expect(exact.body.context).toMatchObject({ + workspaceId: 'wm-1', + workspaceMemberId: 'wm-1', + role: 'member', + }); + }); + + it('rejects an invalid context body', async () => { + const api = await startContextServer(); + const res = await api.req('/api/workspace/context', { method: 'PUT', body: { workspaceType: 'team' } }); + expect(res.status).toBe(400); + }); + + it('requires an explicit workspace/member pair instead of borrowing daemon current state', async () => { + const api = await startContextServer({ + fetchWorkspaceDirectory: async () => ({ + ok: true, + items: [ + { + workspaceId: 'ws-a', + workspaceName: 'Workspace A', + workspaceType: 'team', + workspaceMemberId: 'wm-a', + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + }, + { + workspaceId: 'ws-b', + workspaceName: 'Workspace B', + workspaceType: 'team', + workspaceMemberId: 'wm-b', + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + }, + ], + }), + }); + await api.req('/api/workspace/context', { + method: 'PUT', + body: { + ...TEAM_CONTEXT, + workspaceId: 'ws-b', + workspaceMemberId: 'wm-b', + role: 'owner', + }, + }); + + const missing = await api.req('/api/workspace/context'); + expect(missing.status).toBe(400); + expect(missing.body.error).toBe('WORKSPACE_CONTEXT_REQUIRED'); + + const explicitA = await api.req('/api/workspace/context', { + headers: { + 'x-od-workspace-id': 'ws-a', + 'x-od-workspace-member-id': 'wm-a', + }, + }); + expect(explicitA.status).toBe(200); + expect(explicitA.body.context).toMatchObject({ + workspaceId: 'ws-a', + workspaceMemberId: 'wm-a', + role: 'member', + }); + }); + + it('fails retryably when the membership authority cannot verify an explicit context', async () => { + const api = await startContextServer({ + fetchWorkspaceDirectory: async () => ({ ok: false, items: [] }), + }); + const response = await api.req('/api/workspace/context', { + headers: { + 'x-od-workspace-id': 'ws-a', + 'x-od-workspace-member-id': 'wm-a', + }, + }); + expect(response.status).toBe(503); + expect(response.body).toMatchObject({ + error: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + retryable: true, + }); + }); + + it('keeps workspace selection request-local and does not mutate the daemon active pin', async () => { + const setActive = vi.fn(async () => {}); + const api = await startContextServer({ + activeWorkspace: { + get: () => 'ws-a', + set: setActive, + clear: async () => {}, + }, + fetchWorkspaceDirectory: async () => ({ + ok: true, + items: [{ + workspaceId: 'ws-b', + workspaceName: 'Workspace B', + workspaceType: 'team', + workspaceMemberId: 'wm-b', + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + }], + }), + }); + + const response = await api.req('/api/workspace/active', { + method: 'PUT', + body: { workspaceId: 'ws-b', workspaceMemberId: 'wm-b' }, + }); + expect(response.status).toBe(200); + expect(response.body.context).toMatchObject({ + workspaceId: 'ws-b', + workspaceMemberId: 'wm-b', + }); + expect(setActive).not.toHaveBeenCalled(); + }); +}); + +describe('workspace billing routes', () => { + const teamHeaders = (workspaceId = 'wm-1', workspaceMemberId = 'member-1') => ({ + 'x-od-workspace-id': workspaceId, + 'x-od-workspace-member-id': workspaceMemberId, + // This claim is deliberately not authority. The directory row below is. + 'x-od-workspace-role': 'owner', + }); + const teamDirectory = (workspaceId = 'wm-1') => [{ + workspaceId, + workspaceName: 'Workspace', + workspaceType: 'team' as const, + workspaceMemberId: 'member-1', + role: 'member' as const, + memberStatus: 'active' as const, + lifecycleState: 'active' as const, + }]; + + it('authorizes and atomically replaces a renderer full billing interest set', async () => { + const api = await startContextServer({ + listWorkspaceDirectory: async () => [ + ...teamDirectory('wm-1'), + { + ...teamDirectory('wm-2')[0]!, + workspaceMemberId: 'member-2', + }, + ], + }); + const declared = await api.req( + '/api/workspace/billing/interests/renderer-1', + { + method: 'PUT', + body: { + generation: '1', + interests: [ + { workspaceId: 'wm-1', workspaceMemberId: 'member-1' }, + { workspaceId: 'wm-2', workspaceMemberId: 'member-2' }, + ], + }, + }, + ); + expect(declared.status).toBe(200); + expect(declared.body).toMatchObject({ + clientId: 'renderer-1', + acceptedGeneration: '1', + }); + + const replaced = await api.req( + '/api/workspace/billing/interests/renderer-1', + { + method: 'PUT', + body: { + generation: '2', + interests: [{ workspaceId: 'wm-2', workspaceMemberId: 'member-2' }], + }, + }, + ); + expect(replaced.status).toBe(200); + expect(replaced.body.acceptedGeneration).toBe('2'); + + const released = await api.req( + '/api/workspace/billing/interests/renderer-1?generation=2', + { method: 'DELETE' }, + ); + expect(released.body).toEqual({ ok: true, released: true }); + }); + + it('rejects an interest whose exact workspace/member pair is not authorized', async () => { + const api = await startContextServer({ + listWorkspaceDirectory: async () => teamDirectory('wm-1'), + }); + const response = await api.req( + '/api/workspace/billing/interests/renderer-1', + { + method: 'PUT', + body: { + generation: '1', + interests: [{ workspaceId: 'wm-1', workspaceMemberId: 'member-other' }], + }, + }, + ); + expect(response.status).toBe(403); + expect(response.body).toEqual({ error: 'workspace_not_authorized' }); + }); + + it('does not revoke another client when a stale member declares the same workspace', async () => { + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async ({ workspaceId, workspaceMemberId }) => ({ + snapshot: null, + workspaceBalance: { + workspaceId, + workspaceMemberId, + balanceUsd: '7.89', + billingScopeVersion: 2, + expiresAt: null, + updatedAt: '2026-07-27T00:00:00Z', + }, + }), + }); + const api = await startContextServer({ + listWorkspaceDirectory: async () => teamDirectory('wm-1'), + billingRuntime: runtime, + }); + const valid = await api.req('/api/workspace/billing/interests/valid-renderer', { + method: 'PUT', + body: { + generation: '1', + interests: [{ workspaceId: 'wm-1', workspaceMemberId: 'member-1' }], + }, + }); + expect(valid.status).toBe(200); + + const stale = await api.req('/api/workspace/billing/interests/stale-renderer', { + method: 'PUT', + body: { + generation: '1', + interests: [{ workspaceId: 'wm-1', workspaceMemberId: 'member-old' }], + }, + }); + expect(stale.status).toBe(403); + expect(runtime.interestedKeys()).toEqual([ + { workspaceId: 'wm-1', workspaceMemberId: 'member-1' }, + ]); + runtime.dispose(); + }); + + // recvqgaMLxEdZX: the URL workspace id is the selection source. Membership + // authorization comes from the independently fetched directory, not from + // daemon-global active/current state: two clients may address different + // workspaces through the same daemon without switching each other. + it('returns the explicit backend-scoped balance even when current points elsewhere', async () => { + const accountCalls: string[] = []; + const workspaceCalls: string[] = []; + const api = await startContextServer({ + listWorkspaceDirectory: async () => teamDirectory('wm-1'), + fetchBilling: async () => { + accountCalls.push('account'); + return { + workspaceId: null, + membershipTier: 'team_plus', + totalAvailableCredits: 1_386_294, + subscriptionCredits: 1_000_000, + rechargeCredits: 386_294, + balanceUsd: '13.86', + subscriptionStatus: 'active', + availableActions: ['billing_portal'], + workspaceBalance: null, + }; + }, + fetchWorkspaceBalance: async (workspaceId) => { + workspaceCalls.push(workspaceId); + return { + workspaceId: 'wm-1', + workspaceMemberId: 'member-1', + balanceUsd: '7.89', + billingScopeVersion: 2, + expiresAt: null, + updatedAt: '2026-07-26T12:00:00Z', + }; + }, + }); + await api.req('/api/workspace/context', { + method: 'PUT', + body: { ...TEAM_CONTEXT, workspaceMemberId: 'wm-other' }, + }); + + const res = await api.req('/api/workspace/billing?scope=workspace&workspaceId=wm-1'); + + expect(res.status).toBe(200); + expect(accountCalls).toEqual(['account']); + expect(workspaceCalls).toEqual(['wm-1']); + expect(res.body.summary).toMatchObject({ + workspaceId: null, + membershipTier: 'team_plus', + workspaceBalance: null, + }); + expect(res.body.workspaceBalance).toMatchObject({ + workspaceId: 'wm-1', + balanceUsd: '7.89', + billingScopeVersion: 2, + }); + }); + + it('returns an authorized atomic workspace plan and wallet snapshot additively', async () => { + const api = await startContextServer({ + listWorkspaceDirectory: async () => teamDirectory('wm-1'), + fetchBilling: async () => null, + fetchWorkspaceBillingProjection: async () => ({ + snapshot: { + schemaVersion: 1, + workspaceId: 'wm-1', + workspaceMemberId: 'member-1', + billingScopeVersion: 2, + billing: { billingState: 'active', planId: 'team_plus' }, + wallet: { + balanceUsd: '7.89', + expiresAt: null, + updatedAt: '2026-07-27T00:00:00Z', + }, + revisions: { billing: 'billing-2', wallet: 'wallet-2' }, + }, + workspaceBalance: { + workspaceId: 'wm-1', + workspaceMemberId: 'member-1', + balanceUsd: '7.89', + billingScopeVersion: 2, + expiresAt: null, + updatedAt: '2026-07-27T00:00:00Z', + }, + }), + }); + + const res = await api.req('/api/workspace/billing?scope=workspace&workspaceId=wm-1'); + + expect(res.status).toBe(200); + expect(res.body.workspaceSnapshot).toMatchObject({ + workspaceId: 'wm-1', + workspaceMemberId: 'member-1', + billing: { billingState: 'active', planId: 'team_plus' }, + revisions: { billing: 'billing-2', wallet: 'wallet-2' }, + }); + expect(res.body.workspaceBalance).toMatchObject({ + workspaceId: 'wm-1', + workspaceMemberId: 'member-1', + balanceUsd: '7.89', + }); + }); + + it('single-flights simultaneous exact-scope billing reads in the daemon', async () => { + let projectionCalls = 0; + let releaseProjection!: () => void; + const projectionGate = new Promise((resolve) => { + releaseProjection = resolve; + }); + const api = await startContextServer({ + listWorkspaceDirectory: async () => teamDirectory('wm-1'), + fetchBilling: async () => null, + fetchWorkspaceBillingProjection: async () => { + projectionCalls += 1; + await projectionGate; + return { + snapshot: { + schemaVersion: 1, + workspaceId: 'wm-1', + workspaceMemberId: 'member-1', + billingScopeVersion: 2, + billing: { billingState: 'active', planId: 'team_plus' }, + wallet: { + balanceUsd: '7.89', + expiresAt: null, + updatedAt: '2026-07-27T00:00:00Z', + }, + revisions: { billing: 'billing-2', wallet: 'wallet-2' }, + }, + workspaceBalance: { + workspaceId: 'wm-1', + workspaceMemberId: 'member-1', + balanceUsd: '7.89', + billingScopeVersion: 2, + expiresAt: null, + updatedAt: '2026-07-27T00:00:00Z', + }, + }; + }, + }); + + const first = api.req('/api/workspace/billing?scope=workspace&workspaceId=wm-1'); + const second = api.req('/api/workspace/billing?scope=workspace&workspaceId=wm-1'); + await vi.waitFor(() => expect(projectionCalls).toBeGreaterThan(0)); + try { + expect(projectionCalls).toBe(1); + } finally { + releaseProjection(); + } + + const [firstResponse, secondResponse] = await Promise.all([first, second]); + expect(firstResponse.status).toBe(200); + expect(secondResponse.status).toBe(200); + expect(projectionCalls).toBe(1); + expect(firstResponse.body.workspaceRuntime).toMatchObject({ + workspaceId: 'wm-1', + workspaceMemberId: 'member-1', + status: 'fresh', + revision: '2', + }); + expect(secondResponse.body.workspaceRuntime).toEqual(firstResponse.body.workspaceRuntime); + }); + + it('clears daemon state and returns 403 when membership disappears', async () => { + let authorized = true; + const api = await startContextServer({ + listWorkspaceDirectory: async () => authorized ? teamDirectory('wm-1') : [], + fetchBilling: async () => null, + fetchWorkspaceBillingProjection: async () => ({ + snapshot: null, + workspaceBalance: { + workspaceId: 'wm-1', + workspaceMemberId: 'member-1', + balanceUsd: '7.89', + billingScopeVersion: 2, + expiresAt: null, + updatedAt: '2026-07-27T00:00:00Z', + }, + }), + }); + const headers = { + 'x-od-workspace-runtime-client-id': 'window-1', + 'x-od-workspace-runtime-generation': '1', + }; + + const initial = await api.req( + '/api/workspace/billing?scope=workspace&workspaceId=wm-1', + { headers }, + ); + expect(initial.body.workspaceBalance.balanceUsd).toBe('7.89'); + authorized = false; + + const revoked = await api.req( + '/api/workspace/billing?scope=workspace&workspaceId=wm-1', + { headers }, + ); + expect(revoked.status).toBe(403); + expect(revoked.body).toEqual({ error: 'workspace_not_authorized' }); + }); + + it('retains internal last-good state across a transient directory outage', async () => { + let directoryAvailable = true; + let balance = '7.89'; + const api = await startContextServer({ + fetchWorkspaceDirectory: async () => ({ + ok: directoryAvailable, + items: directoryAvailable ? teamDirectory('wm-1') : [], + }), + fetchBilling: async () => null, + fetchWorkspaceBillingProjection: async () => ({ + snapshot: null, + workspaceBalance: { + workspaceId: 'wm-1', + workspaceMemberId: 'member-1', + balanceUsd: balance, + billingScopeVersion: 2, + expiresAt: null, + updatedAt: '2026-07-27T00:00:00Z', + }, + }), + }); + const request = (generation: string) => + api.req('/api/workspace/billing?scope=workspace&workspaceId=wm-1', { + headers: { + 'x-od-workspace-runtime-client-id': 'window-1', + 'x-od-workspace-runtime-generation': generation, + }, + }); + + expect((await request('1')).body.workspaceBalance.balanceUsd).toBe('7.89'); + directoryAvailable = false; + const unavailable = await request('1'); + expect(unavailable.status).toBe(503); + expect(unavailable.body).toEqual({ error: 'workspace_directory_unavailable' }); + + directoryAvailable = true; + balance = '8.99'; + const recovered = await request('2'); + expect(recovered.body).toMatchObject({ + workspaceBalance: { balanceUsd: '8.99' }, + workspaceRuntime: { status: 'fresh' }, + }); + }); + + it('returns 503 instead of last-good money when an authoritative action catch-up fails', async () => { + let calls = 0; + const api = await startContextServer({ + listWorkspaceDirectory: async () => teamDirectory('wm-1'), + fetchBilling: async () => null, + fetchWorkspaceBillingProjection: async () => { + calls += 1; + if (calls === 1) { + return { + snapshot: null, + workspaceBalance: { + workspaceId: 'wm-1', + workspaceMemberId: 'member-1', + balanceUsd: '7.89', + billingScopeVersion: 2, + expiresAt: null, + updatedAt: '2026-07-27T00:00:00Z', + }, + }; + } + throw Object.assign(new Error('upstream unavailable'), { code: 'temporary' }); + }, + }); + + expect((await api.req( + '/api/workspace/billing?scope=workspace&workspaceId=wm-1', + )).status).toBe(200); + const action = await api.req( + '/api/workspace/billing?scope=workspace&workspaceId=wm-1&freshness=authoritative', + ); + expect(action.status).toBe(503); + expect(action.body).toEqual({ error: 'temporary' }); + expect(calls).toBe(2); + }); + + it('marks a successful action read with exact authoritative workspace proof', async () => { + const api = await startContextServer({ + listWorkspaceDirectory: async () => teamDirectory('wm-1'), + fetchBilling: async () => null, + fetchWorkspaceBillingProjection: async () => ({ + snapshot: null, + workspaceBalance: { + workspaceId: 'wm-1', + workspaceMemberId: 'member-1', + balanceUsd: '7.89', + billingScopeVersion: 2, + expiresAt: null, + updatedAt: '2026-07-27T00:00:00Z', + }, + }), + }); + + const action = await api.req( + '/api/workspace/billing?scope=workspace&workspaceId=wm-1&freshness=authoritative', + ); + expect(action.status).toBe(200); + expect(action.body.workspaceRuntime).toMatchObject({ + workspaceId: 'wm-1', + workspaceMemberId: 'member-1', + status: 'fresh', + }); + expect(action.body.authoritativeWorkspaceRead).toEqual({ + workspaceId: 'wm-1', + workspaceMemberId: 'member-1', + observedAt: action.body.workspaceRuntime.observedAt, + }); + }); + + it('reads the account summary explicitly without requesting a workspace balance', async () => { + const accountCalls: string[] = []; + const workspaceCalls: string[] = []; + const api = await startContextServer({ + fetchBilling: async () => { + accountCalls.push('account'); + return { + workspaceId: null, + membershipTier: '', + totalAvailableCredits: 0, + subscriptionCredits: 0, + rechargeCredits: 0, + balanceUsd: '0', + subscriptionStatus: '', + availableActions: [], + workspaceBalance: null, + }; + }, + fetchWorkspaceBalance: async (workspaceId) => { + workspaceCalls.push(workspaceId); + return null; + }, + }); + + const res = await api.req('/api/workspace/billing?scope=account'); + + expect(res.status).toBe(200); + expect(accountCalls).toEqual(['account']); + expect(workspaceCalls).toEqual([]); + expect(res.body.summary).toMatchObject({ workspaceId: null, workspaceBalance: null }); + expect(res.body.workspaceBalance).toBeNull(); + }); + + it('fails closed when the explicit workspace is absent from the membership directory', async () => { + const calls: string[] = []; + const api = await startContextServer({ + listWorkspaceDirectory: async () => teamDirectory('wm-1'), + fetchBilling: async () => { + calls.push('account'); + return null; + }, + fetchWorkspaceBalance: async (workspaceId) => { + calls.push(workspaceId); + return null; + }, + }); + await api.req('/api/workspace/context', { method: 'PUT', body: TEAM_CONTEXT }); + + const res = await api.req('/api/workspace/billing?scope=workspace&workspaceId=other'); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'workspace_not_authorized' }); + expect(calls).toEqual([]); + }); + + it('rejects missing or contradictory billing scope parameters', async () => { + const api = await startContextServer(); + expect((await api.req('/api/workspace/billing')).status).toBe(400); + expect((await api.req('/api/workspace/billing?scope=workspace')).status).toBe(400); + expect( + (await api.req('/api/workspace/billing?scope=account&workspaceId=wm-1')).status, + ).toBe(400); + }); + + it('keeps account metadata separate when the scoped balance is unavailable', async () => { + const api = await startContextServer({ + listWorkspaceDirectory: async () => teamDirectory(), + fetchBilling: async () => ({ + workspaceId: null, + membershipTier: 'team_plus', + totalAvailableCredits: 10, + subscriptionCredits: 10, + rechargeCredits: 0, + balanceUsd: '999.00', + subscriptionStatus: 'active', + availableActions: [], + workspaceBalance: null, + }), + fetchWorkspaceBalance: async () => null, + }); + await api.req('/api/workspace/context', { method: 'PUT', body: TEAM_CONTEXT }); + + const res = await api.req('/api/workspace/billing?scope=workspace&workspaceId=wm-1'); + + expect(res.status).toBe(200); + expect(res.body.summary).toMatchObject({ + membershipTier: 'team_plus', + balanceUsd: '999.00', + workspaceBalance: null, + }); + expect(res.body.workspaceBalance).toBeNull(); + }); + + it('preserves a proven workspace balance when the account summary is unavailable', async () => { + const api = await startContextServer({ + listWorkspaceDirectory: async () => teamDirectory(), + fetchBilling: async () => null, + fetchWorkspaceBalance: async () => ({ + workspaceId: 'wm-1', + workspaceMemberId: 'member-1', + balanceUsd: '7.89', + billingScopeVersion: 2, + expiresAt: null, + updatedAt: null, + }), + }); + + const res = await api.req('/api/workspace/billing?scope=workspace&workspaceId=wm-1'); + + expect(res.status).toBe(200); + expect(res.body.summary).toBeNull(); + expect(res.body.workspaceBalance).toMatchObject({ + workspaceId: 'wm-1', + balanceUsd: '7.89', + billingScopeVersion: 2, + }); + }); + + it('rejects a workspace wallet issued to a different directory membership', async () => { + const api = await startContextServer({ + listWorkspaceDirectory: async () => teamDirectory(), + fetchBilling: async () => null, + fetchWorkspaceBalance: async () => ({ + workspaceId: 'wm-1', + workspaceMemberId: 'different-member', + balanceUsd: '7.89', + billingScopeVersion: 2, + expiresAt: null, + updatedAt: null, + }), + }); + + const res = await api.req('/api/workspace/billing?scope=workspace&workspaceId=wm-1'); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + summary: null, + workspaceBalance: null, + workspaceRuntime: { + workspaceId: 'wm-1', + workspaceMemberId: 'member-1', + status: 'error', + errorCode: 'workspace_billing_scope_mismatch', + }, + }); + }); + + it('rejects billing catalog and checkout without an explicit verified workspace', async () => { + const fetchBillingCatalog = vi.fn(async () => null); + const startCheckout = vi.fn(async () => null); + const api = await startContextServer({ + listWorkspaceDirectory: async () => teamDirectory(), + fetchBillingCatalog, + startCheckout, + }); + await api.req('/api/workspace/context', { method: 'PUT', body: TEAM_CONTEXT }); + + const catalog = await api.req('/api/workspace/billing/catalog'); + const checkout = await api.req('/api/workspace/billing/checkout', { + method: 'POST', + body: { planId: 'team_pro', seats: 3 }, + }); + + expect(catalog.status).toBe(400); + expect(catalog.body.error).toBe('WORKSPACE_CONTEXT_REQUIRED'); + expect(checkout.status).toBe(400); + expect(checkout.body.error).toBe('WORKSPACE_CONTEXT_REQUIRED'); + expect(fetchBillingCatalog).not.toHaveBeenCalled(); + expect(startCheckout).not.toHaveBeenCalled(); + }); + + it('rejects billing catalog and checkout when the claimed membership is not in the directory', async () => { + const fetchBillingCatalog = vi.fn(async () => null); + const startCheckout = vi.fn(async () => null); + const api = await startContextServer({ + listWorkspaceDirectory: async () => teamDirectory('wm-1'), + fetchBillingCatalog, + startCheckout, + }); + const spoofedHeaders = teamHeaders('wm-2', 'attacker-member'); + + const catalog = await api.req('/api/workspace/billing/catalog', { + headers: spoofedHeaders, + }); + const checkout = await api.req('/api/workspace/billing/checkout', { + method: 'POST', + headers: spoofedHeaders, + body: { planId: 'team_pro', seats: 3 }, + }); + + expect(catalog.status).toBe(403); + expect(catalog.body.error).toBe('WORKSPACE_ACCESS_DENIED'); + expect(checkout.status).toBe(403); + expect(checkout.body.error).toBe('WORKSPACE_ACCESS_DENIED'); + expect(fetchBillingCatalog).not.toHaveBeenCalled(); + expect(startCheckout).not.toHaveBeenCalled(); + }); + + it('returns the real team billing catalog for the directory-verified workspace', async () => { + const calls: string[] = []; + const api = await startContextServer({ + listWorkspaceDirectory: async () => teamDirectory(), + fetchBillingCatalog: async (workspaceId) => { + calls.push(workspaceId); + return { + workspaceId, + billingInterval: 'monthly', + plans: [ + { + planId: 'team_plus', + seatUnitAmountCents: 3900, + currency: 'usd', + minSeats: 1, + status: 'active', + }, + ], + }; + }, + }); + await api.req('/api/workspace/context', { method: 'PUT', body: TEAM_CONTEXT }); + + const res = await api.req('/api/workspace/billing/catalog', { + headers: teamHeaders(), + }); + + expect(res.status).toBe(200); + expect(calls).toEqual(['wm-1']); + expect(res.body).toEqual({ + catalog: { + workspaceId: 'wm-1', + billingInterval: 'monthly', + plans: [ + { + planId: 'team_plus', + seatUnitAmountCents: 3900, + currency: 'usd', + minSeats: 1, + status: 'active', + }, + ], + }, + }); + }); + + it('starts checkout with directory-derived id and ignores spoofed body and role authority', async () => { + const calls: Array<{ workspaceId?: string; planId?: string; seats?: number }> = []; + const api = await startContextServer({ + listWorkspaceDirectory: async () => teamDirectory(), + startCheckout: async (input) => { + calls.push(input); + return 'https://checkout.stripe.test/cs_team'; + }, + }); + await api.req('/api/workspace/context', { method: 'PUT', body: TEAM_CONTEXT }); + + const res = await api.req('/api/workspace/billing/checkout', { + method: 'POST', + headers: teamHeaders(), + body: { workspaceId: 'spoofed', planId: 'team_pro', seats: 3 }, + }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ checkoutUrl: 'https://checkout.stripe.test/cs_team' }); + expect(calls).toEqual([{ workspaceId: 'wm-1', planId: 'team_pro', seats: 3 }]); + }); + + it('keeps checkout pinned to the verified workspace while active workspace switches', async () => { + let releaseCheckout!: () => void; + const checkoutStarted = new Promise((resolve) => { + releaseCheckout = resolve; + }); + let observeCheckout!: (input: { workspaceId?: string }) => void; + const observedCheckout = new Promise<{ workspaceId?: string }>((resolve) => { + observeCheckout = resolve; + }); + const api = await startContextServer({ + listWorkspaceDirectory: async () => [ + ...teamDirectory('wm-1'), + { + ...teamDirectory('wm-2')[0]!, + workspaceMemberId: 'member-2', + }, + ], + startCheckout: async (input) => { + observeCheckout(input); + await checkoutStarted; + return 'https://checkout.stripe.test/cs_team'; + }, + }); + await api.req('/api/workspace/context', { method: 'PUT', body: TEAM_CONTEXT }); + + const checkoutPromise = api.req('/api/workspace/billing/checkout', { + method: 'POST', + headers: teamHeaders('wm-1', 'member-1'), + body: { planId: 'team_plus' }, + }); + const captured = await observedCheckout; + await api.req('/api/workspace/context', { + method: 'PUT', + body: { + ...TEAM_CONTEXT, + workspaceId: 'wm-2', + workspaceMemberId: 'member-2', + }, + }); + releaseCheckout(); + const response = await checkoutPromise; + + expect(response.status).toBe(200); + expect(captured.workspaceId).toBe('wm-1'); + expect(response.body.checkoutUrl).toBe('https://checkout.stripe.test/cs_team'); + }); +}); + +describe('POST /api/workspace/invite', () => { + const headers = { + 'x-od-workspace-id': 'wm-1', + 'x-od-workspace-member-id': 'wm-1', + 'x-od-workspace-role': 'owner', + }; + const directory = (role: 'admin' | 'member' = 'admin') => ({ + ok: true, + items: [{ + workspaceId: 'wm-1', + workspaceName: 'Team One', + workspaceType: 'team' as const, + workspaceMemberId: 'wm-1', + role, + memberStatus: 'active' as const, + lifecycleState: 'active' as const, + }], + }); + + it('creates each invite against the verified workspaceId and reports per-row results', async () => { + const calls: Array<{ email: string; role: string; workspaceId: string }> = []; + const api = await startContextServer({ + fetchWorkspaceDirectory: async () => directory('admin'), + createInvite: async (input) => { + calls.push(input); + return { ok: true, inviteId: `inv-${input.email}` }; + }, + }); + const res = await api.req('/api/workspace/invite', { + method: 'POST', + headers, + body: { invites: [{ email: 'a@x.com', role: 'admin' }, { email: 'b@x.com', role: 'member' }] }, + }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + results: [ + { email: 'a@x.com', ok: true, inviteId: 'inv-a@x.com' }, + { email: 'b@x.com', ok: true, inviteId: 'inv-b@x.com' }, + ], + }); + expect(calls).toEqual([ + { email: 'a@x.com', role: 'admin', workspaceId: 'wm-1' }, + { email: 'b@x.com', role: 'member', workspaceId: 'wm-1' }, + ]); + }); + + it('400s an empty invite list', async () => { + const api = await startContextServer(); + const res = await api.req('/api/workspace/invite', { method: 'POST', body: { invites: [] } }); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'missing_invites' }); + }); + + it('400s when no explicit workspace identity is provided', async () => { + const api = await startContextServer({ + createInvite: async () => ({ ok: true, inviteId: 'inv-x' }), + }); + const res = await api.req('/api/workspace/invite', { + method: 'POST', + body: { invites: [{ email: 'a@x.com', role: 'member' }] }, + }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('WORKSPACE_CONTEXT_REQUIRED'); + }); + + it('403s when the verified team member cannot invite teammates', async () => { + let called = false; + const api = await startContextServer({ + fetchWorkspaceDirectory: async () => directory('member'), + createInvite: async () => { + called = true; + return { ok: true, inviteId: 'inv-x' }; + }, + }); + + const res = await api.req('/api/workspace/invite', { + method: 'POST', + headers, + body: { invites: [{ email: 'a@x.com', role: 'member' }] }, + }); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: 'forbidden' }); + expect(called).toBe(false); + }); + + it('short-circuits to 401 no_session', async () => { + const api = await startContextServer({ + fetchWorkspaceDirectory: async () => directory('admin'), + createInvite: async () => ({ ok: false, status: 401, error: 'no_session' }), + }); + const res = await api.req('/api/workspace/invite', { + method: 'POST', + headers, + body: { invites: [{ email: 'a@x.com', role: 'member' }] }, + }); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: 'no_session' }); + }); + + it("degrades a failed B create (e.g. 404) to an ok:false result, HTTP 200", async () => { + const api = await startContextServer({ + fetchWorkspaceDirectory: async () => directory('admin'), + createInvite: async () => ({ ok: false, status: 404, error: 'create_404' }), + }); + const res = await api.req('/api/workspace/invite', { + method: 'POST', + headers, + body: { invites: [{ email: 'a@x.com', role: 'member' }] }, + }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ results: [{ email: 'a@x.com', ok: false, error: 'create_404' }] }); + }); +}); + +describe('POST /api/workspace/invite/continue', () => { + it('refreshes membership authority before returning a consumed continuation', async () => { + const refreshWorkspaceDirectoryAfterMutation = vi.fn(async () => ({ + ok: true as const, + items: [TEAM_DIRECTORY_ITEM], + })); + const api = await startContextServer({ + consumeInvite: async () => ({ + ok: true, + context: TEAM_CONTEXT_PARSED, + workspaceMemberId: 'wm-1', + }), + refreshWorkspaceDirectoryAfterMutation, + }); + + const response = await api.req('/api/workspace/invite/continue', { + method: 'POST', + body: { nonce: 'nonce-1' }, + }); + + expect(response.status).toBe(200); + expect(refreshWorkspaceDirectoryAfterMutation).toHaveBeenCalledOnce(); + expect(response.body).toEqual({ + context: TEAM_CONTEXT_PARSED, + workspaceMemberId: 'wm-1', + }); + }); + + it('does not reverse a consumed continuation when authority refresh is unavailable', async () => { + const api = await startContextServer({ + consumeInvite: async () => ({ + ok: true, + context: TEAM_CONTEXT_PARSED, + workspaceMemberId: 'wm-1', + }), + refreshWorkspaceDirectoryAfterMutation: async () => { + throw new Error('directory unavailable'); + }, + }); + + const response = await api.req('/api/workspace/invite/continue', { + method: 'POST', + body: { nonce: 'nonce-1' }, + }); + + expect(response.status).toBe(200); + expect(response.body.workspaceMemberId).toBe('wm-1'); + }); +}); + +describe('GET /api/workspace/members', () => { + it('passes the directory-verified Workspace context to the member service', async () => { + const contexts: unknown[] = []; + const api = await startContextServer({ + fetchWorkspaceDirectory: async () => ({ + ok: true, + items: [{ + workspaceId: 'team-a', + workspaceName: 'Team A', + workspaceType: 'team', + workspaceMemberId: 'member-a', + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + }], + }), + listMembers: async (context) => { + contexts.push(context); + return [{ memberId: 'member-a', displayName: 'A', role: 'member' }]; + }, + }); + + const response = await api.req('/api/workspace/members', { + headers: { + 'x-od-workspace-id': 'team-a', + 'x-od-workspace-member-id': 'member-a', + 'x-od-workspace-role': 'owner', + }, + }); + + expect(response.status).toBe(200); + expect(response.body.members).toEqual([ + { memberId: 'member-a', displayName: 'A', role: 'member' }, + ]); + expect(contexts).toHaveLength(1); + expect(contexts[0]).toMatchObject({ + workspaceId: 'team-a', + workspaceMemberId: 'member-a', + role: 'member', + }); + }); +}); diff --git a/apps/daemon/tests/collab-first-open-materializing.test.ts b/apps/daemon/tests/collab-first-open-materializing.test.ts new file mode 100644 index 00000000000..b0a3b9d3e34 --- /dev/null +++ b/apps/daemon/tests/collab-first-open-materializing.test.ts @@ -0,0 +1,219 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import express from 'express'; +import http from 'node:http'; +import { + buildWorkspacePermissions, + buildWorkspaceSeatSummary, + type WorkspaceCollabContext, +} from '@open-design/contracts'; +import { createCollabRuntime } from '../src/collab/runtime.js'; +import type { WorkspaceContextProvider } from '../src/collab/workspace-context.js'; +import { + SHARED_PROJECT_PLACEHOLDER_METADATA_KEY, + isUnmaterializedSharedPlaceholder, +} from '../src/collab/shared-project-placeholder.js'; +import { + registerCollabSyncRoutes, + type PulledProjectStore, + type TeamMirrorPullScope, +} from '../src/routes/collab-sync.js'; + +// Red spec for the QA P0 "first open of a shared project shows nothing" +// report: a brand-new member on a fresh install joins someone's workspace, +// opens a project from it, sees NO loading state and no files — and only a +// SECOND open (minutes later) shows the downloaded content. +// +// The whole first-open handshake is one `/collab/status` request, and on a +// fresh data root it answers with: +// +// { publishedVersion: null, materializedVersion: null, +// contentTransferState: null, syncState: 'synced', ownerMemberId: } +// +// `publishedVersion` is null because `collab.publishedVersion()` is an +// in-process map that a fresh daemon has never written, and the real hub head +// is fetched fire-and-forget into `headEnrichmentCache` for a LATER poll to +// consume (routes/collab-sync.ts, the `needsHubHead` block). So the very first +// response carries no evidence at all that content is on its way — which is +// exactly what the web needs: `useProjectCollab.downloadPending` is gated on +// `publishedVersion > cursor`, so it computes false and DesignFilesPanel +// renders `design-files-empty` (with "create a new sketch" CTAs) instead of +// `design-files-syncing`. Nothing else on the first request starts a pull +// either: the daemon's self-materialization block is gated on `callerIsOwner`, +// and the web's auto-pull is gated on the same missing `publishedVersion`. +// +// The invariant under test: a viewer opening a shared project whose only local +// record is an unmaterialized placeholder (`sharedProjectPlaceholderAt`, see +// collab/shared-project-placeholder.ts) must be TOLD its local file list is +// not the project's content, and the pull that fixes that must start on this +// same request — never be deferred to a later poll or a background +// reconciler. + +let server: http.Server | null = null; + +afterEach(async () => { + if (server) { + const toClose = server; + server = null; + await new Promise((resolve) => toClose.close(() => resolve())); + } +}); + +function memberContextProvider(workspaceMemberId: string): WorkspaceContextProvider { + const context: WorkspaceCollabContext = { + workspaceId: 'ws-1', + workspaceType: 'team', + teamId: 'team-1', + workspaceMemberId, + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + billingState: 'active', + planId: null, + providerMode: 'platform_credits', + seatSummary: buildWorkspaceSeatSummary({ seatLimit: 5, usedSeats: 2 }), + permissions: buildWorkspacePermissions({ role: 'member', lifecycleState: 'active' }), + }; + return { current: async () => context }; +} + +/** + * The exact local state of a fresh install: no projects at all. The status + * route's `ensureSharedProjectPlaceholder` registers the placeholder row on + * the first open and `markSharedProjectPlaceholder` stamps it — this fake + * models both, so the test exercises the real first-open sequence rather than + * a pre-seeded one. + */ +function freshInstallProjectStore() { + const rows = new Map(); + const store: PulledProjectStore = { + get: (projectId) => rows.get(projectId) ?? null, + has: (projectId) => rows.has(projectId), + register: (input) => { + rows.set(input.id, { name: input.name ?? null, metadata: rows.get(input.id)?.metadata }); + }, + }; + const markSharedProjectPlaceholder = (projectId: string, placeholder: boolean) => { + const row = rows.get(projectId); + if (!row) return; + const metadata = { ...((row.metadata as Record) ?? {}) }; + if (placeholder) metadata[SHARED_PROJECT_PLACEHOLDER_METADATA_KEY] = Date.now(); + else delete metadata[SHARED_PROJECT_PLACEHOLDER_METADATA_KEY]; + rows.set(projectId, { ...row, metadata }); + }; + return { rows, store, markSharedProjectPlaceholder }; +} + +async function startFirstOpenDaemon(options: { + beginContentTransfer?: ( + projectId: string, + scope: TeamMirrorPullScope, + version?: number, + ) => { id: string }; +}) { + const { rows, store, markSharedProjectPlaceholder } = freshInstallProjectStore(); + const workspaceContext = memberContextProvider('viewer-member'); + const context = await workspaceContext.current({}); + if (!context) throw new Error('test workspace context missing'); + const runtime = createCollabRuntime({ + workspaceContext, + }); + const app = express(); + app.use(express.json()); + registerCollabSyncRoutes(app, { + collab: runtime, + verifyWorkspaceRequest: async (req) => + req.header('x-od-workspace-id') === context.workspaceId + && req.header('x-od-workspace-member-id') === context.workspaceMemberId + ? context + : null, + verifyWorkspaceScope: async (scope) => + context.workspaceType === 'team' + && scope.workspaceId === context.workspaceId + && scope.resourceTeamId === context.teamId + && scope.viewerMemberId === context.workspaceMemberId, + // The hub catalog lists the project and names SOMEONE ELSE as its owner — + // the member case QA reported. + resolveSharedProjectOwner: async () => 'owner-1', + projectStore: store, + markSharedProjectPlaceholder, + ...(options.beginContentTransfer + ? { beginContentTransfer: options.beginContentTransfer as never } + : {}), + }); + server = http.createServer(app); + await new Promise((resolve) => server!.listen(0, resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('no port'); + return { + base: `http://127.0.0.1:${address.port}`, + rows, + headers: { + 'x-od-workspace-id': context.workspaceId, + 'x-od-workspace-member-id': context.workspaceMemberId, + }, + }; +} + +describe('first open of an unmaterialized shared project (QA P0: no loading state, nothing downloads)', () => { + it('tells the client on the FIRST status response that local files are not the project content yet', async () => { + const { base, rows, headers } = await startFirstOpenDaemon({}); + + const res = await fetch(`${base}/api/projects/shared-from-owner/collab/status`, { headers }); + const body = (await res.json()) as Record; + + expect(res.status).toBe(200); + // Preconditions: this IS the fresh-install first open. The route registered + // the placeholder, and the local record proves the content is missing. + expect(isUnmaterializedSharedPlaceholder(rows.get('shared-from-owner'))).toBe(true); + expect(body.ownerMemberId).toBe('owner-1'); + expect(body.syncState).toBe('synced'); + // …and every field the web currently reasons about is blank, so it cannot + // distinguish "empty project" from "content still downloading". + expect(body.publishedVersion).toBeNull(); + expect(body.materializedVersion).toBeNull(); + expect(body.contentTransferState ?? null).toBeNull(); + + // The signal that has to exist: local files are provably not the content. + expect(body.awaitingFirstMaterialization).toBe(true); + }); + + it('starts the content pull on that same first open instead of waiting for a later poll', async () => { + const beginContentTransfer = vi.fn(() => ({ id: 'transfer-1' })); + const { base, headers } = await startFirstOpenDaemon({ beginContentTransfer }); + + const res = await fetch(`${base}/api/projects/shared-from-owner/collab/status`, { headers }); + expect(res.status).toBe(200); + await res.json(); + + // `beginContentTransfer` is what `pullSharedProjectCoalesced` calls the + // moment an exact-scope pull is admitted — i.e. the observable proof that + // opening the project actually kicked a materialization. + await vi.waitFor(() => { + expect(beginContentTransfer).toHaveBeenCalled(); + }); + const [projectId, scope] = beginContentTransfer.mock.calls[0] as unknown as [ + string, + TeamMirrorPullScope, + ]; + expect(projectId).toBe('shared-from-owner'); + expect(scope).toMatchObject({ + workspaceId: 'ws-1', + resourceTeamId: 'team-1', + viewerMemberId: 'viewer-member', + ownerMemberId: 'owner-1', + }); + }); + + it('stops reporting the awaiting state once the placeholder is materialized', async () => { + const { base, rows, headers } = await startFirstOpenDaemon({}); + + await fetch(`${base}/api/projects/shared-from-owner/collab/status`, { headers }); + // What a landed pull does: replace the row and drop the stamp. + rows.set('shared-from-owner', { name: 'Real project', metadata: {} }); + + const res = await fetch(`${base}/api/projects/shared-from-owner/collab/status`, { headers }); + const body = (await res.json()) as Record; + expect(res.status).toBe(200); + expect(body.awaitingFirstMaterialization).toBe(false); + }); +}); diff --git a/apps/daemon/tests/collab-fresh-install-placeholder-guard.test.ts b/apps/daemon/tests/collab-fresh-install-placeholder-guard.test.ts new file mode 100644 index 00000000000..2bca6781ae1 --- /dev/null +++ b/apps/daemon/tests/collab-fresh-install-placeholder-guard.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createCollabPublishWatcher } from '../src/collab/collab-publish-watcher.js'; +import { createCollabRuntime } from '../src/collab/runtime.js'; +import { + isUnmaterializedSharedPlaceholder, + SHARED_PROJECT_PLACEHOLDER_METADATA_KEY, +} from '../src/collab/shared-project-placeholder.js'; +import { createShouldPublish } from '../src/collab/should-publish.js'; +import type { ResourceHubPrincipal } from '../src/collab/resource-principal.js'; + +// Red spec for 飞书 recvqzaDvUU6B3 — "uninstall wiped local files; after +// reinstall every shared project got emptied for the whole team". +// +// Reproduced live (feature-test hub, 2026-07-27): a daemon started on a FRESH +// data root, signed in as the same owner, registers a local "共享项目" +// placeholder row the moment the owner opens their own hub-shared project +// (`ensureSharedProjectPlaceholder`, routes/collab-sync.ts). The collab +// publish watcher then discovered that placeholder as a local project, the +// owner check passed (hub says owner === me), and the watcher's +// initial-publish-on-first-watch pushed the EMPTY placeholder directory to +// the resource hub as a brand-new published version — wiping the project's +// content (manifestDigest sha256:e3b0c442… = the empty tree) and its catalog +// display name for every team member, one project after another. +// +// The invariant under test (collab/shared-project-placeholder.ts): a local +// record still carrying the `sharedProjectPlaceholderAt` stamp is not content +// authority. It must never be watched for publish, and no scheduler-driven +// publish may run for it, until a pull materializes real hub content. +// +// Red evidence: before the guard existed, the first test failed on HEAD +// (origin/feat/workspace-team @ 09697edf3) with the watcher subscribing to +// `reinstalled-shared-project` and firing its initial publish. + +const ACTIVE_OWNER_PRINCIPAL: ResourceHubPrincipal = { + teamId: 't1', + memberId: 'owner-1', + role: 'owner', + lifecycleState: 'active', + workspaceType: 'team', +}; + +/** The exact local record shape `ensureSharedProjectPlaceholder` + + * `markSharedProjectPlaceholder` leave behind on a wiped data root. */ +function placeholderRecord(now = Date.now()) { + return { + name: '共享项目', + metadata: { [SHARED_PROJECT_PLACEHOLDER_METADATA_KEY]: now }, + }; +} + +describe('fresh-install shared-project placeholder must never publish (recvqzaDvUU6B3)', () => { + it('does not attach a watcher or fire the initial publish for an unmaterialized placeholder the hub says I own', async () => { + // The exact fresh-install state: the only local record for the project is + // the placeholder `ensureSharedProjectPlaceholder` registers when the + // owner opens their own shared project on a wiped data root. There is no + // local content — publishing this state destroys the hub copy. + const store = new Map([ + ['reinstalled-shared-project', placeholderRecord()], + ]); + const notifyChanged = vi.fn(); + const subscribed: string[] = []; + + const shouldPublish = createShouldPublish({ + // The hub's catalog still lists the project and names this daemon's + // member as its owner — that is what made the pre-guard code publish. + resolveSharedProjectOwner: async () => 'owner-1', + resolveProjectPrincipal: async () => ACTIVE_OWNER_PRINCIPAL, + rememberTeamShare: vi.fn(), + hasUnmaterializedPlaceholder: (projectId) => + isUnmaterializedSharedPlaceholder(store.get(projectId) ?? null), + }); + + const watcher = createCollabPublishWatcher({ + notifyChanged, + listProjectIds: () => [...store.keys()], + shouldPublish, + subscribeFiles: (projectId) => { + subscribed.push(projectId); + return { unsubscribe: () => {} }; + }, + }); + + await watcher.reconcile(); + + // The wipe propagation is exactly these two calls happening: subscribe + + // the initial "publish current (empty) content" notification. + expect(subscribed).toEqual([]); + expect(notifyChanged).not.toHaveBeenCalled(); + }); + + it('resumes watching (and the initial publish) once the pull flow clears the placeholder stamp', async () => { + const store = new Map([ + ['reinstalled-shared-project', placeholderRecord()], + ]); + const notifyChanged = vi.fn(); + const watcher = createCollabPublishWatcher({ + notifyChanged, + listProjectIds: () => [...store.keys()], + shouldPublish: createShouldPublish({ + resolveSharedProjectOwner: async () => 'owner-1', + resolveProjectPrincipal: async () => ACTIVE_OWNER_PRINCIPAL, + rememberTeamShare: vi.fn(), + hasUnmaterializedPlaceholder: (projectId) => + isUnmaterializedSharedPlaceholder(store.get(projectId) ?? null), + }), + subscribeFiles: () => ({ unsubscribe: () => {} }), + }); + + await watcher.reconcile(); + expect(notifyChanged).not.toHaveBeenCalled(); + + // What the pull flow does after materializing real hub content locally: + // registerPreparedPulledProject swaps the record and + // markSharedProjectPlaceholder(projectId, false) drops the stamp. + store.set('reinstalled-shared-project', { name: 'Real project', metadata: {} }); + + await watcher.reconcile(); + expect(notifyChanged).toHaveBeenCalledTimes(1); + expect(notifyChanged).toHaveBeenCalledWith( + 'reinstalled-shared-project', + ACTIVE_OWNER_PRINCIPAL, + ); + }); + + it('blocks a scheduler-driven publish for a placeholder at the runtime choke point, without touching explicit shares', async () => { + // Layer 2: even when a publish notification reaches the scheduler (e.g. a + // direct POST /collab/publish nudge, or a watcher attached before the + // stamp landed), the flush itself must refuse a placeholder. + const store = new Map([ + ['placeholder-project', placeholderRecord()], + ['real-project', { name: 'Real', metadata: {} }], + ]); + const publish = vi.fn(async (_input: { projectId: string }) => ({ version: 7 })); + const principal: ResourceHubPrincipal = { + teamId: 't1', + memberId: 'owner-1', + role: 'owner', + lifecycleState: 'active', + }; + const runtime = createCollabRuntime({ + adapter: { publish }, + workspaceContext: { + current: () => + Promise.resolve({ + workspaceType: 'team', + workspaceId: 't1', + workspaceMemberId: 'owner-1', + memberStatus: 'active', + } as never), + }, + canPublishProjectContent: (projectId) => + !isUnmaterializedSharedPlaceholder(store.get(projectId) ?? null), + }); + runtime.rememberTeamShare('placeholder-project', principal); + runtime.rememberTeamShare('real-project', principal); + + // notifyChanged schedules the debounced publish; runBoundary flushes it + // immediately (the exact POST /collab/changed + /collab/publish shape). + runtime.scheduler.notifyChanged('placeholder-project'); + runtime.scheduler.runBoundary('placeholder-project'); + runtime.scheduler.notifyChanged('real-project'); + runtime.scheduler.runBoundary('real-project'); + await vi.waitFor(() => { + expect(publish).toHaveBeenCalled(); + }); + + const publishedIds = publish.mock.calls.map((call) => call[0].projectId); + expect(publishedIds).toContain('real-project'); + expect(publishedIds).not.toContain('placeholder-project'); + + // An explicit share is the user deliberately publishing their local + // state; it never carries the stamp in practice and stays ungated. + publish.mockClear(); + await runtime.requestTeamShare('brand-new-local-project', principal); + expect(publish).toHaveBeenCalledTimes(1); + + runtime.dispose(); + }); +}); diff --git a/apps/daemon/tests/collab-presence-routes.test.ts b/apps/daemon/tests/collab-presence-routes.test.ts new file mode 100644 index 00000000000..3e79b7f7940 --- /dev/null +++ b/apps/daemon/tests/collab-presence-routes.test.ts @@ -0,0 +1,975 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import express from 'express'; +import http from 'node:http'; +import { + buildWorkspacePermissions, + buildWorkspaceSeatSummary, + type WorkspaceCollabContext, +} from '@open-design/contracts'; +import { createCollabRuntime } from '../src/collab/runtime.js'; +import type { + CollabPresenceCloudClient, + RegisterCollabPresenceRoutesDeps, +} from '../src/routes/collab-presence.js'; +import { + createCollabPresenceCloudClient, + registerCollabPresenceRoutes, +} from '../src/routes/collab-presence.js'; +import { verifyWorkspaceRequestContext } from '../src/collab/request-workspace-context.js'; +import { createCachedWorkspaceDirectoryFetcher } from '../src/collab/vela-workspace-context.js'; + +let server: http.Server | null = null; + +afterEach(async () => { + if (server) { + const toClose = server; + server = null; + await new Promise((resolve) => toClose.close(() => resolve())); + } +}); + +async function startPresenceServer( + cloud?: CollabPresenceCloudClient, + options: { + isProjectShared?: (projectId: string) => Promise; + cloudAuthorizesProjectPresence?: (projectId: string) => boolean; + verifyWorkspaceRequest?: RegisterCollabPresenceRoutesDeps['verifyWorkspaceRequest']; + verifyWorkspaceReadRequest?: RegisterCollabPresenceRoutesDeps['verifyWorkspaceReadRequest']; + presenceListCacheFreshMs?: number; + presenceListCacheNow?: () => number; + } = {}, +) { + const app = express(); + app.use(express.json()); + const routes = registerCollabPresenceRoutes(app, { + collab: createCollabRuntime(), + ...(cloud ? { cloud } : {}), + ...(options.isProjectShared ? { isProjectShared: options.isProjectShared } : {}), + ...(options.cloudAuthorizesProjectPresence + ? { cloudAuthorizesProjectPresence: options.cloudAuthorizesProjectPresence } + : {}), + ...(options.verifyWorkspaceRequest + ? { verifyWorkspaceRequest: options.verifyWorkspaceRequest } + : {}), + ...(options.verifyWorkspaceReadRequest + ? { verifyWorkspaceReadRequest: options.verifyWorkspaceReadRequest } + : {}), + ...(options.presenceListCacheFreshMs !== undefined + ? { presenceListCacheFreshMs: options.presenceListCacheFreshMs } + : {}), + ...(options.presenceListCacheNow + ? { presenceListCacheNow: options.presenceListCacheNow } + : {}), + }); + server = http.createServer(app); + await new Promise((resolve) => server!.listen(0, resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('server did not bind to a TCP port'); + const base = `http://127.0.0.1:${address.port}`; + return { + routes, + async json( + route: string, + options: { + method?: string; + body?: unknown; + headers?: Record; + } = {}, + ) { + const init: RequestInit = { method: options.method ?? 'GET' }; + if (options.headers) init.headers = options.headers; + if (options.body !== undefined) { + init.headers = { + ...options.headers, + 'content-type': 'application/json', + }; + init.body = JSON.stringify(options.body); + } + const response = await fetch(`${base}${route}`, init); + return { status: response.status, body: (await response.json()) as Record }; + }, + }; +} + +function presentIds(body: Record): string[] { + return (body.present as { memberId: string }[]).map((member) => member.memberId).sort(); +} + +function teamContext( + workspaceId = 'w1', + workspaceMemberId = 'm1', +): WorkspaceCollabContext { + return { + workspaceId, + workspaceName: `Workspace ${workspaceId}`, + workspaceType: 'team', + teamId: workspaceId, + workspaceMemberId, + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + billingState: 'active', + planId: null, + providerMode: 'platform_credits', + seatSummary: buildWorkspaceSeatSummary({ seatLimit: 5, usedSeats: 1 }), + permissions: buildWorkspacePermissions({ + role: 'member', + lifecycleState: 'active', + }), + }; +} + +describe('collab presence routes', () => { + it('heartbeats a member and lists the present set', async () => { + const api = await startPresenceServer(); + const hb = await api.json('/api/projects/p1/presence/heartbeat', { + method: 'POST', + body: { memberId: 'm1', name: 'Ada', role: 'owner' }, + }); + expect(hb.status).toBe(200); + expect(hb.body.present).toEqual([{ memberId: 'm1', name: 'Ada', role: 'owner' }]); + + const list = await api.json('/api/projects/p1/presence'); + expect(list.status).toBe(200); + expect(presentIds(list.body)).toEqual(['m1']); + }); + + it('removes a member on leave', async () => { + const api = await startPresenceServer(); + await api.json('/api/projects/p1/presence/heartbeat', { method: 'POST', body: { memberId: 'm1' } }); + await api.json('/api/projects/p1/presence/heartbeat', { method: 'POST', body: { memberId: 'm2' } }); + const left = await api.json('/api/projects/p1/presence/leave', { method: 'POST', body: { memberId: 'm1' } }); + expect(left.status).toBe(200); + expect(presentIds(left.body)).toEqual(['m2']); + }); + + it('rejects a heartbeat without a memberId', async () => { + const api = await startPresenceServer(); + const res = await api.json('/api/projects/p1/presence/heartbeat', { method: 'POST', body: {} }); + expect(res.status).toBe(400); + }); + + it('scopes presence per project', async () => { + const api = await startPresenceServer(); + await api.json('/api/projects/p1/presence/heartbeat', { method: 'POST', body: { memberId: 'm1' } }); + const other = await api.json('/api/projects/p2/presence'); + expect(other.body.present).toEqual([]); + }); + + it('proxies presence through a cloud client when configured', async () => { + const calls: Array<{ op: string; projectId: string; input?: unknown }> = []; + const cloud: CollabPresenceCloudClient = { + async heartbeatPresence(projectId, input) { + calls.push({ op: 'heartbeat', projectId, input }); + return [{ memberId: 'm1', name: 'Ada', role: 'owner', filePath: 'Typography' }]; + }, + async listPresence(projectId) { + calls.push({ op: 'list', projectId }); + return [{ memberId: 'm1', name: 'Ada', role: 'owner' }]; + }, + async leavePresence(projectId, input) { + calls.push({ op: 'leave', projectId, input }); + return []; + }, + }; + const api = await startPresenceServer(cloud); + + const hb = await api.json('/api/projects/p1/presence/heartbeat', { + method: 'POST', + body: { + memberId: 'm1', + name: 'Ada', + role: 'owner', + clientId: 'client-1', + filePath: 'Typography', + activity: { label: '正在评论 Typography' }, + }, + }); + expect(hb.status).toBe(200); + expect(hb.body.present).toEqual([ + { memberId: 'm1', name: 'Ada', role: 'owner', filePath: 'Typography' }, + ]); + + await api.json('/api/projects/p1/presence'); + await api.json('/api/projects/p1/presence/leave', { + method: 'POST', + body: { memberId: 'm1', clientId: 'client-1' }, + }); + + expect(calls).toMatchObject([ + { + op: 'heartbeat', + projectId: 'p1', + input: { + member: { memberId: 'm1', name: 'Ada', role: 'owner', filePath: 'Typography', activity: { label: '正在评论 Typography' } }, + clientId: 'client-1', + filePath: 'Typography', + activity: { label: '正在评论 Typography' }, + }, + }, + { op: 'leave', projectId: 'p1', input: { memberId: 'm1', clientId: 'client-1' } }, + ]); + }); + + it('does not publish presence for a project that is no longer team-shared', async () => { + const calls: Array<{ op: string; projectId: string; input?: unknown }> = []; + const cloud: CollabPresenceCloudClient = { + async heartbeatPresence(projectId, input) { + calls.push({ op: 'heartbeat', projectId, input }); + return [{ memberId: 'm1', name: 'Ada', role: 'owner' }]; + }, + async listPresence(projectId) { + calls.push({ op: 'list', projectId }); + return [{ memberId: 'm1', name: 'Ada', role: 'owner' }]; + }, + async leavePresence(projectId, input) { + calls.push({ op: 'leave', projectId, input }); + return []; + }, + }; + const api = await startPresenceServer(cloud, { isProjectShared: async () => false }); + + const list = await api.json('/api/projects/p1/presence'); + const heartbeat = await api.json('/api/projects/p1/presence/heartbeat', { + method: 'POST', + body: { memberId: 'm1', name: 'Ada', role: 'owner' }, + }); + + expect(list.status).toBe(200); + expect(list.body.present).toEqual([]); + expect(heartbeat.status).toBe(200); + expect(heartbeat.body.present).toEqual([]); + expect(calls).toEqual([]); + }); + + it('delegates project authorization to an authoritative cloud presence route', async () => { + const isProjectShared = vi.fn(async () => false); + const calls: string[] = []; + const cloud: CollabPresenceCloudClient = { + async heartbeatPresence(projectId) { + calls.push(`heartbeat:${projectId}`); + return [{ memberId: 'm1', name: 'Ada', role: 'owner' }]; + }, + async listPresence(projectId) { + calls.push(`list:${projectId}`); + return [{ memberId: 'm1', name: 'Ada', role: 'owner' }]; + }, + async leavePresence() { + return []; + }, + }; + const api = await startPresenceServer(cloud, { + isProjectShared, + cloudAuthorizesProjectPresence: () => true, + }); + + const list = await api.json('/api/projects/p1/presence'); + const heartbeat = await api.json('/api/projects/p1/presence/heartbeat', { + method: 'POST', + body: { memberId: 'm1', name: 'Ada', role: 'owner' }, + }); + + expect(list.status).toBe(200); + expect(heartbeat.status).toBe(200); + expect(calls).toEqual(['list:p1', 'heartbeat:p1']); + expect(isProjectShared).not.toHaveBeenCalled(); + }); + + it('uses the read authority lease and coalesces sequential cloud presence reads', async () => { + const context = teamContext(); + let now = 1_000; + const fetchDirectory = vi.fn(async () => ({ + ok: true as const, + items: [{ + workspaceId: context.workspaceId, + workspaceName: context.workspaceName ?? context.workspaceId, + workspaceType: context.workspaceType, + workspaceMemberId: context.workspaceMemberId, + role: context.role, + memberStatus: context.memberStatus, + lifecycleState: context.lifecycleState, + }], + })); + const cachedDirectory = createCachedWorkspaceDirectoryFetcher({ + fetchDirectory, + identityKey: () => 'presence-reader', + ttlMs: 5_000, + now: () => now, + }); + const verifyWorkspaceRequest = vi.fn(async () => { + throw new Error('GET must not use fresh mutation authority'); + }); + const verifyWorkspaceReadRequest = vi.fn((req: unknown) => + verifyWorkspaceRequestContext({ + req, + fetchWorkspaceDirectory: cachedDirectory, + })); + const listPresence = vi.fn(async () => [{ memberId: 'm1' }]); + const api = await startPresenceServer( + { + heartbeatPresence: vi.fn(async () => []), + listPresence, + leavePresence: vi.fn(async () => []), + }, + { + verifyWorkspaceRequest, + verifyWorkspaceReadRequest, + cloudAuthorizesProjectPresence: () => true, + }, + ); + + const headers = { + 'x-od-workspace-id': context.workspaceId, + 'x-od-workspace-member-id': context.workspaceMemberId, + }; + await expect(api.json('/api/projects/p1/presence', { headers })).resolves.toMatchObject({ + status: 200, + body: { present: [{ memberId: 'm1' }] }, + }); + now += 4_999; + await expect(api.json('/api/projects/p1/presence', { headers })).resolves.toMatchObject({ + status: 200, + body: { present: [{ memberId: 'm1' }] }, + }); + + expect(verifyWorkspaceReadRequest).toHaveBeenCalledTimes(2); + expect(fetchDirectory).toHaveBeenCalledTimes(1); + expect(verifyWorkspaceRequest).not.toHaveBeenCalled(); + expect(listPresence).toHaveBeenCalledTimes(1); + }); + + it('keeps the non-authoritative shared-project fallback inside the hot roster cache', async () => { + const context = teamContext(); + const isProjectShared = vi.fn(async () => true); + const listPresence = vi.fn(async () => [{ memberId: 'm1' }]); + const api = await startPresenceServer( + { + heartbeatPresence: vi.fn(async () => []), + listPresence, + leavePresence: vi.fn(async () => []), + }, + { + verifyWorkspaceReadRequest: async () => ({ ok: true, context }), + isProjectShared, + cloudAuthorizesProjectPresence: () => false, + }, + ); + + const responses = []; + for (let index = 0; index < 20; index += 1) { + responses.push(await api.json('/api/projects/p1/presence')); + } + expect(responses).toHaveLength(20); + expect(responses.every((response) => response.status === 200)).toBe(true); + expect(responses.every((response) => + presentIds(response.body).join(',') === 'm1')).toBe(true); + expect(isProjectShared).toHaveBeenCalledTimes(1); + expect(listPresence).toHaveBeenCalledTimes(1); + + await expect(api.json('/api/projects/p1/presence')).resolves.toMatchObject({ + status: 200, + body: { present: [{ memberId: 'm1' }] }, + }); + expect(isProjectShared).toHaveBeenCalledTimes(1); + expect(listPresence).toHaveBeenCalledTimes(1); + }); + + it('rechecks the shared-project fallback only after explicit invalidation', async () => { + const context = teamContext(); + let shared = true; + const isProjectShared = vi.fn(async () => shared); + const listPresence = vi.fn(async () => [{ memberId: 'm1' }]); + const api = await startPresenceServer( + { + heartbeatPresence: vi.fn(async () => []), + listPresence, + leavePresence: vi.fn(async () => []), + }, + { + verifyWorkspaceReadRequest: async () => ({ ok: true, context }), + isProjectShared, + cloudAuthorizesProjectPresence: () => false, + }, + ); + + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([ + { memberId: 'm1' }, + ]); + shared = false; + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([ + { memberId: 'm1' }, + ]); + + api.routes.invalidatePresence('p1', context.workspaceId); + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([]); + expect(isProjectShared).toHaveBeenCalledTimes(2); + expect(listPresence).toHaveBeenCalledTimes(1); + }); + + it('keeps the last authorized roster non-blocking across presence-change events while refreshing once in the background', async () => { + const context = teamContext(); + let resolveRefresh: + | ((present: Array<{ memberId: string }>) => void) + | undefined; + const refresh = new Promise>((resolve) => { + resolveRefresh = resolve; + }); + const listPresence = vi + .fn() + .mockResolvedValueOnce([{ memberId: 'before-event' }]) + .mockReturnValueOnce(refresh); + const api = await startPresenceServer( + { + heartbeatPresence: vi.fn(async () => []), + listPresence, + leavePresence: vi.fn(async () => []), + }, + { + verifyWorkspaceReadRequest: async () => ({ ok: true, context }), + cloudAuthorizesProjectPresence: () => true, + }, + ); + + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([ + { memberId: 'before-event' }, + ]); + api.routes.markPresenceStale('p1', context.workspaceId); + + const firstAfterEvent = await api.json('/api/projects/p1/presence'); + const concurrentAfterEvent = await api.json('/api/projects/p1/presence'); + expect(firstAfterEvent.body.present).toEqual([ + { memberId: 'before-event' }, + ]); + expect(concurrentAfterEvent.body.present).toEqual([ + { memberId: 'before-event' }, + ]); + expect(listPresence).toHaveBeenCalledTimes(2); + + resolveRefresh!([{ memberId: 'after-event' }]); + await refresh; + await new Promise((resolve) => setImmediate(resolve)); + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([ + { memberId: 'after-event' }, + ]); + expect(listPresence).toHaveBeenCalledTimes(2); + }); + + it('still hard-invalidates a soft-stale roster on share authority changes', async () => { + const context = teamContext(); + let shared = true; + const isProjectShared = vi.fn(async () => shared); + const listPresence = vi.fn(async () => [{ memberId: 'cached' }]); + const api = await startPresenceServer( + { + heartbeatPresence: vi.fn(async () => []), + listPresence, + leavePresence: vi.fn(async () => []), + }, + { + verifyWorkspaceReadRequest: async () => ({ ok: true, context }), + isProjectShared, + cloudAuthorizesProjectPresence: () => false, + }, + ); + + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([ + { memberId: 'cached' }, + ]); + api.routes.markPresenceStale('p1', context.workspaceId); + shared = false; + api.routes.invalidatePresence('p1', context.workspaceId); + + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual( + [], + ); + expect(isProjectShared).toHaveBeenCalledTimes(2); + expect(listPresence).toHaveBeenCalledTimes(1); + }); + + it('rechecks the shared-project fallback on TTL refresh without an invalidation event', async () => { + const context = teamContext(); + let now = 1_000; + let shared = true; + const isProjectShared = vi.fn(async () => shared); + const listPresence = vi.fn(async () => [{ memberId: 'm1' }]); + const api = await startPresenceServer( + { + heartbeatPresence: vi.fn(async () => []), + listPresence, + leavePresence: vi.fn(async () => []), + }, + { + verifyWorkspaceReadRequest: async () => ({ ok: true, context }), + isProjectShared, + cloudAuthorizesProjectPresence: () => false, + presenceListCacheFreshMs: 1_000, + presenceListCacheNow: () => now, + }, + ); + + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([ + { memberId: 'm1' }, + ]); + shared = false; + now += 999; + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([ + { memberId: 'm1' }, + ]); + expect(isProjectShared).toHaveBeenCalledTimes(1); + + now += 1; + // SWR: the boundary read stays non-blocking on the last roster while one + // background refresh rechecks both share authority and cloud presence. + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([ + { memberId: 'm1' }, + ]); + await vi.waitFor(() => expect(isProjectShared).toHaveBeenCalledTimes(2)); + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([]); + expect(listPresence).toHaveBeenCalledTimes(1); + }); + + it('clears a cached roster when heartbeat or leave observes an unshared project', async () => { + const context = teamContext(); + let shared = true; + const isProjectShared = vi.fn(async () => shared); + const api = await startPresenceServer( + { + heartbeatPresence: vi.fn(async () => [{ memberId: 'm1' }]), + listPresence: vi.fn(async () => [{ memberId: 'm1' }]), + leavePresence: vi.fn(async () => []), + }, + { + verifyWorkspaceRequest: async () => ({ ok: true, context }), + verifyWorkspaceReadRequest: async () => ({ ok: true, context }), + isProjectShared, + cloudAuthorizesProjectPresence: () => false, + }, + ); + + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([ + { memberId: 'm1' }, + ]); + shared = false; + expect((await api.json('/api/projects/p1/presence/heartbeat', { + method: 'POST', + body: { memberId: context.workspaceMemberId }, + })).body.present).toEqual([]); + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([]); + + shared = true; + api.routes.invalidatePresence('p1', context.workspaceId); + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([ + { memberId: 'm1' }, + ]); + shared = false; + expect((await api.json('/api/projects/p1/presence/leave', { + method: 'POST', + body: { memberId: context.workspaceMemberId }, + })).body.present).toEqual([]); + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([]); + }); + + it('single-flights concurrent presence reads for one exact viewer scope', async () => { + let resolveList: + | ((present: Array<{ memberId: string }>) => void) + | undefined; + const listPresence = vi.fn( + () => + new Promise>((resolve) => { + resolveList = resolve; + }), + ); + const context = teamContext(); + const isProjectShared = vi.fn(async () => true); + const api = await startPresenceServer( + { + heartbeatPresence: vi.fn(async () => []), + listPresence, + leavePresence: vi.fn(async () => []), + }, + { + verifyWorkspaceReadRequest: async () => ({ ok: true, context }), + isProjectShared, + cloudAuthorizesProjectPresence: () => false, + }, + ); + + const first = api.json('/api/projects/p1/presence'); + const second = api.json('/api/projects/p1/presence'); + await vi.waitFor(() => expect(listPresence).toHaveBeenCalledTimes(1)); + resolveList?.([{ memberId: 'm1' }]); + + await expect(Promise.all([first, second])).resolves.toEqual([ + { status: 200, body: { present: [{ memberId: 'm1' }] } }, + { status: 200, body: { present: [{ memberId: 'm1' }] } }, + ]); + expect(isProjectShared).toHaveBeenCalledTimes(1); + }); + + it('isolates cached presence by workspace, project, and viewer member', async () => { + const listPresence = vi.fn(async (_projectId, context) => [ + { memberId: context?.workspaceMemberId ?? 'missing' }, + ]); + const api = await startPresenceServer( + { + heartbeatPresence: vi.fn(async () => []), + listPresence, + leavePresence: vi.fn(async () => []), + }, + { + verifyWorkspaceReadRequest: async (req) => { + const workspaceId = String(req.headers['x-test-workspace']); + const workspaceMemberId = String(req.headers['x-test-member']); + return { + ok: true, + context: teamContext(workspaceId, workspaceMemberId), + }; + }, + cloudAuthorizesProjectPresence: () => true, + }, + ); + const scopedGet = (projectId: string, workspaceId: string, memberId: string) => + api.json(`/api/projects/${projectId}/presence`, { + headers: { + 'x-test-workspace': workspaceId, + 'x-test-member': memberId, + }, + }); + + await scopedGet('p1', 'w1', 'm1'); + await scopedGet('p1', 'w1', 'm2'); + await scopedGet('p1', 'w2', 'm1'); + await scopedGet('p2', 'w1', 'm1'); + await scopedGet('p1', 'w1', 'm1'); + + expect(listPresence).toHaveBeenCalledTimes(4); + }); + + it('partitions cached presence by every permission bit in the verified identity', async () => { + const baseContext = teamContext(); + const restrictedContext: WorkspaceCollabContext = { + ...baseContext, + permissions: { + ...baseContext.permissions, + canShareProjects: false, + canWriteSyncedFiles: false, + }, + }; + const listPresence = vi.fn(async (_projectId, context) => [ + { memberId: context?.permissions.canShareProjects ? 'writer' : 'reader' }, + ]); + const api = await startPresenceServer( + { + heartbeatPresence: vi.fn(async () => []), + listPresence, + leavePresence: vi.fn(async () => []), + }, + { + verifyWorkspaceReadRequest: async (req) => ({ + ok: true, + context: req.headers['x-test-permission'] === 'restricted' + ? restrictedContext + : baseContext, + }), + cloudAuthorizesProjectPresence: () => true, + }, + ); + + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([ + { memberId: 'writer' }, + ]); + expect((await api.json('/api/projects/p1/presence', { + headers: { 'x-test-permission': 'restricted' }, + })).body.present).toEqual([{ memberId: 'reader' }]); + expect(listPresence).toHaveBeenCalledTimes(2); + }); + + it('uses a virtual clock for TTL refresh and explicit hub invalidation', async () => { + let now = 1_000; + const listPresence = vi + .fn() + .mockResolvedValueOnce([{ memberId: 'first' }]) + .mockResolvedValueOnce([{ memberId: 'refreshed' }]) + .mockResolvedValueOnce([{ memberId: 'invalidated' }]); + const context = teamContext(); + const api = await startPresenceServer( + { + heartbeatPresence: vi.fn(async () => []), + listPresence, + leavePresence: vi.fn(async () => []), + }, + { + verifyWorkspaceReadRequest: async () => ({ ok: true, context }), + cloudAuthorizesProjectPresence: () => true, + presenceListCacheFreshMs: 1_000, + presenceListCacheNow: () => now, + }, + ); + + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([ + { memberId: 'first' }, + ]); + now += 999; + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([ + { memberId: 'first' }, + ]); + expect(listPresence).toHaveBeenCalledTimes(1); + + now += 1; + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([ + { memberId: 'first' }, + ]); + await vi.waitFor(() => expect(listPresence).toHaveBeenCalledTimes(2)); + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([ + { memberId: 'refreshed' }, + ]); + + api.routes.invalidatePresence('p1', 'w1'); + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([ + { memberId: 'invalidated' }, + ]); + expect(listPresence).toHaveBeenCalledTimes(3); + }); + + it('does not cache failed cloud presence reads', async () => { + const listPresence = vi + .fn() + .mockRejectedValueOnce(new Error('temporary outage')) + .mockResolvedValueOnce([{ memberId: 'm1' }]); + const context = teamContext(); + const api = await startPresenceServer( + { + heartbeatPresence: vi.fn(async () => []), + listPresence, + leavePresence: vi.fn(async () => []), + }, + { + verifyWorkspaceReadRequest: async () => ({ ok: true, context }), + cloudAuthorizesProjectPresence: () => true, + }, + ); + + expect((await api.json('/api/projects/p1/presence')).status).toBe(502); + expect((await api.json('/api/projects/p1/presence')).status).toBe(200); + expect(listPresence).toHaveBeenCalledTimes(2); + }); + + it('drops stale presence after a failed background refresh', async () => { + let now = 1_000; + const listPresence = vi + .fn() + .mockResolvedValueOnce([{ memberId: 'stale' }]) + .mockRejectedValueOnce(new Error('temporary outage')) + .mockResolvedValueOnce([{ memberId: 'recovered' }]); + const context = teamContext(); + const api = await startPresenceServer( + { + heartbeatPresence: vi.fn(async () => []), + listPresence, + leavePresence: vi.fn(async () => []), + }, + { + verifyWorkspaceReadRequest: async () => ({ ok: true, context }), + cloudAuthorizesProjectPresence: () => true, + presenceListCacheFreshMs: 1_000, + presenceListCacheNow: () => now, + }, + ); + + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([ + { memberId: 'stale' }, + ]); + now += 1_000; + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([ + { memberId: 'stale' }, + ]); + await vi.waitFor(() => expect(listPresence).toHaveBeenCalledTimes(2)); + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([ + { memberId: 'recovered' }, + ]); + expect(listPresence).toHaveBeenCalledTimes(3); + }); + + it('denies reads after the authority lease expires without serving cached presence', async () => { + const context = teamContext(); + let now = 1_000; + let directoryItems = [{ + workspaceId: context.workspaceId, + workspaceName: context.workspaceName ?? context.workspaceId, + workspaceType: context.workspaceType, + workspaceMemberId: context.workspaceMemberId, + role: context.role, + memberStatus: context.memberStatus, + lifecycleState: context.lifecycleState, + }]; + const fetchDirectory = vi.fn(async () => ({ + ok: true as const, + items: directoryItems, + })); + const cachedDirectory = createCachedWorkspaceDirectoryFetcher({ + fetchDirectory, + identityKey: () => 'revoked-presence-reader', + ttlMs: 5_000, + now: () => now, + }); + const listPresence = vi.fn(async () => [{ memberId: 'm1' }]); + const verifyWorkspaceReadRequest = vi.fn((req: unknown) => + verifyWorkspaceRequestContext({ + req, + fetchWorkspaceDirectory: cachedDirectory, + })); + const api = await startPresenceServer( + { + heartbeatPresence: vi.fn(async () => []), + listPresence, + leavePresence: vi.fn(async () => []), + }, + { + verifyWorkspaceReadRequest, + cloudAuthorizesProjectPresence: () => true, + }, + ); + const headers = { + 'x-od-workspace-id': context.workspaceId, + 'x-od-workspace-member-id': context.workspaceMemberId, + }; + + expect((await api.json('/api/projects/p1/presence', { headers })).status).toBe(200); + directoryItems = []; + now += 4_999; + expect((await api.json('/api/projects/p1/presence', { headers })).status).toBe(200); + now += 1; + expect((await api.json('/api/projects/p1/presence', { headers })).status).toBe(403); + expect(fetchDirectory).toHaveBeenCalledTimes(2); + expect(listPresence).toHaveBeenCalledTimes(1); + }); + + it('keeps heartbeat and leave on fresh authority and publishes their latest result', async () => { + const context = teamContext(); + const verifyWorkspaceRequest = vi.fn(async () => ({ + ok: true as const, + context, + })); + const verifyWorkspaceReadRequest = vi.fn(async () => ({ + ok: true as const, + context, + })); + const heartbeatPresence = vi.fn(async () => [{ memberId: 'm1' }]); + const leavePresence = vi.fn(async () => []); + const listPresence = vi.fn(async () => { + throw new Error('mutation result should prime the read cache'); + }); + const api = await startPresenceServer( + { heartbeatPresence, listPresence, leavePresence }, + { + verifyWorkspaceRequest, + verifyWorkspaceReadRequest, + cloudAuthorizesProjectPresence: () => true, + }, + ); + + expect((await api.json('/api/projects/p1/presence/heartbeat', { + method: 'POST', + body: { memberId: 'm1' }, + })).status).toBe(200); + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([ + { memberId: 'm1' }, + ]); + expect((await api.json('/api/projects/p1/presence/leave', { + method: 'POST', + body: { memberId: 'm1' }, + })).status).toBe(200); + expect((await api.json('/api/projects/p1/presence')).body.present).toEqual([]); + + expect(verifyWorkspaceRequest).toHaveBeenCalledTimes(2); + expect(verifyWorkspaceReadRequest).toHaveBeenCalledTimes(2); + expect(listPresence).not.toHaveBeenCalled(); + }); + + it('returns a retryable 503 without relay side effects when Workspace authority is unavailable', async () => { + const heartbeatPresence = vi.fn(async () => []); + const listPresence = vi.fn(async () => []); + const leavePresence = vi.fn(async () => []); + const isProjectShared = vi.fn(async () => true); + const api = await startPresenceServer( + { heartbeatPresence, listPresence, leavePresence }, + { + isProjectShared, + verifyWorkspaceRequest: async () => ({ + ok: false, + status: 503, + code: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + message: 'workspace membership authority is temporarily unavailable', + retryable: true, + }), + }, + ); + + const responses = [ + await api.json('/api/projects/p1/presence'), + await api.json('/api/projects/p1/presence/heartbeat', { + method: 'POST', + body: { memberId: 'm1' }, + }), + await api.json('/api/projects/p1/presence/leave', { + method: 'POST', + body: { memberId: 'm1' }, + }), + ]; + + for (const response of responses) { + expect(response.status).toBe(503); + expect(response.body).toMatchObject({ + error: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + retryable: true, + }); + } + expect(isProjectShared).not.toHaveBeenCalled(); + expect(heartbeatPresence).not.toHaveBeenCalled(); + expect(listPresence).not.toHaveBeenCalled(); + expect(leavePresence).not.toHaveBeenCalled(); + }); +}); + +describe('createCollabPresenceCloudClient', () => { + // The routes read a present `cloud` as "the cloud owns presence", so an + // absent transport MUST produce an absent dependency — not a relay that + // dereferences nothing. See the factory's docblock. + it('is absent when there is no collab transport', () => { + expect(createCollabPresenceCloudClient(null, () => undefined)).toBeNull(); + expect(createCollabPresenceCloudClient(undefined, () => undefined)).toBeNull(); + }); + + it('binds each call to the project workspace scope when a transport exists', async () => { + const calls: string[] = []; + const transport = { + async heartbeatPresence(projectId: string, _input: unknown, workspaceId?: string) { + calls.push(`heartbeat:${projectId}:${workspaceId}`); + return []; + }, + async listPresence(projectId: string, workspaceId?: string) { + calls.push(`list:${projectId}:${workspaceId}`); + return []; + }, + async leavePresence(projectId: string, _input: unknown, workspaceId?: string) { + calls.push(`leave:${projectId}:${workspaceId}`); + return []; + }, + }; + const cloud = createCollabPresenceCloudClient( + transport, + (projectId) => `ws-for-${projectId}`, + ); + expect(cloud).not.toBeNull(); + + await cloud!.heartbeatPresence('p1', { member: { memberId: 'm1' } }); + await cloud!.listPresence('p1'); + await cloud!.leavePresence('p1', { memberId: 'm1' }); + + expect(calls).toEqual([ + 'heartbeat:p1:ws-for-p1', + 'list:p1:ws-for-p1', + 'leave:p1:ws-for-p1', + ]); + }); +}); diff --git a/apps/daemon/tests/collab-presence-tracker.test.ts b/apps/daemon/tests/collab-presence-tracker.test.ts new file mode 100644 index 00000000000..450922f0efe --- /dev/null +++ b/apps/daemon/tests/collab-presence-tracker.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CollabPresenceTracker } from '../src/collab/presence-tracker.js'; + +let clock = 0; +const now = () => clock; + +beforeEach(() => { + clock = 1_000; +}); + +function ids(members: { memberId: string }[]): string[] { + return members.map((member) => member.memberId).sort(); +} + +describe('CollabPresenceTracker', () => { + it('lists members that have recently heartbeat', () => { + const tracker = new CollabPresenceTracker({ ttlMs: 100, now }); + tracker.heartbeat('p1', { memberId: 'm1', role: 'owner' }); + tracker.heartbeat('p1', { memberId: 'm2', role: 'member' }); + expect(ids(tracker.present('p1'))).toEqual(['m1', 'm2']); + expect(tracker.present('other')).toEqual([]); + }); + + it('drops a member whose heartbeat aged past the TTL', () => { + const tracker = new CollabPresenceTracker({ ttlMs: 100, now }); + tracker.heartbeat('p1', { memberId: 'm1' }); + clock += 99; + expect(ids(tracker.present('p1'))).toEqual(['m1']); // still inside TTL + clock += 2; // now 101ms since heartbeat, past TTL + expect(tracker.present('p1')).toEqual([]); + }); + + it('keeps a member present as long as they keep heartbeating', () => { + const tracker = new CollabPresenceTracker({ ttlMs: 100, now }); + tracker.heartbeat('p1', { memberId: 'm1' }); + clock += 90; + tracker.heartbeat('p1', { memberId: 'm1' }); // refresh + clock += 90; // 90ms since the refresh — still present despite 180ms total + expect(ids(tracker.present('p1'))).toEqual(['m1']); + }); + + it('removes a member immediately on explicit leave', () => { + const tracker = new CollabPresenceTracker({ ttlMs: 10_000, now }); + tracker.heartbeat('p1', { memberId: 'm1' }); + tracker.heartbeat('p1', { memberId: 'm2' }); + tracker.leave('p1', 'm1'); + expect(ids(tracker.present('p1'))).toEqual(['m2']); + }); + + it('fires onChange on join and leave, but not on a refresh heartbeat', () => { + const onChange = vi.fn(); + const tracker = new CollabPresenceTracker({ ttlMs: 10_000, now, onChange }); + + tracker.heartbeat('p1', { memberId: 'm1' }); // join + expect(onChange).toHaveBeenCalledTimes(1); + expect(ids(onChange.mock.calls[0]![0].present)).toEqual(['m1']); + + tracker.heartbeat('p1', { memberId: 'm1' }); // refresh — no membership change + expect(onChange).toHaveBeenCalledTimes(1); + + tracker.heartbeat('p1', { memberId: 'm2' }); // join + expect(onChange).toHaveBeenCalledTimes(2); + + tracker.leave('p1', 'm1'); // leave + expect(onChange).toHaveBeenCalledTimes(3); + expect(ids(onChange.mock.calls[2]![0].present)).toEqual(['m2']); + }); + + it('ignores a leave for an unknown member', () => { + const onChange = vi.fn(); + const tracker = new CollabPresenceTracker({ ttlMs: 10_000, now, onChange }); + tracker.leave('p1', 'ghost'); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/daemon/tests/collab-presence-transport-off.test.ts b/apps/daemon/tests/collab-presence-transport-off.test.ts new file mode 100644 index 00000000000..140166da70f --- /dev/null +++ b/apps/daemon/tests/collab-presence-transport-off.test.ts @@ -0,0 +1,394 @@ +import type { Server } from 'node:http'; +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + buildWorkspacePermissions, + buildWorkspaceSeatSummary, + type WorkspaceCollabContext, +} from '@open-design/contracts'; + +/** + * Presence when the vela-cli collab transport is OFF. + * + * `createVelaCliCollabClientFromEnv` returns `null` unless this run opted into + * the vela-cli collab transport — which is every stable/prod packaged build and + * every plain `tools-dev` run. `registerCollabPresenceRoutes` is built to cope + * with that: `deps.cloud` is optional and each endpoint falls back to the + * process-local presence tracker. + * + * These tests pin that contract at the real daemon HTTP boundary (the routes are + * wired in `server.ts`, so a route-module test cannot see the wiring), and pin + * the other direction too: with the transport ON, presence must still relay to + * the cloud. + */ + +type StartedServer = { + url: string; + server: Server; + shutdown?: () => Promise | void; +}; +type ServerModule = { + startServer: (options: { + port: number; + returnServer: boolean; + }) => Promise; +}; + +const MANAGED_ENV = [ + 'OD_DATA_DIR', + 'OD_COLLAB_TRANSPORT', + 'OD_TEAM_PROJECTS_TRANSPORT', + 'OD_RESOURCE_TRANSPORT', + 'OD_WORKSPACE_CONTEXT_SOURCE', + 'OD_COLLAB_CLOUD_URL', + 'OD_DEV_WORKSPACE_CONTEXT', + 'VELA_BIN', + 'OD_TEST_TEAM_PROJECTS_JSON', + 'OD_TEST_CLOUD_VIEWERS_JSON', + 'OD_TEST_VELA_LOG', +] as const; + +let started: StartedServer | null = null; +let scratch: string | null = null; +// `startServer` reads the transport env on every call, so one module instance +// serves every case. Re-importing it would re-register the prom-client metrics. +let serverModule: ServerModule | null = null; +const savedEnv = new Map(); + +afterEach(async () => { + await stopServer(); + if (scratch) await rm(scratch, { recursive: true, force: true }); + scratch = null; + for (const [key, value] of savedEnv) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + savedEnv.clear(); +}, 60_000); + +const SHARED_PROJECT = 'presence-transport-off-shared'; + +describe('collab presence with the vela-cli collab transport off', () => { + it('falls back to process-local presence on leave instead of failing', async () => { + // The leanest possible reproduction: no cloud transport, no fixtures. `leave` + // carries no shared/authorized precondition, so it reaches the cloud branch + // on any project id. + setEnv({ OD_COLLAB_TRANSPORT: 'off' }); + const api = await startIsolatedServer(); + + const left = await api.post(`/api/projects/any-local-project/presence/leave`, { + memberId: 'm1', + }); + + expect(left.status).toBe(200); + expect(left.body).toMatchObject({ ok: true, present: [] }); + }, 60_000); + + it('serves the process-local present set for a shared project', async () => { + // A genuinely shared project clears the presence gate, so heartbeat/list + // reach the same cloud branch. With the transport off they must answer from + // the in-process tracker. + setEnv({ + OD_COLLAB_TRANSPORT: 'off', + OD_TEAM_PROJECTS_TRANSPORT: 'vela-cli', + OD_TEST_TEAM_PROJECTS_JSON: JSON.stringify({ + projects: [ + { + projectId: SHARED_PROJECT, + ownerMemberId: 'owner-1', + createdAt: '2026-01-01T00:00:00.000Z', + }, + ], + }), + }); + await setupVelaStub(); + const api = await startIsolatedServer(); + + const beat = await api.post(`/api/projects/${SHARED_PROJECT}/presence/heartbeat`, { + memberId: 'local-member', + name: 'Ada', + role: 'owner', + }); + expect(beat.status).toBe(200); + expect(presentIds(beat.body)).toEqual(['local-member']); + // The project really did clear the shared-project gate via the CLI catalog. + expect((await velaCalls()).some((call) => call.startsWith('team-projects'))).toBe(true); + // ...and no presence call was relayed, because there is no cloud transport. + expect( + (await velaCalls()).some((call) => call.startsWith('collab presence')), + ).toBe(false); + + const list = await api.get(`/api/projects/${SHARED_PROJECT}/presence`); + expect(list.status).toBe(200); + expect(presentIds(list.body)).toEqual(['local-member']); + + const left = await api.post(`/api/projects/${SHARED_PROJECT}/presence/leave`, { + memberId: 'local-member', + }); + expect(left.status).toBe(200); + expect(presentIds(left.body)).toEqual([]); + }, 60_000); +}); + +describe('collab presence with the vela-cli collab transport on', () => { + it('still relays presence to the cloud', async () => { + setEnv({ + OD_COLLAB_TRANSPORT: 'vela-cli', + OD_TEAM_PROJECTS_TRANSPORT: 'vela-cli', + OD_TEST_TEAM_PROJECTS_JSON: JSON.stringify({ + projects: [ + { + projectId: SHARED_PROJECT, + ownerMemberId: 'owner-1', + createdAt: '2026-01-01T00:00:00.000Z', + }, + ], + }), + OD_TEST_CLOUD_VIEWERS_JSON: JSON.stringify([ + { memberId: 'cloud-member', displayName: 'Cloud Member', role: 'member' }, + ]), + }); + await setupVelaStub(); + const api = await startIsolatedServer(); + + // Exercise the read relay before the heartbeat primes its short-lived + // roster cache. The cloud answer wins over anything the in-process tracker + // holds, which is how a second daemon's viewer becomes visible here. + const list = await api.get(`/api/projects/${SHARED_PROJECT}/presence`); + expect(list.status).toBe(200); + expect(presentIds(list.body)).toEqual(['cloud-member']); + + const beat = await api.post(`/api/projects/${SHARED_PROJECT}/presence/heartbeat`, { + memberId: 'local-member', + name: 'Ada', + role: 'owner', + }); + expect(beat.status).toBe(200); + expect(presentIds(beat.body)).toEqual(['cloud-member']); + + const left = await api.post(`/api/projects/${SHARED_PROJECT}/presence/leave`, { + memberId: 'local-member', + }); + expect(left.status).toBe(200); + expect(left.body).toMatchObject({ ok: true }); + + const calls = await velaCalls(); + expect(calls).toContain(`collab presence list ${SHARED_PROJECT}`); + expect( + calls.some((call) => call.startsWith(`collab presence heartbeat ${SHARED_PROJECT}`)), + ).toBe(true); + expect( + calls.some((call) => call.startsWith(`collab presence leave ${SHARED_PROJECT}`)), + ).toBe(true); + }, 60_000); +}); + +function presentIds(body: Record): string[] { + return ((body.present ?? []) as { memberId: string }[]) + .map((member) => member.memberId) + .sort(); +} + +function setEnv(values: Record): void { + for (const key of MANAGED_ENV) { + if (!savedEnv.has(key)) savedEnv.set(key, process.env[key]); + } + // Start from a clean slate so an ambient transport/context in the developer's + // shell cannot decide what this test exercises. + for (const key of MANAGED_ENV) delete process.env[key]; + process.env.OD_DEV_WORKSPACE_CONTEXT = JSON.stringify(devWorkspaceContext()); + for (const [key, value] of Object.entries(values)) process.env[key] = value; +} + +function devWorkspaceContext(): WorkspaceCollabContext { + return { + workspaceId: 'ws-transport-off', + workspaceType: 'team', + teamId: 'team-transport-off', + workspaceMemberId: 'local-member', + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + billingState: 'active', + planId: null, + providerMode: 'platform_credits', + seatSummary: buildWorkspaceSeatSummary({ seatLimit: 5, usedSeats: 1 }), + permissions: buildWorkspacePermissions({ + role: 'owner', + lifecycleState: 'active', + }), + }; +} + +function devWorkspaceHeaders(): Record { + const context = devWorkspaceContext(); + return { + 'x-od-workspace-id': context.workspaceId, + 'x-od-workspace-type': context.workspaceType, + 'x-od-workspace-member-id': context.workspaceMemberId, + 'x-od-workspace-role': context.role, + 'x-od-workspace-member-status': context.memberStatus, + 'x-od-workspace-lifecycle-state': context.lifecycleState, + 'x-od-workspace-can-share-projects': String( + context.permissions.canShareProjects, + ), + 'x-od-workspace-can-write-synced-files': String( + context.permissions.canWriteSyncedFiles, + ), + }; +} + +/** + * A stand-in `vela` on the daemon's normal resolution path (`VELA_BIN`), so the + * team-project catalog and the collab presence relay exercise the real + * child-process transport rather than an injected fake. + */ +async function setupVelaStub(): Promise { + const root = await ensureScratch(); + const script = join(root, 'vela-stub.mjs'); + await writeFile( + script, + `import { appendFileSync } from 'node:fs'; +const args = process.argv.slice(2); +if (process.env.OD_TEST_VELA_LOG) { + appendFileSync(process.env.OD_TEST_VELA_LOG, args.join(' ') + '\\n'); +} +const [group, ...rest] = args; +const out = (value) => { + process.stdout.write(JSON.stringify(value)); + process.exit(0); +}; +if (group === 'team-projects') { + if (rest[0] === '--help') process.exit(0); + const catalog = JSON.parse(process.env.OD_TEST_TEAM_PROJECTS_JSON || '{"projects":[]}'); + const projects = Array.isArray(catalog.projects) ? catalog.projects : []; + if (rest[0] === 'list') out({ projects }); + if (rest[0] === 'get') { + const found = projects.find((project) => project.projectId === rest[1]); + if (!found) { + process.stderr.write('API request failed with status 404'); + process.exit(1); + } + out(found); + } + out({}); +} +if (group === 'resource' && rest[0] === 'shared') { + // Older CLI builds expose only the resource index; the catalog adapter + // probes once per process, so answer both shapes for the same catalog. + const catalog = JSON.parse(process.env.OD_TEST_TEAM_PROJECTS_JSON || '{"projects":[]}'); + const projects = Array.isArray(catalog.projects) ? catalog.projects : []; + out({ + resources: projects.map((project) => ({ + id: 'project-' + project.projectId, + teamId: 'team-transport-off', + kind: 'project', + ownerMemberId: project.ownerMemberId, + createdAt: project.createdAt, + metadata: { projectId: project.projectId }, + deletedAt: null, + })), + }); +} +if (group === 'collab' && rest[0] === 'presence') { + const viewers = JSON.parse(process.env.OD_TEST_CLOUD_VIEWERS_JSON || '[]'); + out({ viewers: rest[1] === 'leave' ? [] : viewers }); +} +out({}); +`, + 'utf8', + ); + const bin = join(root, 'vela'); + await writeFile(bin, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(script)} "$@"\n`, 'utf8'); + await chmod(bin, 0o755); + process.env.VELA_BIN = bin; + process.env.OD_TEST_VELA_LOG = join(root, 'vela-calls.log'); +} + +async function velaCalls(): Promise { + const log = process.env.OD_TEST_VELA_LOG; + if (!log) return []; + try { + return (await readFile(log, 'utf8')).split('\n').filter(Boolean); + } catch { + return []; + } +} + +async function ensureScratch(): Promise { + scratch ??= await mkdtemp(join(tmpdir(), 'od-presence-transport-off-')); + return scratch; +} + +async function startIsolatedServer(): Promise<{ + get(route: string): Promise<{ status: number; body: Record }>; + post( + route: string, + body: unknown, + ): Promise<{ status: number; body: Record }>; +}> { + const root = await ensureScratch(); + process.env.OD_DATA_DIR = join(root, 'data'); + if (!serverModule) { + vi.resetModules(); + serverModule = (await import('../src/server.js')) as unknown as ServerModule; + } + started = await serverModule.startServer({ port: 0, returnServer: true }); + const base = started.url; + const read = async (response: Response) => ({ + status: response.status, + body: (await response.json()) as Record, + }); + return { + async get(route: string) { + return read( + await fetch(`${base}${route}`, { + headers: devWorkspaceHeaders(), + }), + ); + }, + async post(route: string, body: unknown) { + return read( + await fetch(`${base}${route}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...devWorkspaceHeaders(), + }, + body: JSON.stringify(body), + }), + ); + }, + }; +} + +async function stopServer(): Promise { + const current = started; + started = null; + if (!current) return; + await withTimeout(Promise.resolve(current.shutdown?.()), 8_000); + if (current.server) { + current.server.closeAllConnections?.(); + current.server.closeIdleConnections?.(); + await withTimeout( + new Promise((resolve) => current.server.close(() => resolve())), + 8_000, + ); + } +} + +async function withTimeout(promise: Promise, ms: number): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), ms); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} diff --git a/apps/daemon/tests/collab-publish-scheduler.test.ts b/apps/daemon/tests/collab-publish-scheduler.test.ts new file mode 100644 index 00000000000..3281353a5a3 --- /dev/null +++ b/apps/daemon/tests/collab-publish-scheduler.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CollabPublishScheduler } from '../src/collab/publish-scheduler.js'; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('CollabPublishScheduler', () => { + it('coalesces rapid changes into a single publish', async () => { + vi.useFakeTimers(); + const publish = vi.fn().mockResolvedValue({ version: 1 }); + const scheduler = new CollabPublishScheduler({ adapter: { publish }, debounceMs: 100 }); + + scheduler.notifyChanged('p1'); + scheduler.notifyChanged('p1'); + scheduler.notifyChanged('p1'); + expect(publish).not.toHaveBeenCalled(); // still inside the coalesce window + + await vi.advanceTimersByTimeAsync(100); + expect(publish).toHaveBeenCalledTimes(1); + expect(publish).toHaveBeenCalledWith({ projectId: 'p1', reason: 'change' }); + }); + + it('flushes immediately at a run boundary instead of waiting out the debounce', async () => { + vi.useFakeTimers(); + const publish = vi.fn().mockResolvedValue({ version: 2 }); + const scheduler = new CollabPublishScheduler({ adapter: { publish }, debounceMs: 10_000 }); + + scheduler.notifyChanged('p1', 'run'); + expect(publish).not.toHaveBeenCalled(); + + scheduler.runBoundary('p1'); + // flush() calls the adapter synchronously up to its first await. + expect(publish).toHaveBeenCalledTimes(1); + expect(publish).toHaveBeenCalledWith({ projectId: 'p1', reason: 'run' }); + }); + + it('re-publishes when a change arrives while a publish is in flight (no lost change)', async () => { + vi.useFakeTimers(); + let settleFirst: (value: { version: number }) => void = () => {}; + const publish = vi + .fn() + .mockImplementationOnce(() => new Promise<{ version: number }>((resolve) => { + settleFirst = resolve; + })) + .mockResolvedValue({ version: 2 }); + const scheduler = new CollabPublishScheduler({ adapter: { publish }, debounceMs: 100 }); + + scheduler.notifyChanged('p1', 'first'); + await vi.advanceTimersByTimeAsync(100); // fires publish #1, which stays pending + expect(publish).toHaveBeenCalledTimes(1); + + scheduler.notifyChanged('p1', 'later'); // lands mid-publish → marked dirty + expect(publish).toHaveBeenCalledTimes(1); + + settleFirst({ version: 1 }); // publish #1 settles → dirty re-schedules + await vi.advanceTimersByTimeAsync(100); + expect(publish).toHaveBeenCalledTimes(2); + expect(publish).toHaveBeenLastCalledWith({ projectId: 'p1', reason: 'later' }); + }); + + it('reports the published version so the orchestrator can notify members', async () => { + vi.useFakeTimers(); + const onPublished = vi.fn(); + const scheduler = new CollabPublishScheduler({ + adapter: { publish: vi.fn().mockResolvedValue({ version: 7 }) }, + debounceMs: 50, + onPublished, + }); + + scheduler.notifyChanged('p1', 'save'); + await vi.advanceTimersByTimeAsync(50); + expect(onPublished).toHaveBeenCalledWith({ projectId: 'p1', version: 7, reason: 'save' }); + }); + + it('routes a publish failure to onError and stays usable', async () => { + vi.useFakeTimers(); + const onError = vi.fn(); + const boom = new Error('hub down'); + const publish = vi.fn().mockRejectedValueOnce(boom).mockResolvedValue({ version: 1 }); + const scheduler = new CollabPublishScheduler({ adapter: { publish }, debounceMs: 50, onError }); + + scheduler.notifyChanged('p1'); + await vi.advanceTimersByTimeAsync(50); + expect(onError).toHaveBeenCalledWith({ projectId: 'p1', error: boom }); + + // A later change still publishes — a failed publish must not wedge the scheduler. + scheduler.notifyChanged('p1'); + await vi.advanceTimersByTimeAsync(50); + expect(publish).toHaveBeenCalledTimes(2); + }); + + it('keeps per-project publishes independent', async () => { + vi.useFakeTimers(); + const publish = vi.fn().mockResolvedValue({ version: 1 }); + const scheduler = new CollabPublishScheduler({ adapter: { publish }, debounceMs: 100 }); + + scheduler.notifyChanged('a'); + scheduler.notifyChanged('b'); + await vi.advanceTimersByTimeAsync(100); + expect(publish).toHaveBeenCalledTimes(2); + expect(publish.mock.calls.map((call) => call[0].projectId).sort()).toEqual(['a', 'b']); + }); +}); diff --git a/apps/daemon/tests/collab-publish-watcher.test.ts b/apps/daemon/tests/collab-publish-watcher.test.ts new file mode 100644 index 00000000000..6f8b469aa98 --- /dev/null +++ b/apps/daemon/tests/collab-publish-watcher.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createCollabPublishWatcher } from '../src/collab/collab-publish-watcher.js'; +import type { ResourceHubPrincipal } from '../src/collab/resource-principal.js'; + +describe('collab publish watcher', () => { + it('publishes current content once when it first watches an owned+shared project', async () => { + const notifyChanged = vi.fn(); + const onChangeHandlers = new Map void>(); + const watcher = createCollabPublishWatcher({ + notifyChanged, + listProjectIds: () => ['owned-shared', 'someone-elses'], + shouldPublish: async (projectId) => projectId === 'owned-shared', + subscribeFiles: (projectId, onChange) => { + onChangeHandlers.set(projectId, onChange); + return { unsubscribe: () => {} }; + }, + }); + + await watcher.reconcile(); + + // Owned+shared → subscribed AND an initial publish so existing files (which + // the file watcher's ignoreInitial would skip) still reach members. + expect(onChangeHandlers.has('owned-shared')).toBe(true); + expect(notifyChanged).toHaveBeenCalledTimes(1); + expect(notifyChanged).toHaveBeenCalledWith('owned-shared'); + // A project owned by someone else is never watched or published from here. + expect(onChangeHandlers.has('someone-elses')).toBe(false); + }); + + it('does not re-publish an already-watched project on subsequent reconciles', async () => { + const notifyChanged = vi.fn(); + const watcher = createCollabPublishWatcher({ + notifyChanged, + listProjectIds: () => ['p1'], + shouldPublish: async () => true, + subscribeFiles: () => ({ unsubscribe: () => {} }), + }); + + await watcher.reconcile(); + await watcher.reconcile(); + await watcher.reconcile(); + + // Initial publish fires once per watch session, not on every reconcile tick. + expect(notifyChanged).toHaveBeenCalledTimes(1); + }); + + it('publishes on a later file change through the subscribed handler', async () => { + const notifyChanged = vi.fn(); + const handler: { onChange: (() => void) | null } = { onChange: null }; + const watcher = createCollabPublishWatcher({ + notifyChanged, + listProjectIds: () => ['p1'], + shouldPublish: async () => true, + subscribeFiles: (_projectId, onChange) => { + handler.onChange = onChange; + return { unsubscribe: () => {} }; + }, + }); + + await watcher.reconcile(); + notifyChanged.mockClear(); + handler.onChange?.(); + + expect(notifyChanged).toHaveBeenCalledTimes(1); + expect(notifyChanged).toHaveBeenCalledWith('p1'); + }); + + it('keeps the verified workspace principal captured by the watch', async () => { + const principal: ResourceHubPrincipal = { + teamId: 'workspace-a', + memberId: 'member-a', + role: 'owner', + lifecycleState: 'active', + workspaceType: 'team', + }; + const notifyChanged = vi.fn(); + const handler: { onChange: (() => void) | null } = { onChange: null }; + const watcher = createCollabPublishWatcher({ + notifyChanged, + listProjectIds: () => ['p1'], + shouldPublish: async () => principal, + subscribeFiles: (_projectId, onChange) => { + handler.onChange = onChange; + return { unsubscribe: () => {} }; + }, + }); + + await watcher.reconcile(); + expect(notifyChanged).toHaveBeenLastCalledWith('p1', principal); + + notifyChanged.mockClear(); + handler.onChange?.(); + expect(notifyChanged).toHaveBeenCalledOnce(); + expect(notifyChanged).toHaveBeenCalledWith('p1', principal); + }); +}); diff --git a/apps/daemon/tests/collab-runtime-metadata-refresh.test.ts b/apps/daemon/tests/collab-runtime-metadata-refresh.test.ts new file mode 100644 index 00000000000..f7040251c59 --- /dev/null +++ b/apps/daemon/tests/collab-runtime-metadata-refresh.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createCollabRuntime, type CollabRuntime } from '../src/collab/runtime.js'; +import type { ResourcePublishAdapter } from '../src/collab/publish-scheduler.js'; +import type { ResourceHubPrincipal } from '../src/collab/resource-principal.js'; + +describe('shared-project metadata refresh', () => { + let runtime: CollabRuntime | null = null; + + afterEach(() => { + runtime?.dispose(); + runtime = null; + }); + + it('re-upserts an owner rename for every remembered Team share without publishing content', async () => { + let projectName = 'Before rename'; + const publish = vi.fn(); + const upsert = vi.fn(async () => {}); + const adapter: ResourcePublishAdapter = { + publish, + }; + const principal: ResourceHubPrincipal = { + memberId: 'owner-member', + teamId: 'team-1', + role: 'owner', + lifecycleState: 'active', + }; + runtime = createCollabRuntime({ + adapter, + describeProject: () => ({ name: projectName }), + teamProjectCatalog: { + upsert, + remove: vi.fn(async () => {}), + }, + }); + runtime.rememberTeamShare('shared-project', principal, 'synced'); + + projectName = 'Owner renamed project'; + runtime.refreshTeamProjectMetadata('shared-project'); + + await vi.waitFor(() => { + expect(upsert).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 'shared-project', + displayName: 'Owner renamed project', + metadata: expect.objectContaining({ name: 'Owner renamed project' }), + syncState: 'synced', + }), + principal, + ); + }); + expect(publish).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/daemon/tests/collab-runtime-upload-badge-window.test.ts b/apps/daemon/tests/collab-runtime-upload-badge-window.test.ts new file mode 100644 index 00000000000..39727e6542a --- /dev/null +++ b/apps/daemon/tests/collab-runtime-upload-badge-window.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createCollabRuntime, type CollabRuntime } from '../src/collab/runtime.js'; + +// recvqghymxqQQq follow-up: characterizes how long `syncState` actually stays +// 'pending_upload' after a local (owner-side) file edit on an already-shared +// project, per `markLocalChangePending` in ../src/collab/runtime.ts. The web +// client (apps/web/src/collab/collab-client.ts) only learns the current +// syncState via a fixed 5s poll unless something calls `checkStatusNow()` +// sooner — this test proves the transient window the poll has to land in is, +// with the default debounce and a fast (stub) publish, an order of magnitude +// shorter than that poll interval, so a blind 5s poll will usually observe +// 'synced' on both sides of the edit and never catch 'pending_upload'. +describe('collab runtime — owner upload-badge transient window', () => { + it('flips pending_upload -> synced well inside the web client\'s 5s status-poll cadence', async () => { + const runtime: CollabRuntime = createCollabRuntime({ + adapter: { publish: async () => ({ version: 2 }) }, + }); + try { + const projectId = 'shared-project'; + const principal = { + memberId: 'owner-1', + teamId: 'team-1', + role: 'admin' as const, + lifecycleState: 'active' as const, + }; + + await runtime.requestTeamShare(projectId, principal); + expect(runtime.projectSyncState(projectId, principal)).toBe('synced'); + + // Simulate collab-publish-watcher.ts's onChange handler firing right + // after chokidar observes a real file edit on disk. + runtime.scheduler.notifyChanged(projectId, 'file-change'); + expect(runtime.projectSyncState(projectId, principal)).toBe('pending_upload'); + + // Wait past the scheduler's default 400ms debounce plus a fast publish + // — comfortably under CollabClient's 5_000ms DEFAULT_STATUS_POLL_MS. + await new Promise((resolve) => setTimeout(resolve, 600)); + expect(runtime.projectSyncState(projectId, principal)).toBe('synced'); + } finally { + runtime.dispose(); + } + }); +}); + +describe('collab runtime — member pull materialized version', () => { + it('returns the version reported by the pull instead of a newer post-pull head', async () => { + const syncLatest = vi.fn(async () => ({ version: 2 })); + const runtime = createCollabRuntime({ + adapter: { + publish: async () => null, + pull: async () => ({ version: 1, versionId: 'v1' }), + syncLatest, + }, + }); + + try { + await expect(runtime.pullLatest('shared-project')).resolves.toEqual({ version: 1 }); + expect(syncLatest).not.toHaveBeenCalled(); + } finally { + runtime.dispose(); + } + }); +}); diff --git a/apps/daemon/tests/collab-status-local-project-fast-path.test.ts b/apps/daemon/tests/collab-status-local-project-fast-path.test.ts new file mode 100644 index 00000000000..91cd23afd31 --- /dev/null +++ b/apps/daemon/tests/collab-status-local-project-fast-path.test.ts @@ -0,0 +1,600 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import express, { type Request } from 'express'; +import http from 'node:http'; +import { + buildWorkspacePermissions, + buildWorkspaceSeatSummary, + type WorkspaceCollabContext, +} from '@open-design/contracts'; +import { createCollabRuntime, type CollabRuntime } from '../src/collab/runtime.js'; +import type { WorkspaceContextProvider } from '../src/collab/workspace-context.js'; +import { registerCollabSyncRoutes } from '../src/routes/collab-sync.js'; + +let server: http.Server | null = null; + +afterEach(async () => { + if (server) { + const toClose = server; + server = null; + await new Promise((resolve) => toClose.close(() => resolve())); + } +}); + +function memberContextProvider(workspaceMemberId: string): WorkspaceContextProvider { + const context: WorkspaceCollabContext = { + workspaceId: 'ws-1', + workspaceType: 'team', + teamId: 'team-1', + workspaceMemberId, + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + billingState: 'active', + planId: null, + providerMode: 'platform_credits', + seatSummary: buildWorkspaceSeatSummary({ seatLimit: 5, usedSeats: 1 }), + permissions: buildWorkspacePermissions({ role: 'member', lifecycleState: 'active' }), + }; + return { current: async () => context }; +} + +function teamContext( + workspaceId: string, + workspaceMemberId: string, + teamId = 'team-1', +): WorkspaceCollabContext { + return { + workspaceId, + workspaceType: 'team', + teamId, + workspaceMemberId, + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + billingState: 'active', + planId: null, + providerMode: 'platform_credits', + seatSummary: buildWorkspaceSeatSummary({ seatLimit: 5, usedSeats: 1 }), + permissions: buildWorkspacePermissions({ role: 'member', lifecycleState: 'active' }), + }; +} + +function workspaceHeaders(context: WorkspaceCollabContext): Record { + return { + 'x-od-workspace-id': context.workspaceId, + 'x-od-workspace-member-id': context.workspaceMemberId, + }; +} + +function verifiedScopeDeps(context: WorkspaceCollabContext) { + return { + verifyWorkspaceRequest: async (req: Request) => + req.header('x-od-workspace-id') === context.workspaceId + && req.header('x-od-workspace-member-id') === context.workspaceMemberId + ? context + : null, + verifyWorkspaceScope: async (scope: { + workspaceId: string; + resourceTeamId: string; + viewerMemberId: string; + }) => + context.workspaceType === 'team' + && scope.workspaceId === context.workspaceId + && scope.resourceTeamId === context.teamId + && scope.viewerMemberId === context.workspaceMemberId, + }; +} + +/** + * A local-only, unowned project must NOT trigger the resource-hub published-head + * lookup. That call is an uncached ~2s round-trip; running it on every status + * poll made a member's own project sit in the front end's fail-closed + * "shared read-only" state for seconds before /collab/status confirmed ownership. + */ +describe('collab/status local-only fast path', () => { + it('skips publishedHead when the project is local-only and unowned', async () => { + const context = teamContext('ws-1', 'viewer-member'); + const runtime = createCollabRuntime() as CollabRuntime & { + publishedHead: CollabRuntime['publishedHead']; + }; + let headCalls = 0; + const originalHead = runtime.publishedHead.bind(runtime); + runtime.publishedHead = ((projectId: string, principal: unknown) => { + headCalls += 1; + return originalHead(projectId, principal as never); + }) as CollabRuntime['publishedHead']; + + let ownerLookups = 0; + const app = express(); + app.use(express.json()); + registerCollabSyncRoutes(app, { + collab: runtime, + ...verifiedScopeDeps(context), + resolveSharedProjectOwner: async () => { + ownerLookups += 1; + return null; // not shared to the team + }, + }); + server = http.createServer(app); + await new Promise((resolve) => server!.listen(0, resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('no port'); + const base = `http://127.0.0.1:${address.port}`; + + const res = await fetch(`${base}/api/projects/my-local-project/collab/status`, { + headers: workspaceHeaders(context), + }); + const body = (await res.json()) as Record; + + expect(res.status).toBe(200); + expect(body.syncState).toBe('local_only'); + expect(body.ownerMemberId).toBeNull(); + expect(body.publishedVersion).toBeNull(); + // The cheap cached owner lookup ran; the expensive hub head lookup did not. + expect(ownerLookups).toBe(1); + expect(headCalls).toBe(0); + }); + + it('consults publishedHead for a NON-owner member of a shared project', async () => { + const context = teamContext('ws-1', 'viewer-member'); + const runtime = createCollabRuntime({ + workspaceContext: memberContextProvider('viewer-member'), + }) as CollabRuntime & { publishedHead: CollabRuntime['publishedHead'] }; + let headCalls = 0; + const originalHead = runtime.publishedHead.bind(runtime); + runtime.publishedHead = ((projectId: string, principal: unknown) => { + headCalls += 1; + return originalHead(projectId, principal as never); + }) as CollabRuntime['publishedHead']; + + let nameLookups = 0; + const app = express(); + app.use(express.json()); + registerCollabSyncRoutes(app, { + collab: runtime, + ...verifiedScopeDeps(context), + resolveSharedProjectOwner: async () => 'member-owner', // someone else owns it + resolveOwnerDisplayName: async () => { + nameLookups += 1; + return { displayName: 'Owner', role: 'member' }; + }, + }); + server = http.createServer(app); + await new Promise((resolve) => server!.listen(0, resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('no port'); + const base = `http://127.0.0.1:${address.port}`; + + const res = await fetch(`${base}/api/projects/shared-project/collab/status`, { + headers: workspaceHeaders(context), + }); + const body = (await res.json()) as Record; + + expect(res.status).toBe(200); + expect(body.ownerMemberId).toBe('member-owner'); + expect(body.syncState).toBe('synced'); + expect(body.ownerDisplayName).toBeUndefined(); + // Remote enrichment never gates the local shared identity. Once it settles, + // the next status poll consumes the scoped cache. + await new Promise((resolve) => setImmediate(resolve)); + expect(headCalls).toBe(1); + expect(nameLookups).toBe(1); + const enrichedRes = await fetch( + `${base}/api/projects/shared-project/collab/status`, + { headers: workspaceHeaders(context) }, + ); + const enrichedBody = (await enrichedRes.json()) as Record; + expect(enrichedRes.status).toBe(200); + expect(enrichedBody.ownerDisplayName).toBe('Owner'); + // The owner directory entry is TTL-cached. The head is refreshed in the + // background on every poll so auto-pull freshness keeps advancing. + expect(nameLookups).toBe(1); + expect(headCalls).toBe(2); + }); + + it('returns local shared identity before remote owner-name and head enrichment settle', async () => { + const context = teamContext('ws-1', 'viewer-member'); + const runtime = createCollabRuntime({ + workspaceContext: memberContextProvider('viewer-member'), + }) as CollabRuntime & { publishedHead: CollabRuntime['publishedHead'] }; + runtime.rememberTeamShare( + 'shared-local-project', + { + teamId: 'team-1', + memberId: 'member-owner', + role: 'member', + lifecycleState: 'active', + workspaceType: 'team', + }, + 'synced', + ); + + let resolveHead!: (value: number | null) => void; + const pendingHead = new Promise((resolve) => { + resolveHead = resolve; + }); + let resolveOwnerName!: ( + value: { displayName: string; role: 'member' } | null + ) => void; + const pendingOwnerName = new Promise<{ displayName: string; role: 'member' } | null>( + (resolve) => { + resolveOwnerName = resolve; + }, + ); + + let headCalls = 0; + let nameLookups = 0; + let resolveRemoteLookupsStarted!: () => void; + const remoteLookupsStarted = new Promise((resolve) => { + resolveRemoteLookupsStarted = resolve; + }); + const markRemoteLookupStarted = () => { + if (headCalls === 1 && nameLookups === 1) resolveRemoteLookupsStarted(); + }; + runtime.publishedHead = (() => { + headCalls += 1; + markRemoteLookupStarted(); + return pendingHead; + }) as CollabRuntime['publishedHead']; + + const app = express(); + app.use(express.json()); + registerCollabSyncRoutes(app, { + collab: runtime, + ...verifiedScopeDeps(context), + resolveOwnerDisplayName: async () => { + nameLookups += 1; + markRemoteLookupStarted(); + return pendingOwnerName; + }, + }); + server = http.createServer(app); + await new Promise((resolve) => server!.listen(0, resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('no port'); + const base = `http://127.0.0.1:${address.port}`; + + const responsePromise = fetch( + `${base}/api/projects/shared-local-project/collab/status`, + { headers: workspaceHeaders(context) }, + ); + await remoteLookupsStarted; + const firstResult = await Promise.race([ + responsePromise.then((response) => ({ kind: 'response' as const, response })), + new Promise<{ kind: 'blocked' }>((resolve) => { + setTimeout(() => resolve({ kind: 'blocked' }), 100); + }), + ]); + + // Always release the injected remote calls so the old-code red test can + // finish cleanly instead of leaving the HTTP server with an open request. + resolveHead(7); + resolveOwnerName({ displayName: 'Owner', role: 'member' }); + const res = + firstResult.kind === 'response' + ? firstResult.response + : await responsePromise; + const body = (await res.json()) as Record; + + expect(firstResult.kind).toBe('response'); + expect(res.status).toBe(200); + expect(body.ownerMemberId).toBe('member-owner'); + expect(body.syncState).toBe('synced'); + expect(headCalls).toBe(1); + expect(nameLookups).toBe(1); + + await new Promise((resolve) => setImmediate(resolve)); + const enrichedRes = await fetch( + `${base}/api/projects/shared-local-project/collab/status`, + { headers: workspaceHeaders(context) }, + ); + const enrichedBody = (await enrichedRes.json()) as Record; + expect(enrichedRes.status).toBe(200); + expect(enrichedBody.ownerDisplayName).toBe('Owner'); + expect(enrichedBody.publishedVersion).toBe(7); + }); + + it('keeps explicit workspace enrichment isolated when resource identity is shared', async () => { + const contexts = [ + teamContext('workspace-a', 'viewer-member', 'shared-resource-team'), + teamContext('workspace-b', 'viewer-member', 'shared-resource-team'), + ]; + let verificationReads = 0; + const runtime = createCollabRuntime() as CollabRuntime & { + publishedHead: CollabRuntime['publishedHead']; + }; + runtime.rememberTeamShare( + 'switching-project', + { + teamId: 'shared-resource-team', + memberId: 'member-owner', + role: 'member', + lifecycleState: 'active', + workspaceType: 'team', + }, + 'synced', + ); + runtime.publishedHead = (async () => { + return 9; + }) as CollabRuntime['publishedHead']; + const materializedScopes: string[] = []; + + const app = express(); + app.use(express.json()); + registerCollabSyncRoutes(app, { + collab: runtime, + verifyWorkspaceRequest: async (req) => { + verificationReads += 1; + return contexts.find( + (context) => + req.header('x-od-workspace-id') === context.workspaceId + && req.header('x-od-workspace-member-id') === context.workspaceMemberId, + ) ?? null; + }, + verifyWorkspaceScope: async (scope) => + contexts.some( + (context) => + scope.workspaceId === context.workspaceId + && scope.resourceTeamId === context.teamId + && scope.viewerMemberId === context.workspaceMemberId, + ), + resolveOwnerDisplayName: async () => ({ + displayName: 'Owner', + role: 'member', + }), + readMaterializedVersion: (_projectId, scope) => { + materializedScopes.push(scope.workspaceId); + return scope.workspaceId === 'workspace-a' ? 11 : 22; + }, + }); + server = http.createServer(app); + await new Promise((resolve) => server!.listen(0, resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('no port'); + const base = `http://127.0.0.1:${address.port}`; + + const firstA = await fetch( + `${base}/api/projects/switching-project/collab/status`, + { headers: workspaceHeaders(contexts[0]!) }, + ); + const firstABody = (await firstA.json()) as Record; + await new Promise((resolve) => setImmediate(resolve)); + + expect(firstA.status).toBe(200); + expect(firstABody.ownerMemberId).toBe('member-owner'); + expect(firstABody.syncState).toBe('synced'); + expect(firstABody.ownerDisplayName).toBeUndefined(); + expect(firstABody.publishedVersion).toBeNull(); + + // A/B deliberately share the same resource team, viewer, owner and + // project. The explicit Workspace selector must still keep their caches + // isolated. + const firstB = await fetch( + `${base}/api/projects/switching-project/collab/status`, + { headers: workspaceHeaders(contexts[1]!) }, + ); + const firstBBody = (await firstB.json()) as Record; + expect(firstBBody.ownerDisplayName).toBeUndefined(); + expect(firstBBody.publishedVersion).toBeNull(); + expect(firstBBody.materializedVersion).toBeNull(); + await new Promise((resolve) => setImmediate(resolve)); + + const enrichedB = await fetch( + `${base}/api/projects/switching-project/collab/status`, + { headers: workspaceHeaders(contexts[1]!) }, + ); + const enrichedBBody = (await enrichedB.json()) as Record; + expect(enrichedBBody.ownerDisplayName).toBe('Owner'); + expect(enrichedBBody.publishedVersion).toBe(9); + expect(enrichedBBody.materializedVersion).toBe(22); + + const revisitedA = await fetch( + `${base}/api/projects/switching-project/collab/status`, + { headers: workspaceHeaders(contexts[0]!) }, + ); + const revisitedABody = (await revisitedA.json()) as Record; + expect(revisitedABody.ownerDisplayName).toBe('Owner'); + expect(revisitedABody.publishedVersion).toBe(9); + expect(revisitedABody.materializedVersion).toBe(11); + expect(materializedScopes).toEqual(['workspace-b', 'workspace-a']); + // One authoritative verification per status request; background enrichment + // reuses the captured identity instead of reading an ambient active one. + expect(verificationReads).toBe(4); + }); + + it('does not enrich an owner name from a personal workspace without a team principal', async () => { + const context: WorkspaceCollabContext = { + workspaceId: 'personal-workspace', + workspaceType: 'personal', + workspaceMemberId: 'personal-member', + 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 personalContext: WorkspaceContextProvider = { + current: async () => context, + }; + const runtime = createCollabRuntime({ + workspaceContext: personalContext, + }); + runtime.rememberTeamShare( + 'personal-context-project', + { + teamId: 'team-1', + memberId: 'member-owner', + role: 'member', + lifecycleState: 'active', + workspaceType: 'team', + }, + 'synced', + ); + let ownerNameLookups = 0; + + const app = express(); + app.use(express.json()); + registerCollabSyncRoutes(app, { + collab: runtime, + ...verifiedScopeDeps(context), + resolveOwnerDisplayName: async () => { + ownerNameLookups += 1; + return { displayName: 'Owner', role: 'member' }; + }, + }); + server = http.createServer(app); + await new Promise((resolve) => server!.listen(0, resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('no port'); + const base = `http://127.0.0.1:${address.port}`; + + const res = await fetch( + `${base}/api/projects/personal-context-project/collab/status`, + { headers: workspaceHeaders(context) }, + ); + const body = (await res.json()) as Record; + await new Promise((resolve) => setImmediate(resolve)); + + expect(res.status).toBe(200); + expect(body.ownerMemberId).toBe('member-owner'); + expect(body.syncState).toBe('synced'); + expect(body.ownerDisplayName).toBeUndefined(); + expect(ownerNameLookups).toBe(0); + }); + + it('bounds scoped enrichment caches with LRU eviction and never reuses an evicted scope', async () => { + const runtime = createCollabRuntime() as CollabRuntime & { + publishedHead: CollabRuntime['publishedHead']; + }; + runtime.publishedHead = (async ( + _projectId: string, + principal: { teamId: string } | null | undefined, + ) => { + const scopeIndex = Number(principal?.teamId.replace('workspace-', '')); + return 1_000 + scopeIndex; + }) as CollabRuntime['publishedHead']; + + const app = express(); + app.use(express.json()); + registerCollabSyncRoutes(app, { + collab: runtime, + verifyWorkspaceRequest: async (req) => { + const workspaceId = req.header('x-od-workspace-id'); + const memberId = req.header('x-od-workspace-member-id'); + return workspaceId && memberId + ? teamContext(workspaceId, memberId, workspaceId) + : null; + }, + verifyWorkspaceScope: async (scope) => + scope.workspaceId === scope.resourceTeamId + && scope.viewerMemberId.startsWith('viewer-'), + resolveSharedProjectOwner: async () => 'member-owner', + resolveOwnerDisplayName: async () => ({ + displayName: 'Owner', + role: 'member', + }), + }); + server = http.createServer(app); + await new Promise((resolve) => server!.listen(0, resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('no port'); + const base = `http://127.0.0.1:${address.port}`; + const readStatus = async (scopeIndex: number) => { + const response = await fetch( + `${base}/api/projects/lru-project/collab/status`, + { + headers: { + 'x-od-workspace-id': `workspace-${scopeIndex}`, + 'x-od-workspace-member-id': `viewer-${scopeIndex}`, + 'x-od-workspace-role': 'member', + }, + }, + ); + return { + response, + body: (await response.json()) as Record, + }; + }; + + // Fill the exact 256-entry bound, letting each scope's asynchronous + // enrichment settle before inserting the next one. + for (let scopeIndex = 0; scopeIndex < 256; scopeIndex += 1) { + await readStatus(scopeIndex); + await new Promise((resolve) => setImmediate(resolve)); + } + + // Touch scope 0 so it becomes most-recently used, then overflow by one. + const touched = await readStatus(0); + expect(touched.body.ownerDisplayName).toBe('Owner'); + expect(touched.body.publishedVersion).toBe(1_000); + await new Promise((resolve) => setImmediate(resolve)); + await readStatus(256); + await new Promise((resolve) => setImmediate(resolve)); + + // Scope 1, now the least-recent entry, was evicted from BOTH caches. Its + // first revisit gets only local identity and cannot reuse old enrichment. + const evicted = await readStatus(1); + expect(evicted.response.status).toBe(200); + expect(evicted.body.ownerMemberId).toBe('member-owner'); + expect(evicted.body.syncState).toBe('synced'); + expect(evicted.body.ownerDisplayName).toBeUndefined(); + expect(evicted.body.publishedVersion).toBeNull(); + await new Promise((resolve) => setImmediate(resolve)); + + // The touched scope survived the overflow and still exposes its own head. + const retained = await readStatus(0); + expect(retained.body.ownerDisplayName).toBe('Owner'); + expect(retained.body.publishedVersion).toBe(1_000); + }); + + it('skips publishedHead when the caller IS the owner of a shared project', async () => { + const context = teamContext('ws-1', 'member-owner'); + const runtime = createCollabRuntime({ + workspaceContext: memberContextProvider('member-owner'), + }) as CollabRuntime & { publishedHead: CollabRuntime['publishedHead'] }; + let headCalls = 0; + const originalHead = runtime.publishedHead.bind(runtime); + runtime.publishedHead = ((projectId: string, principal: unknown) => { + headCalls += 1; + return originalHead(projectId, principal as never); + }) as CollabRuntime['publishedHead']; + + let nameLookups = 0; + const app = express(); + app.use(express.json()); + registerCollabSyncRoutes(app, { + collab: runtime, + ...verifiedScopeDeps(context), + resolveSharedProjectOwner: async () => 'member-owner', // caller owns it + resolveOwnerDisplayName: async () => { + nameLookups += 1; + return { displayName: 'Owner', role: 'member' }; + }, + }); + server = http.createServer(app); + await new Promise((resolve) => server!.listen(0, resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('no port'); + const base = `http://127.0.0.1:${address.port}`; + + const res = await fetch(`${base}/api/projects/my-shared-project/collab/status`, { + headers: workspaceHeaders(context), + }); + const body = (await res.json()) as Record; + + expect(res.status).toBe(200); + expect(body.ownerMemberId).toBe('member-owner'); + expect(body.syncState).toBe('synced'); + // The owner is the single writer, never auto-pulls, and sees an editable + // surface (no "shared by X" banner) — so BOTH the hub head lookup and the + // owner-name directory lookup are skipped; their editable state resolves fast. + expect(headCalls).toBe(0); + expect(nameLookups).toBe(0); + }); +}); diff --git a/apps/daemon/tests/collab-sync-routes.test.ts b/apps/daemon/tests/collab-sync-routes.test.ts new file mode 100644 index 00000000000..28b7f6cdd5d --- /dev/null +++ b/apps/daemon/tests/collab-sync-routes.test.ts @@ -0,0 +1,4935 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import express from 'express'; +import http from 'node:http'; +import { + lstat, + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { + buildWorkspacePermissions, + buildWorkspaceSeatSummary, + type WorkspaceCollabContext, +} from '@open-design/contracts'; +import { runVelaResourceCommand } from '../src/collab/vela-cli-resource-adapter.js'; +import { + createCollabRuntime, + type CollabRuntime, + type CreateCollabRuntimeOptions, +} from '../src/collab/runtime.js'; +import { contextToResourceHubPrincipal } from '../src/collab/resource-principal.js'; +import type { WorkspaceContextProvider } from '../src/collab/workspace-context.js'; +import { + createProactiveContentPull, + type ProactiveContentPullTarget, + type ProactivePullAuthorizationWitness, +} from '../src/collab/proactive-content-pull.js'; +import { createProjectContentTransferStateStore } from '../src/collab/project-content-transfer-state.js'; +import { createSwrCache } from '../src/collab/swr-cache.js'; +import { resolveAuthorizedActiveTeamWorkspaceSnapshot } from '../src/collab/active-workspace-selection.js'; +import { verifyWorkspaceRequestContext } from '../src/collab/request-workspace-context.js'; +import { createCachedWorkspaceDirectoryFetcher } from '../src/collab/vela-workspace-context.js'; +import { + promoteAuthorizedTeamProjectStage, + type PromoteAuthorizedTeamProjectStageInput, +} from '../src/collab/team-mirror-promotion.js'; +import { + getTeamProjectMaterialization, + materializePulledTeamMirror, +} from '../src/collab/team-mirror-materializer.js'; +import { SHARED_PROJECT_PLACEHOLDER_METADATA_KEY } from '../src/collab/shared-project-placeholder.js'; +import { withLastKnownWorkspaceContext } from '../src/collab/workspace-context.js'; +import { closeDatabase, getProject, openDatabase } from '../src/db.js'; +import { readVelaControlApiContext } from '../src/integrations/vela.js'; +import { projectResourceIdFor } from '../src/integrations/vela-team-projects.js'; +import { + registerCollabSyncRoutes, + type CollabSyncRoutesHandle, + type PulledProjectStore, + type RegisterCollabSyncRoutesDeps, + type RegisterPulledProjectInput, + type TeamMirrorPullScope, +} from '../src/routes/collab-sync.js'; +import { writeProjectManifest } from '../src/project-locations.js'; +import type { AuthorizedTeamProjectPullReceipt } from '../src/collab/authorized-team-project-pull.js'; + +vi.mock('../src/collab/vela-cli-resource-adapter.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runVelaResourceCommand: vi.fn(), + }; +}); + +vi.mock('../src/integrations/vela.js', () => ({ + readVelaControlApiContext: vi.fn(() => null), +})); + +/** In-memory project store standing in for the daemon's SQLite-backed store, so + * a route test can assert register-on-pull without a real database. */ +function fakeProjectStore(): PulledProjectStore & { + projects: Map; + bindings: Map; + registerCalls: number; +} { + const projects = new Map(); + const bindings = new Map(); + const store = { + projects, + bindings, + registerCalls: 0, + get: (projectId: string) => projects.get(projectId) ?? null, + has: (projectId: string) => projects.has(projectId), + register(input: RegisterPulledProjectInput) { + store.registerCalls += 1; + projects.set(input.id, input); + }, + update(input: RegisterPulledProjectInput) { + projects.set(input.id, input); + }, + materializeTeamMirror(input: RegisterPulledProjectInput, scope: TeamMirrorPullScope) { + const existing = projects.get(input.id); + const localRecordChanged = !existing || existing.name === '共享项目'; + if (localRecordChanged) projects.set(input.id, input); + bindings.set(input.id, scope); + return { localRecordChanged }; + }, + materializeAuthorizedTeamMirror( + input: RegisterPulledProjectInput, + scope: TeamMirrorPullScope, + _receipt: AuthorizedTeamProjectPullReceipt, + ) { + return store.materializeTeamMirror(input, scope); + }, + }; + return store; +} + +/** A personal (non-team) workspace context — the default a fresh account lands + * on. Non-null and fully populated, but with no `teamId`: the resource hub is + * addressed by its `workspaceId` instead, as a partition of one. */ +function personalContextProvider(): WorkspaceContextProvider { + const context: WorkspaceCollabContext = { + workspaceId: 'ws-personal-1', + workspaceType: 'personal', + workspaceMemberId: 'wm-personal-1', + 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' }), + }; + return { current: async () => context }; +} + +/** A fixed team context whose `canShareProjects` bit is forced to the tested + * value, served by a minimal provider (no `set` seam). */ +function fixedShareContextProvider(canShareProjects: boolean): WorkspaceContextProvider { + const context: WorkspaceCollabContext = { + workspaceId: 'ws-1', + workspaceType: 'team', + teamId: 'team-1', + workspaceMemberId: 'wm-1', + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + billingState: 'active', + planId: null, + providerMode: 'platform_credits', + seatSummary: buildWorkspaceSeatSummary({ seatLimit: 5, usedSeats: 1 }), + permissions: { + ...buildWorkspacePermissions({ role: 'member', lifecycleState: 'active' }), + canShareProjects, + }, + }; + return { current: async () => context }; +} + +function teamContext( + workspaceId: string, + workspaceMemberId: string, +): WorkspaceCollabContext { + return { + workspaceId, + workspaceType: 'team', + teamId: workspaceId, + workspaceMemberId, + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + billingState: 'active', + planId: null, + providerMode: 'platform_credits', + seatSummary: buildWorkspaceSeatSummary({ seatLimit: 5, usedSeats: 1 }), + permissions: buildWorkspacePermissions({ + role: 'owner', + lifecycleState: 'active', + }), + }; +} + +async function mintProactivePullWitness( + projectId: string, + scope: TeamMirrorPullScope, + version: number, +): Promise { + return (await mintProactivePullTarget(projectId, scope, version)) + .authorizationWitness!; +} + +async function mintProactivePullTarget( + projectId: string, + scope: TeamMirrorPullScope, + version: number, +): Promise { + const targets: ProactiveContentPullTarget[] = []; + const pull = createProactiveContentPull({ + getLocalBinding: () => ({ + workspaceId: scope.workspaceId, + visibility: 'team', + }), + getWorkspaceIdentity: async () => ({ + workspaceId: scope.workspaceId, + resourceTeamId: scope.resourceTeamId, + workspaceMemberId: scope.viewerMemberId, + }), + resolveSharedProjectOwner: async () => scope.ownerMemberId, + pullSharedProject: async (input) => { + targets.push(input); + return { status: 'pulled', version }; + }, + }); + try { + await pull.handleContentChanged({ + projectId, + workspaceId: scope.workspaceId, + version, + }); + } finally { + pull.dispose(); + } + const target = targets[0]; + if (!target?.authorizationWitness) { + throw new Error('expected proactive guard to issue a witness'); + } + return target; +} + +async function invokeThroughProactivePull( + handle: CollabSyncRoutesHandle, + projectId: string, + scope: TeamMirrorPullScope, + version: number, + profileReceivedAtMs?: number, +) { + let outcome: Awaited> + | undefined; + const pull = createProactiveContentPull({ + getLocalBinding: () => ({ + workspaceId: scope.workspaceId, + visibility: 'team', + }), + getWorkspaceIdentity: async () => ({ + workspaceId: scope.workspaceId, + resourceTeamId: scope.resourceTeamId, + workspaceMemberId: scope.viewerMemberId, + }), + resolveSharedProjectOwner: async () => scope.ownerMemberId, + pullSharedProject: async (target, expectedVersion) => { + outcome = await handle.pullSharedProject( + target.projectId, + scope, + target.authorizationWitness, + expectedVersion, + target.authorizedStageInvocation, + ); + return outcome; + }, + }); + await pull.handleContentChanged({ + projectId, + workspaceId: scope.workspaceId, + version, + ...(profileReceivedAtMs != null ? { profileReceivedAtMs } : {}), + }); + if (!outcome) throw new Error('proactive pull did not invoke the route handle'); + return outcome; +} + +let server: http.Server | null = null; +let runtime: CollabRuntime | null = null; +const tempDirs: string[] = []; + +afterEach(async () => { + vi.mocked(runVelaResourceCommand).mockReset(); + vi.mocked(readVelaControlApiContext).mockReturnValue(null); + runtime?.dispose(); // cancel any pending debounce timers + runtime = null; + if (server) { + const toClose = server; + server = null; + await new Promise((resolve) => toClose.close(() => resolve())); + } + while (tempDirs.length > 0) { + const dir = tempDirs.pop()!; + await rm(dir, { recursive: true, force: true }).catch(() => {}); + } +}); + +async function startSyncServer( + workspaceContext?: WorkspaceContextProvider, + extraDeps?: Omit, + runtimeOptions?: Omit, +) { + const app = express(); + app.use(express.json()); + const effectiveWorkspaceContext = + workspaceContext ?? fixedShareContextProvider(true); + // Freeze the test authority once, mirroring an authoritative directory + // fixture. Route verification must never follow later ambient Workspace + // changes from the runtime provider. + const authoritativeContext = + await effectiveWorkspaceContext.current({}); + runtime = createCollabRuntime({ + ...(runtimeOptions ?? {}), + workspaceContext: effectiveWorkspaceContext, + }); + const verifyWorkspaceRequest = async (req: express.Request) => { + if ( + !authoritativeContext + || req.get('x-od-workspace-id') !== authoritativeContext.workspaceId + || req.get('x-od-workspace-member-id') + !== authoritativeContext.workspaceMemberId + ) { + return null; + } + return authoritativeContext; + }; + const verifyWorkspaceScope = async (scope: TeamMirrorPullScope) => { + return Boolean( + authoritativeContext + && authoritativeContext.workspaceType === 'team' + && authoritativeContext.memberStatus === 'active' + && authoritativeContext.lifecycleState === 'active' + && authoritativeContext.workspaceId === scope.workspaceId + && (authoritativeContext.teamId ?? authoritativeContext.workspaceId) + === scope.resourceTeamId + && authoritativeContext.workspaceMemberId === scope.viewerMemberId, + ); + }; + const defaultResolveSharedProject = async ( + projectId: string, + scope?: TeamMirrorPullScope | null, + ) => { + const ownerMemberId = + scope?.ownerMemberId || authoritativeContext?.workspaceMemberId || ''; + return ownerMemberId + ? { + projectId, + ownerMemberId, + sharedAt: '2026-07-30T00:00:00.000Z', + } + : null; + }; + const defaultResolveSharedProjectOwner = async ( + projectId: string, + scope?: { workspaceId: string; workspaceMemberId: string }, + ) => { + if ( + !authoritativeContext + || authoritativeContext.workspaceType !== 'team' + || !scope + || scope.workspaceId !== authoritativeContext.workspaceId + || scope.workspaceMemberId !== authoritativeContext.workspaceMemberId + ) { + return null; + } + return runtime!.projectOwnerMemberId(projectId, { + teamId: + authoritativeContext.teamId ?? authoritativeContext.workspaceId, + memberId: authoritativeContext.workspaceMemberId, + role: authoritativeContext.role, + lifecycleState: authoritativeContext.lifecycleState, + }); + }; + const handle: CollabSyncRoutesHandle = registerCollabSyncRoutes(app, { + collab: runtime, + verifyWorkspaceRequest, + verifyWorkspaceScope, + resolveSharedProject: defaultResolveSharedProject, + resolveSharedProjectOwner: defaultResolveSharedProjectOwner, + ...(extraDeps?.resolveSharedProject && !extraDeps.resolveSharedProjectOwner + ? { + resolveSharedProjectOwner: async ( + projectId: string, + scope?: { workspaceId: string; workspaceMemberId: string }, + ) => + (await extraDeps.resolveSharedProject?.( + projectId, + scope + ? { + workspaceId: scope.workspaceId, + resourceTeamId: scope.workspaceId, + viewerMemberId: scope.workspaceMemberId, + ownerMemberId: '', + } + : null, + ))?.ownerMemberId ?? null, + } + : {}), + ...extraDeps, + }); + server = http.createServer(app); + await new Promise((resolve) => server!.listen(0, resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('server did not bind to a TCP port'); + const base = `http://127.0.0.1:${address.port}`; + return { + handle, + async json( + route: string, + options: { + method?: string; + body?: unknown; + headers?: Record; + workspaceScope?: WorkspaceCollabContext | false; + } = {}, + ) { + const init: RequestInit = { method: options.method ?? 'GET' }; + const workspaceScope = + options.workspaceScope === false + ? null + : options.workspaceScope ?? authoritativeContext; + const requestHeaders: Record = { + ...(workspaceScope + ? { + 'x-od-workspace-id': workspaceScope.workspaceId, + 'x-od-workspace-member-id': workspaceScope.workspaceMemberId, + } + : {}), + ...(options.headers ?? {}), + }; + if (options.body !== undefined) { + init.headers = { 'content-type': 'application/json', ...requestHeaders }; + init.body = JSON.stringify(options.body); + } else if (Object.keys(requestHeaders).length > 0) { + init.headers = requestHeaders; + } + const response = await fetch(`${base}${route}`, init); + return { status: response.status, body: (await response.json()) as Record }; + }, + // Publishing is async (flush → adapter → onPublished); poll until it lands. + async awaitPublishedVersion(route: string, notEqualTo: number | null): Promise { + let version = notEqualTo; + for (let i = 0; i < 40 && version === notEqualTo; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + version = (await this.json(route)).body.publishedVersion; + } + return version; + }, + }; +} + +describe('collab sync routes', () => { + it('keeps publish callbacks scoped to every workspace sharing the same project', async () => { + const onPublished = vi.fn(); + const publish = vi.fn(async (input: { principal?: { memberId?: string } }) => ({ + version: input.principal?.memberId === 'member-a' ? 11 : 22, + })); + runtime = createCollabRuntime({ + adapter: { publish }, + onPublished, + }); + const projectId = 'shared-project'; + const workspaceA = { + memberId: 'member-a', + teamId: 'workspace-a', + role: 'admin' as const, + lifecycleState: 'active' as const, + }; + const workspaceB = { + memberId: 'member-b', + teamId: 'workspace-b', + role: 'admin' as const, + lifecycleState: 'active' as const, + }; + + runtime.requestTeamShare(projectId, workspaceA); + runtime.requestTeamShare(projectId, workspaceB); + + for (let i = 0; i < 40 && (publish.mock.calls.length < 2 || onPublished.mock.calls.length < 2); i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect((publish.mock.calls as unknown as Array<[Record]>).map((call) => call[0])).toEqual( + expect.arrayContaining([ + expect.objectContaining({ projectId, principal: workspaceA }), + expect.objectContaining({ projectId, principal: workspaceB }), + ]), + ); + expect(onPublished.mock.calls.map((call) => call[0]?.principal)).toEqual( + expect.arrayContaining([workspaceA, workspaceB]), + ); + expect(runtime.publishedVersion(projectId, workspaceA)).toBe(11); + expect(runtime.publishedVersion(projectId, workspaceB)).toBe(22); + expect(runtime.projectOwnerMemberId(projectId, workspaceA)).toBe('member-a'); + expect(runtime.projectOwnerMemberId(projectId, workspaceB)).toBe('member-b'); + + publish.mockClear(); + onPublished.mockClear(); + runtime.scheduler.notifyChanged(projectId, 'save'); + runtime.scheduler.runBoundary(projectId); + + for ( + let i = 0; + i < 40 && + (publish.mock.calls.length < 2 || + runtime.publishedVersion(projectId, workspaceA) === null || + runtime.publishedVersion(projectId, workspaceB) === null); + i += 1 + ) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect((publish.mock.calls as unknown as Array<[Record]>).map((call) => call[0])).toEqual( + expect.arrayContaining([ + expect.objectContaining({ projectId, reason: 'save', principal: workspaceA }), + expect.objectContaining({ projectId, reason: 'save', principal: workspaceB }), + ]), + ); + expect(onPublished.mock.calls.map((call) => call[0]?.principal)).toEqual( + expect.arrayContaining([workspaceA, workspaceB]), + ); + expect(runtime.publishedVersion(projectId, workspaceA)).toBe(11); + expect(runtime.publishedVersion(projectId, workspaceB)).toBe(22); + }); + + it('unshares only the requested workspace and keeps other workspace state live', async () => { + const publish = vi.fn(async (input: { principal?: { memberId?: string } }) => ({ + version: input.principal?.memberId === 'member-a' ? 11 : 22, + })); + const unpublish = vi.fn(async () => undefined); + const teamProjectCatalog = { + upsert: vi.fn(async () => undefined), + remove: vi.fn(async () => undefined), + }; + runtime = createCollabRuntime({ + adapter: { publish, unpublish }, + teamProjectCatalog, + }); + const projectId = 'shared-project'; + const workspaceA = { + memberId: 'member-a', + teamId: 'workspace-a', + role: 'admin' as const, + lifecycleState: 'active' as const, + }; + const workspaceB = { + memberId: 'member-b', + teamId: 'workspace-b', + role: 'admin' as const, + lifecycleState: 'active' as const, + }; + + await runtime.requestTeamShare(projectId, workspaceA); + await runtime.requestTeamShare(projectId, workspaceB); + await runtime.requestTeamUnshare(projectId, workspaceA); + + expect(unpublish).toHaveBeenCalledWith({ projectId, principal: workspaceA }); + expect(teamProjectCatalog.remove).toHaveBeenCalledWith(projectId, workspaceA); + expect(runtime.publishedVersion(projectId, workspaceA)).toBeNull(); + expect(runtime.projectSyncState(projectId, workspaceA)).toBe('local_only'); + expect(runtime.projectOwnerMemberId(projectId, workspaceA)).toBeNull(); + expect(runtime.publishedVersion(projectId, workspaceB)).toBe(22); + expect(runtime.projectSyncState(projectId, workspaceB)).toBe('synced'); + expect(runtime.projectOwnerMemberId(projectId, workspaceB)).toBe('member-b'); + expect(runtime.publishedVersion(projectId)).toBe(22); + expect(runtime.projectSyncState(projectId)).toBe('synced'); + + publish.mockClear(); + runtime.scheduler.notifyChanged(projectId, 'save'); + runtime.scheduler.runBoundary(projectId); + for (let i = 0; i < 40 && publish.mock.calls.length < 1; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(publish).toHaveBeenCalledTimes(1); + expect(publish).toHaveBeenCalledWith({ + projectId, + reason: 'save', + principal: workspaceB, + }); + }); + + it('reports ordinary publish failures to every workspace sharing the same project', async () => { + let failPublish = false; + const onError = vi.fn(); + const publish = vi.fn(async (input: { principal?: { memberId?: string } }) => { + if (failPublish) throw new Error('resource hub unavailable'); + return { version: input.principal?.memberId === 'member-a' ? 11 : 22 }; + }); + runtime = createCollabRuntime({ + adapter: { publish }, + onError, + }); + const projectId = 'shared-project'; + const workspaceA = { + memberId: 'member-a', + teamId: 'workspace-a', + role: 'admin' as const, + lifecycleState: 'active' as const, + }; + const workspaceB = { + memberId: 'member-b', + teamId: 'workspace-b', + role: 'admin' as const, + lifecycleState: 'active' as const, + }; + + runtime.requestTeamShare(projectId, workspaceA); + runtime.requestTeamShare(projectId, workspaceB); + + for ( + let i = 0; + i < 40 && + (publish.mock.calls.length < 2 || + runtime.publishedVersion(projectId, workspaceA) === null || + runtime.publishedVersion(projectId, workspaceB) === null); + i += 1 + ) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + publish.mockClear(); + onError.mockClear(); + + failPublish = true; + runtime.scheduler.notifyChanged(projectId, 'change'); + runtime.scheduler.runBoundary(projectId); + + for ( + let i = 0; + i < 40 && + (onError.mock.calls.length < 2 || + runtime.projectSyncState(projectId, workspaceA) !== 'sync_failed' || + runtime.projectSyncState(projectId, workspaceB) !== 'sync_failed'); + i += 1 + ) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(onError.mock.calls.map((call) => call[0]?.principal)).toEqual( + expect.arrayContaining([workspaceA, workspaceB]), + ); + expect(runtime.projectSyncState(projectId, workspaceA)).toBe('sync_failed'); + expect(runtime.projectSyncState(projectId, workspaceB)).toBe('sync_failed'); + }); + + it('does not recreate a failed catalog row when a publish error arrives after unshare', async () => { + let publishCalls = 0; + const teamProjectCatalog = { + upsert: vi.fn(async () => null), + remove: vi.fn(async () => null), + }; + const workspace = { + memberId: 'wm-1', + teamId: 'ws-1', + role: 'member' as const, + lifecycleState: 'active' as const, + }; + runtime = createCollabRuntime({ + adapter: { + publish: async () => { + publishCalls += 1; + if (publishCalls === 1) return { version: 1 }; + throw new Error('project directory removed after unshare'); + }, + unpublish: async () => {}, + }, + workspaceContext: fixedShareContextProvider(true), + teamProjectCatalog, + }); + + await runtime.requestTeamShare('landing', workspace); + for (let i = 0; i < 40 && teamProjectCatalog.upsert.mock.calls.length < 1; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + teamProjectCatalog.upsert.mockClear(); + + await runtime.requestTeamUnshare('landing', workspace); + runtime.scheduler.notifyChanged('landing', 'save'); + runtime.scheduler.runBoundary('landing'); + for (let i = 0; i < 40 && teamProjectCatalog.upsert.mock.calls.length < 1; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + expect(runtime.projectSyncState('landing', workspace)).toBe('local_only'); + expect(teamProjectCatalog.upsert).not.toHaveBeenCalled(); + }); + + it('never re-publishes a fully-unshared project when a stale file-watcher notification arrives after unshare', async () => { + // Regression for "unshare silently reverts to shared" (recvpTXk3liiya): + // the project's chokidar subscription in collab-publish-watcher.ts is + // only torn down when the project is deleted locally, never when it is + // merely unshared. A file touched after the unshare (autosave, thumbnail + // regen, or any write under the project dir) still fires + // `scheduler.notifyChanged`. Before the fix, the scheduler adapter found + // no remaining principal for the project and fell through to publishing + // it anyway under an unscoped resource id — durably re-sharing it for + // the window before `onPublished`'s `unshared` guard noticed and issued + // a compensating unpublish. A status read landing in that window saw the + // project as shared again. + const publish = vi.fn(async () => ({ version: 1 })); + const unpublish = vi.fn(async () => undefined); + runtime = createCollabRuntime({ + adapter: { publish, unpublish }, + }); + const projectId = 'stale-watcher-project'; + const workspace = { + memberId: 'wm-1', + teamId: 'ws-1', + role: 'member' as const, + lifecycleState: 'active' as const, + }; + + await runtime.requestTeamShare(projectId, workspace); + expect(publish).toHaveBeenCalledTimes(1); + + await runtime.requestTeamUnshare(projectId, workspace); + publish.mockClear(); + + // Simulate the stale file-watcher subscription firing after unshare — + // exactly what collab-publish-watcher.ts's leftover `subs` entry does. + runtime.scheduler.notifyChanged(projectId, 'file-change'); + runtime.scheduler.runBoundary(projectId); + for (let i = 0; i < 30; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + expect(publish).not.toHaveBeenCalled(); + expect(runtime.projectOwnerMemberId(projectId, workspace)).toBeNull(); + expect(runtime.projectSyncState(projectId, workspace)).toBe('local_only'); + }); + + it('preserves existing sync state when rememberTeamShare only seeds ownership', () => { + runtime = createCollabRuntime(); + const workspace = { + memberId: 'member-a', + teamId: 'workspace-a', + role: 'admin' as const, + lifecycleState: 'active' as const, + }; + runtime.rememberTeamShare('p1', workspace, 'sync_failed'); + runtime.rememberTeamShare('p1', workspace); + expect(runtime.projectSyncState('p1', workspace)).toBe('sync_failed'); + }); + + it('keeps team-project catalog resource ids scoped per workspace principal', async () => { + const teamProjectCatalog = { + list: vi.fn(), + upsert: vi.fn(async () => null), + }; + runtime = createCollabRuntime({ + teamProjectCatalog, + }); + const projectId = 'landing'; + const workspaceA = { + memberId: 'member-a', + teamId: 'workspace-a', + role: 'admin' as const, + lifecycleState: 'active' as const, + }; + const workspaceB = { + memberId: 'member-b', + teamId: 'workspace-b', + role: 'admin' as const, + lifecycleState: 'active' as const, + }; + + runtime.requestTeamShare(projectId, workspaceA); + runtime.requestTeamShare(projectId, workspaceB); + + for (let i = 0; i < 40 && teamProjectCatalog.upsert.mock.calls.length < 2; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + const resourceIds = ( + teamProjectCatalog.upsert.mock.calls as unknown as Array<[{ + resourceId?: string; + }]> + ).map((call) => call[0]?.resourceId); + expect(resourceIds).toEqual( + expect.arrayContaining([ + projectResourceIdFor(projectId, workspaceA), + projectResourceIdFor(projectId, workspaceB), + ]), + ); + expect(projectResourceIdFor(projectId, workspaceA)).not.toBe(projectResourceIdFor(projectId, workspaceB)); + }); + + it('writes project discovery metadata when publishing a team share through the runtime', async () => { + const descriptor = { + name: 'Electric Studio 2', + skillId: 'deck-builder', + designSystemId: 'ds-emerald', + createdAt: 1719820800000, + updatedAt: 1719907200000, + metadata: { kind: 'deck', entryFile: 'index.html' }, + }; + const teamProjectCatalog = { + upsert: vi.fn(async () => null), + }; + const workspace = { + memberId: 'member-owner', + teamId: 'workspace-team', + role: 'owner' as const, + lifecycleState: 'active' as const, + }; + runtime = createCollabRuntime({ + adapter: { publish: async () => ({ version: 1 }) }, + describeProject: () => descriptor, + teamProjectCatalog, + }); + + await runtime.requestTeamShare('landing', workspace); + for (let i = 0; i < 40 && teamProjectCatalog.upsert.mock.calls.length < 1; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + expect(teamProjectCatalog.upsert).toHaveBeenCalledWith( + { + projectId: 'landing', + resourceId: projectResourceIdFor('landing', workspace), + displayName: 'Electric Studio 2', + syncState: 'synced', + metadata: descriptor, + }, + workspace, + ); + }); + + it('restores persisted team-share principals after runtime restart', async () => { + const projectId = 'shared-after-restart'; + const workspace = { + memberId: 'member-owner', + teamId: 'workspace-restart', + role: 'member' as const, + lifecycleState: 'active' as const, + }; + const initialPublish = vi.fn(async () => ({ version: 1 })); + runtime = createCollabRuntime({ + adapter: { publish: initialPublish }, + }); + runtime.requestTeamShare(projectId, workspace); + for (let i = 0; i < 40 && initialPublish.mock.calls.length < 1; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + runtime.dispose(); + + const publish = vi.fn(async () => ({ version: 2 })); + runtime = createCollabRuntime({ + adapter: { publish }, + }); + runtime.rememberTeamShare(projectId, workspace, 'synced'); + + expect(runtime.projectOwnerMemberId(projectId, workspace)).toBe('member-owner'); + runtime.scheduler.notifyChanged(projectId, 'change'); + runtime.scheduler.runBoundary(projectId); + + for (let i = 0; i < 40 && publish.mock.calls.length < 1; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(publish).toHaveBeenCalledWith(expect.objectContaining({ + projectId, + principal: workspace, + })); + }); + + it('publishes on request and advances the published version monotonically', async () => { + const context = teamContext('workspace-publish', 'member-publish'); + const api = await startSyncServer({ current: async () => context }); + runtime!.rememberTeamShare( + 'p1', + contextToResourceHubPrincipal(context)!, + 'synced', + ); + expect((await api.json('/api/projects/p1/collab/status')).body.publishedVersion).toBeNull(); + + const pub = await api.json('/api/projects/p1/collab/publish', { method: 'POST' }); + expect(pub.status).toBe(200); + expect(pub.body.ok).toBe(true); + + const v1 = await api.awaitPublishedVersion('/api/projects/p1/collab/status', null); + expect(v1).toBe(1); + + await api.json('/api/projects/p1/collab/publish', { method: 'POST' }); + const v2 = await api.awaitPublishedVersion('/api/projects/p1/collab/status', v1); + expect(v2).toBe(2); + }); + + it('accepts a coalesced change notification', async () => { + const context = teamContext('workspace-change', 'member-change'); + const api = await startSyncServer({ current: async () => context }); + runtime!.rememberTeamShare( + 'p1', + contextToResourceHubPrincipal(context)!, + 'synced', + ); + const res = await api.json('/api/projects/p1/collab/changed', { method: 'POST' }); + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + }); + + it('keeps the default route verifier on its authoritative fixture after ambient moves', async () => { + const contextA = teamContext('workspace-fixture-a', 'member-fixture-a'); + const contextB = teamContext('workspace-fixture-b', 'member-fixture-b'); + let ambientContext = contextA; + const api = await startSyncServer({ + current: async () => ambientContext, + }); + runtime!.rememberTeamShare( + 'fixture-project', + contextToResourceHubPrincipal(contextA)!, + 'synced', + ); + + ambientContext = contextB; + const response = await api.json( + '/api/projects/fixture-project/collab/changed', + { + method: 'POST', + workspaceScope: contextA, + }, + ); + + expect(response.status).toBe(200); + expect(response.body.ok).toBe(true); + }); + + it('fails status closed before reading or materializing when workspace verification fails', async () => { + const store = fakeProjectStore(); + const resolveSharedProjectOwner = vi.fn(async () => 'member-owner'); + const context = teamContext('workspace-status', 'member-status'); + let authorityAvailable = true; + const api = await startSyncServer( + { current: async () => context }, + { + projectStore: store, + resolveSharedProjectOwner, + verifyWorkspaceRequest: async (req) => + authorityAvailable + && req.get('x-od-workspace-id') === context.workspaceId + && req.get('x-od-workspace-member-id') === context.workspaceMemberId + ? context + : null, + }, + ); + + const missing = await api.json('/api/projects/secret-project/collab/status', { + workspaceScope: false, + }); + expect(missing.status).toBe(403); + expect(missing.body.error).toBe('WORKSPACE_PROJECT_STATUS_DENIED'); + expect(resolveSharedProjectOwner).not.toHaveBeenCalled(); + expect(store.has('secret-project')).toBe(false); + + const spoofed = await api.json('/api/projects/secret-project/collab/status', { + workspaceScope: false, + headers: { + 'x-od-workspace-id': 'workspace-spoofed', + 'x-od-workspace-member-id': 'member-spoofed', + }, + }); + expect(spoofed.status).toBe(403); + expect(resolveSharedProjectOwner).not.toHaveBeenCalled(); + expect(store.has('secret-project')).toBe(false); + + authorityAvailable = false; + const unavailable = await api.json('/api/projects/secret-project/collab/status'); + expect(unavailable.status).toBe(403); + expect(resolveSharedProjectOwner).not.toHaveBeenCalled(); + expect(store.has('secret-project')).toBe(false); + }); + + it('publishes a changed project only to the verified request workspace', async () => { + const contextA = teamContext('workspace-a', 'member-a'); + const contextB = teamContext('workspace-b', 'member-b'); + const principalA = contextToResourceHubPrincipal(contextA)!; + const principalB = contextToResourceHubPrincipal(contextB)!; + const publish = vi.fn(async () => ({ version: 1 })); + const verifyWorkspaceRequest = async (req: express.Request) => { + const workspaceId = req.get('x-od-workspace-id'); + const memberId = req.get('x-od-workspace-member-id'); + if (workspaceId === contextA.workspaceId && memberId === contextA.workspaceMemberId) { + return contextA; + } + if (workspaceId === contextB.workspaceId && memberId === contextB.workspaceMemberId) { + return contextB; + } + return null; + }; + const api = await startSyncServer( + { current: async () => contextA }, + { + verifyWorkspaceRequest, + resolveSharedProjectOwner: async (_projectId, scope) => + scope?.workspaceId === contextA.workspaceId + ? contextA.workspaceMemberId + : scope?.workspaceId === contextB.workspaceId + ? contextB.workspaceMemberId + : null, + }, + { adapter: { publish } }, + ); + runtime!.rememberTeamShare('shared-project', principalA, 'synced'); + runtime!.rememberTeamShare('shared-project', principalB, 'synced'); + + const response = await api.json('/api/projects/shared-project/collab/publish', { + method: 'POST', + workspaceScope: contextB, + }); + expect(response.status).toBe(200); + + for (let i = 0; i < 40 && publish.mock.calls.length < 1; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(publish).toHaveBeenCalledTimes(1); + expect(publish).toHaveBeenCalledWith(expect.objectContaining({ + projectId: 'shared-project', + principal: principalB, + })); + }); + + it('rejects unscoped and spoofed change notifications before scheduling', async () => { + const context = teamContext('workspace-author', 'member-author'); + const principal = contextToResourceHubPrincipal(context)!; + const publish = vi.fn(async () => ({ version: 1 })); + const api = await startSyncServer( + { current: async () => context }, + undefined, + { adapter: { publish } }, + ); + runtime!.rememberTeamShare('shared-project', principal, 'synced'); + + const missing = await api.json('/api/projects/shared-project/collab/changed', { + method: 'POST', + workspaceScope: false, + }); + expect(missing.status).toBe(403); + expect(missing.body.error).toBe('WORKSPACE_PROJECT_PUBLISH_DENIED'); + + const spoofed = await api.json('/api/projects/shared-project/collab/publish', { + method: 'POST', + workspaceScope: false, + headers: { + 'x-od-workspace-id': 'workspace-spoofed', + 'x-od-workspace-member-id': 'member-spoofed', + }, + }); + expect(spoofed.status).toBe(403); + expect(spoofed.body.error).toBe('WORKSPACE_PROJECT_PUBLISH_DENIED'); + + expect(runtime!.projectSyncState('shared-project', principal)).toBe('synced'); + expect(publish).not.toHaveBeenCalled(); + }); + + it('keeps published versions independent per project', async () => { + const context = teamContext('workspace-independent', 'member-independent'); + const principal = contextToResourceHubPrincipal(context)!; + const api = await startSyncServer({ current: async () => context }); + runtime!.rememberTeamShare('a', principal, 'synced'); + await api.json('/api/projects/a/collab/publish', { method: 'POST' }); + await api.awaitPublishedVersion('/api/projects/a/collab/status', null); + expect((await api.json('/api/projects/b/collab/status')).body.publishedVersion).toBeNull(); + }); + + it('reports local_only sync state before any share', async () => { + const api = await startSyncServer(); + expect((await api.json('/api/projects/p1/collab/status')).body.syncState).toBe('local_only'); + }); + + it('leases consecutive status reads while publish, share, and pull stay fresh', async () => { + const context = teamContext('ws-1', 'wm-1'); + const directory = { + ok: true as const, + items: [{ + workspaceId: context.workspaceId, + workspaceName: 'Team', + workspaceType: 'team' as const, + workspaceMemberId: context.workspaceMemberId, + role: context.role, + memberStatus: context.memberStatus, + lifecycleState: context.lifecycleState, + }], + }; + const fetchReadDirectory = vi.fn(async () => directory); + const cachedReadDirectory = createCachedWorkspaceDirectoryFetcher({ + fetchDirectory: fetchReadDirectory, + identityKey: () => 'account-a:config-a', + ttlMs: 5_000, + }); + const verifyWorkspaceReadRequest = vi.fn((req: express.Request) => + verifyWorkspaceRequestContext({ + req, + fetchWorkspaceDirectory: cachedReadDirectory, + })); + const verifyWorkspaceRequest = vi.fn(async () => ({ + ok: true as const, + context, + })); + const api = await startSyncServer( + { current: async () => context }, + { + verifyWorkspaceReadRequest, + verifyWorkspaceRequest, + resolveSharedProjectOwner: async () => null, + }, + ); + + expect((await api.json('/api/projects/p1/collab/status')).status).toBe(200); + expect((await api.json('/api/projects/p1/collab/status')).status).toBe(200); + expect(verifyWorkspaceReadRequest).toHaveBeenCalledTimes(2); + expect(fetchReadDirectory).toHaveBeenCalledTimes(1); + expect(verifyWorkspaceRequest).not.toHaveBeenCalled(); + + expect((await api.json('/api/projects/p1/collab/sync-intent', { + method: 'POST', + body: { + event: 'project_team_share_requested', + projectId: 'p1', + }, + })).status).toBe(200); + expect((await api.json('/api/projects/p1/collab/publish', { + method: 'POST', + })).status).toBe(200); + // This narrow fixture has no mirror store, so pull reaches its expected + // post-authorization 502 after proving it used fresh authority. + expect((await api.json('/api/projects/p1/collab/pull', { + method: 'POST', + })).status).toBe(502); + expect(verifyWorkspaceRequest).toHaveBeenCalledTimes(3); + expect(fetchReadDirectory).toHaveBeenCalledTimes(1); + }); + + it('reuses the scoped catalog across consecutive status owner reads and refreshes after invalidation', async () => { + const context = teamContext('ws-status-cache', 'wm-status-viewer'); + const listCatalog = vi.fn(async () => [{ + projectId: 'shared-status-project', + ownerMemberId: 'wm-status-owner', + }]); + const cache = createSwrCache( + listCatalog, + () => JSON.stringify([ + context.workspaceId, + context.workspaceMemberId, + ]), + 3_000, + ); + const resolveSharedProjectOwnerForStatus = vi.fn(async ( + projectId: string, + scope?: { workspaceId: string; workspaceMemberId: string }, + ) => { + if ( + scope?.workspaceId !== context.workspaceId + || scope.workspaceMemberId !== context.workspaceMemberId + ) { + return null; + } + return (await cache()) + .find((entry) => entry.projectId === projectId) + ?.ownerMemberId ?? null; + }); + const api = await startSyncServer( + { current: async () => context }, + { resolveSharedProjectOwnerForStatus }, + ); + + expect((await api.json( + '/api/projects/shared-status-project/collab/status', + )).status).toBe(200); + expect((await api.json( + '/api/projects/shared-status-project/collab/status', + )).status).toBe(200); + expect(resolveSharedProjectOwnerForStatus).toHaveBeenCalledTimes(2); + expect(listCatalog).toHaveBeenCalledTimes(1); + + cache.invalidate(); + expect((await api.json( + '/api/projects/shared-status-project/collab/status', + )).status).toBe(200); + expect(listCatalog).toHaveBeenCalledTimes(2); + }); + + it('rejects a revoked member before consulting a cached status owner catalog', async () => { + const context = teamContext('ws-revoked-status', 'wm-revoked-status'); + let revoked = false; + const resolveSharedProjectOwnerForStatus = vi.fn( + async () => 'wm-status-owner', + ); + const verifyWorkspaceReadRequest = vi.fn(async () => + revoked + ? { + ok: false as const, + status: 403 as const, + code: 'WORKSPACE_ACCESS_DENIED' as const, + message: 'workspace membership is inactive', + } + : { + ok: true as const, + context, + }); + const api = await startSyncServer( + { current: async () => context }, + { + verifyWorkspaceReadRequest, + resolveSharedProjectOwnerForStatus, + }, + ); + + expect((await api.json( + '/api/projects/revoked-status-project/collab/status', + )).status).toBe(200); + expect(resolveSharedProjectOwnerForStatus).toHaveBeenCalledTimes(1); + + revoked = true; + expect((await api.json( + '/api/projects/revoked-status-project/collab/status', + )).status).toBe(403); + expect(verifyWorkspaceReadRequest).toHaveBeenCalledTimes(2); + expect(resolveSharedProjectOwnerForStatus).toHaveBeenCalledTimes(1); + }); + + it('does not cache a failed status authority read', async () => { + const context = teamContext('ws-1', 'wm-1'); + const fetchReadDirectory = vi + .fn() + .mockResolvedValueOnce({ ok: false as const, items: [] }) + .mockResolvedValueOnce({ + ok: true as const, + items: [{ + workspaceId: context.workspaceId, + workspaceName: 'Team', + workspaceType: 'team' as const, + workspaceMemberId: context.workspaceMemberId, + role: context.role, + memberStatus: context.memberStatus, + lifecycleState: context.lifecycleState, + }], + }); + const cachedReadDirectory = createCachedWorkspaceDirectoryFetcher({ + fetchDirectory: fetchReadDirectory, + identityKey: () => 'account-a:config-a', + ttlMs: 5_000, + }); + const api = await startSyncServer( + { current: async () => context }, + { + verifyWorkspaceReadRequest: (req) => + verifyWorkspaceRequestContext({ + req, + fetchWorkspaceDirectory: cachedReadDirectory, + }), + verifyWorkspaceRequest: vi.fn(async () => ({ + ok: true as const, + context, + })), + resolveSharedProjectOwner: async () => null, + }, + ); + + expect((await api.json('/api/projects/p1/collab/status')).status).toBe(503); + expect((await api.json('/api/projects/p1/collab/status')).status).toBe(200); + expect(fetchReadDirectory).toHaveBeenCalledTimes(2); + }); + + it('rechecks fresh authority before a status-triggered placeholder pull', async () => { + const context = teamContext('ws-1', 'wm-1'); + const projectStore = fakeProjectStore(); + const markSharedProjectPlaceholder = vi.fn( + (projectId: string, placeholder: boolean) => { + const record = projectStore.projects.get(projectId); + if (!record) return; + const metadata = { + ...((record.metadata as Record | undefined) ?? {}), + }; + if (placeholder) { + metadata[SHARED_PROJECT_PLACEHOLDER_METADATA_KEY] = Date.now(); + } else { + delete metadata[SHARED_PROJECT_PLACEHOLDER_METADATA_KEY]; + } + projectStore.projects.set(projectId, { + ...record, + metadata: metadata as never, + }); + }, + ); + const pullDir = await mkdtemp(path.join(tmpdir(), 'od-status-fresh-pull-')); + tempDirs.push(pullDir); + let resolveFresh: + | ((value: { ok: true; context: WorkspaceCollabContext }) => void) + | undefined; + const verifyWorkspaceRequest = vi.fn( + () => + new Promise<{ ok: true; context: WorkspaceCollabContext }>((resolve) => { + resolveFresh = resolve; + }), + ); + const pull = vi.fn(async () => ({ version: 1 })); + const api = await startSyncServer( + { current: async () => context }, + { + projectStore, + markSharedProjectPlaceholder, + resolvePullDir: () => pullDir, + verifyWorkspaceReadRequest: vi.fn(async () => ({ + ok: true as const, + context, + })), + verifyWorkspaceRequest, + resolveSharedProjectOwner: async () => context.workspaceMemberId, + resolveSharedProject: async (projectId, scope) => ({ + projectId, + ownerMemberId: scope?.ownerMemberId ?? context.workspaceMemberId, + sharedAt: new Date(1).toISOString(), + name: 'Freshly Authorized Project', + }), + }, + { + adapter: { + publish: async () => ({ version: 1 }), + syncLatest: async () => ({ version: 1 }), + pull, + }, + }, + ); + + const status = await api.json('/api/projects/fresh-pull/collab/status'); + expect(status.status).toBe(200); + expect(status.body.awaitingFirstMaterialization).toBe(true); + await vi.waitFor(() => expect(verifyWorkspaceRequest).toHaveBeenCalledOnce()); + expect(pull).not.toHaveBeenCalled(); + + resolveFresh?.({ ok: true, context }); + await vi.waitFor(() => expect(pull).toHaveBeenCalledOnce()); + }); + + it('registers a local placeholder when a member opens a not-yet-pulled shared project', async () => { + const projectStore = fakeProjectStore(); + const api = await startSyncServer(undefined, { + projectStore, + resolveSharedProjectOwner: async () => 'other-owner', + }); + expect(projectStore.has('shared-p')).toBe(false); + // The first status poll a member fires on opening the shared project must + // register the placeholder so the other project routes stop 404ing while + // the pull runs. + const res = await api.json('/api/projects/shared-p/collab/status'); + expect(res.status).toBe(200); + expect(projectStore.registerCalls).toBe(1); + expect(projectStore.projects.get('shared-p')?.name).toBe('共享项目'); + // Idempotent: subsequent polls do not re-register the now-known project. + await api.json('/api/projects/shared-p/collab/status'); + expect(projectStore.registerCalls).toBe(1); + }); + + it('owner opening their own unmaterialized shared project self-pulls and clears the placeholder stamp (recvqzaDvUU6B3)', async () => { + // Fresh-install shape: the hub still lists the project with THIS member as + // owner, but the local data root has no copy. The status poll registers a + // placeholder — and, because the owner has no other pull path ("the owner + // never auto-pulls" only holds when their local copy is real), that same + // poll must kick off a background self-pull. Without it the empty + // placeholder stays the only local state, which is exactly what the + // publish paths used to wipe the hub with. + const projectStore = fakeProjectStore(); + const markSharedProjectPlaceholder = vi.fn( + (projectId: string, placeholder: boolean) => { + const rec = projectStore.projects.get(projectId); + if (!rec) return; + const metadata = { + ...((rec.metadata as Record | undefined) ?? {}), + }; + if (placeholder) { + metadata[SHARED_PROJECT_PLACEHOLDER_METADATA_KEY] = Date.now(); + } else { + delete metadata[SHARED_PROJECT_PLACEHOLDER_METADATA_KEY]; + } + projectStore.projects.set(projectId, { ...rec, metadata: metadata as never }); + }, + ); + const pullDir = await mkdtemp(path.join(tmpdir(), 'od-owner-selfpull-')); + tempDirs.push(pullDir); + let pullCalls = 0; + const api = await startSyncServer( + fixedShareContextProvider(true), + { + projectStore, + markSharedProjectPlaceholder, + resolvePullDir: () => pullDir, + // The CALLER (wm-1) is the hub-registered owner of this project. + resolveSharedProjectOwner: async () => 'wm-1', + resolveSharedProject: async () => ({ + projectId: 'owned-shared-p', + ownerMemberId: 'wm-1', + sharedAt: new Date(1).toISOString(), + name: 'Real Owner Project', + }), + }, + { + adapter: { + publish: async () => { + throw new Error('an owner self-pull must never publish'); + }, + syncLatest: async () => ({ version: 5 }), + pull: async () => { + pullCalls += 1; + return { version: 5 }; + }, + }, + }, + ); + + const res = await api.json('/api/projects/owned-shared-p/collab/status'); + expect(res.status).toBe(200); + // The placeholder was registered AND stamped as unmaterialized on open. + expect(markSharedProjectPlaceholder).toHaveBeenCalledWith('owned-shared-p', true); + + // The same status poll kicks off the background owner self-pull … + await vi.waitFor(() => { + expect(pullCalls).toBeGreaterThan(0); + }); + // … whose registration replaces the placeholder with the real record and + // clears the stamp, so normal owner publishing can resume on top of the + // materialized content. + await vi.waitFor(() => { + expect(markSharedProjectPlaceholder).toHaveBeenCalledWith('owned-shared-p', false); + }); + expect(projectStore.projects.get('owned-shared-p')?.name).toBe('Real Owner Project'); + }); + + it('owner self-pull hitting a retracted hub resource heals the dangling catalog row instead of leaving a ghost', async () => { + // The reinstall-revival shape reproduced live on the feature-test hub + // (2026-07-27, workspace res-wipe-0727): an unshare's two hub writes are + // resource remove → team-projects catalog remove, and when only the first + // landed the hub is left dangling — the catalog still lists the project + // while its backing resource row is tombstoned. On a fresh data root the + // local `cloudTombstonedAt` suppression is gone, so the retracted project + // came back as a normal-looking team card (visibility=team, canOpen) for + // every member. Opening it registered a placeholder and the owner + // self-pull died with `resource_not_found`, silently — the ghost stayed + // in the list forever, and the unshare retry-trap (see + // vela-cli-resource-adapter.test.ts) meant no user action could clear it. + // + // The invariant: the catalog naming this caller as owner WHILE the + // published pull answers `resource_not_found` is the hub-authoritative + // signature of "曾共享已撤" (a half-landed retraction) — never of a live + // share. The owner's daemon must finish the retraction (remove the + // dangling catalog row) and retire the just-registered unmaterialized + // placeholder, instead of swallowing the pull error. + const projectStore = fakeProjectStore(); + const markSharedProjectPlaceholder = vi.fn( + (projectId: string, placeholder: boolean) => { + const rec = projectStore.projects.get(projectId); + if (!rec) return; + const metadata = { + ...((rec.metadata as Record | undefined) ?? {}), + }; + if (placeholder) { + metadata[SHARED_PROJECT_PLACEHOLDER_METADATA_KEY] = Date.now(); + } else { + delete metadata[SHARED_PROJECT_PLACEHOLDER_METADATA_KEY]; + } + projectStore.projects.set(projectId, { ...rec, metadata: metadata as never }); + }, + ); + const retireUnmaterializedSharedPlaceholder = vi.fn((projectId: string) => { + projectStore.projects.delete(projectId); + }); + const invalidateTeamProjectCatalog = vi.fn(); + const pullDir = await mkdtemp(path.join(tmpdir(), 'od-owner-ghost-heal-')); + tempDirs.push(pullDir); + const unpublish = vi.fn(async () => undefined); + const catalogRemove = vi.fn(async (_projectId: string, _principal?: unknown) => ({})); + const api = await startSyncServer( + fixedShareContextProvider(true), + { + projectStore, + markSharedProjectPlaceholder, + retireUnmaterializedSharedPlaceholder, + invalidateTeamProjectCatalog, + resolvePullDir: () => pullDir, + // The CALLER (wm-1) is the hub-registered owner: the dangling catalog + // row still names them, which is exactly why the ghost renders. + resolveSharedProjectOwner: async () => 'wm-1', + resolveSharedProject: async () => ({ + projectId: 'ghost-shared-p', + ownerMemberId: 'wm-1', + sharedAt: new Date(1).toISOString(), + name: 'Ghost Project', + }), + }, + { + adapter: { + publish: async () => { + throw new Error('an owner self-pull must never publish'); + }, + // `head` on a tombstoned resource reports "no published version". + syncLatest: async () => null, + // The exact transport failure the live repro surfaced: the hub's + // tombstone gate 404s the published-ref pull. + pull: async () => { + throw new Error( + 'Command failed: vela resource pull project project-ghost /dir --ref published --json\n' + + 'Error: pull resource: API request failed with status 404: resource_not_found\n', + ); + }, + unpublish, + }, + teamProjectCatalog: { upsert: async () => ({}), remove: catalogRemove }, + }, + ); + + const res = await api.json('/api/projects/ghost-shared-p/collab/status'); + expect(res.status).toBe(200); + expect(markSharedProjectPlaceholder).toHaveBeenCalledWith('ghost-shared-p', true); + + // The heal finishes the half-landed retraction hub-side … + await vi.waitFor(() => { + expect(catalogRemove).toHaveBeenCalled(); + }); + expect(catalogRemove.mock.calls[0]?.[0]).toBe('ghost-shared-p'); + // … retires the contentless placeholder this same open registered … + await vi.waitFor(() => { + expect(retireUnmaterializedSharedPlaceholder).toHaveBeenCalledWith('ghost-shared-p'); + }); + expect(projectStore.projects.has('ghost-shared-p')).toBe(false); + // … and drops the cached catalog so the ghost card leaves the list now, + // not one stale-while-revalidate TTL later. + expect(invalidateTeamProjectCatalog).toHaveBeenCalled(); + }); + + it('reports the durable owner-scoped materialized version for a shared project', async () => { + const readMaterializedVersion = vi.fn(() => 6); + const api = await startSyncServer( + fixedShareContextProvider(true), + { + resolveSharedProjectOwner: async () => 'wm-owner', + readMaterializedVersion, + }, + { + adapter: { + publish: async () => ({ version: 7 }), + syncLatest: async () => ({ version: 7 }), + }, + }, + ); + + const first = await api.json('/api/projects/shared-p/collab/status'); + expect(first.status).toBe(200); + expect(first.body).toMatchObject({ + publishedVersion: null, + materializedVersion: null, + ownerMemberId: 'wm-owner', + }); + expect(await api.awaitPublishedVersion( + '/api/projects/shared-p/collab/status', + null, + )).toBe(7); + const res = await api.json('/api/projects/shared-p/collab/status'); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + publishedVersion: 7, + materializedVersion: 6, + ownerMemberId: 'wm-owner', + }); + expect(readMaterializedVersion).toHaveBeenCalledWith('shared-p', { + workspaceId: 'ws-1', + resourceTeamId: 'team-1', + viewerMemberId: 'wm-1', + ownerMemberId: 'wm-owner', + }); + }); + + it('returns the daemon-local content transfer snapshot for reconnects', async () => { + const contentTransferState = { + status: 'downloading' as const, + version: 8, + startedAt: 100, + updatedAt: 101, + }; + const readContentTransferState = vi.fn(() => contentTransferState); + const api = await startSyncServer( + fixedShareContextProvider(true), + { + resolveSharedProjectOwner: async () => 'wm-owner', + readContentTransferState, + }, + ); + + const res = await api.json('/api/projects/shared-p/collab/status'); + + expect(res.status).toBe(200); + expect(res.body.contentTransferState).toEqual(contentTransferState); + expect(readContentTransferState).toHaveBeenCalledWith('shared-p', { + workspaceId: 'ws-1', + resourceTeamId: 'team-1', + viewerMemberId: 'wm-1', + ownerMemberId: 'wm-owner', + }); + }); + + it('fails closed to a null materialized version when the durable cursor cannot be read', async () => { + const api = await startSyncServer( + fixedShareContextProvider(true), + { + resolveSharedProjectOwner: async () => 'wm-owner', + readMaterializedVersion: () => { + throw new Error('cursor unavailable'); + }, + }, + { + adapter: { + publish: async () => ({ version: 7 }), + syncLatest: async () => ({ version: 7 }), + }, + }, + ); + + const res = await api.json('/api/projects/shared-p/collab/status'); + + expect(res.status).toBe(200); + expect(res.body.materializedVersion).toBeNull(); + }); + + it('registers a local placeholder when the OWNER opens their own shared project not materialized on this machine', async () => { + const projectStore = fakeProjectStore(); + const ownerContext = teamContext('ws-1', 'owner-self'); + const api = await startSyncServer({ current: async () => ownerContext }, { + projectStore, + // The hub reports the caller themselves as the owner. + resolveSharedProjectOwner: async () => 'owner-self', + }); + expect(projectStore.has('shared-owned')).toBe(false); + // An owner can hit a shared project that is NOT in this daemon's local DB — + // it was created/shared on another machine (or a smoke-test attributed it to + // them in the hub). Opening it must still register the placeholder, or + // conversations/events/tabs 404 and the left pane hangs for a minute. The + // owner never auto-pulls, so nothing else registers it. callerIsOwner=true + // here (owner member id === caller's own member id via the workspace header). + const ownerHeaders = { + 'x-od-workspace-id': 'ws-1', + 'x-od-workspace-member-id': 'owner-self', + }; + const res = await api.json('/api/projects/shared-owned/collab/status', { + headers: ownerHeaders, + }); + expect(res.status).toBe(200); + expect(projectStore.registerCalls).toBe(1); + expect(projectStore.projects.get('shared-owned')?.name).toBe('共享项目'); + // Idempotent: the now-known project is not re-registered on the next poll. + await api.json('/api/projects/shared-owned/collab/status', { headers: ownerHeaders }); + expect(projectStore.registerCalls).toBe(1); + }); + + it('drives the visibility-to-sync team-share intent through to synced', async () => { + const api = await startSyncServer(fixedShareContextProvider(true)); + const intent = await api.json('/api/projects/p1/collab/sync-intent', { + method: 'POST', + body: { event: 'project_team_share_requested', projectId: 'p1' }, + }); + expect(intent.status).toBe(200); + // Team-share is user-facing: the route waits for a durable resource version + // before reporting success, so teammates never see a catalog-only shell. + expect(intent.body.syncState).toBe('synced'); + expect(intent.body.publishedVersion).toBe(1); + expect((await api.json('/api/projects/p1/collab/status')).body.publishedVersion).toBe(1); + }); + + it('moves a team-shared project back to local_only on unshare intent', async () => { + const api = await startSyncServer(fixedShareContextProvider(true)); + await api.json('/api/projects/p1/collab/sync-intent', { + method: 'POST', + body: { event: 'project_team_share_requested', projectId: 'p1' }, + }); + await api.awaitPublishedVersion('/api/projects/p1/collab/status', null); + + const unshare = await api.json('/api/projects/p1/collab/sync-intent', { + method: 'POST', + body: { event: 'project_team_unshare_requested', projectId: 'p1' }, + }); + + expect(unshare.status).toBe(200); + expect(unshare.body.syncState).toBe('local_only'); + const status = await api.json('/api/projects/p1/collab/status'); + expect(status.body.syncState).toBe('local_only'); + expect(status.body.publishedVersion).toBeNull(); + }); + + it('accepts a visibility-changed intent as a no-op signal', async () => { + const api = await startSyncServer(); + const res = await api.json('/api/projects/p1/collab/sync-intent', { + method: 'POST', + body: { event: 'project_visibility_changed', projectId: 'p1' }, + }); + expect(res.status).toBe(200); + expect(res.body.syncState).toBe('local_only'); // visibility change alone doesn't publish + }); + + it('rejects an unknown sync intent event', async () => { + const api = await startSyncServer(); + const res = await api.json('/api/projects/p1/collab/sync-intent', { + method: 'POST', + body: { event: 'nonsense', projectId: 'p1' }, + }); + expect(res.status).toBe(400); + }); + + it('refuses a team-share intent from a member without canShareProjects (server-side gate)', async () => { + // The client hides the share affordance, but the daemon must not trust the + // client — a member whose context lacks canShareProjects is refused (403), + // and the project stays local_only (no publish is triggered). + const api = await startSyncServer(fixedShareContextProvider(false)); + const res = await api.json('/api/projects/p1/collab/sync-intent', { + method: 'POST', + body: { event: 'project_team_share_requested', projectId: 'p1' }, + }); + expect(res.status).toBe(403); + expect(res.body.error).toBe('WORKSPACE_PROJECT_SHARE_DENIED'); + expect((await api.json('/api/projects/p1/collab/status')).body.syncState).toBe('local_only'); + }); + + it('refuses a team-share intent when no workspace context is available', async () => { + const api = await startSyncServer({ current: async () => null }); + const res = await api.json('/api/projects/p1/collab/sync-intent', { + method: 'POST', + body: { event: 'project_team_share_requested', projectId: 'p1' }, + }); + expect(res.status).toBe(403); + expect(res.body.error).toBe('WORKSPACE_PROJECT_SHARE_DENIED'); + const status = await api.json('/api/projects/p1/collab/status'); + expect(status.status).toBe(403); + expect(status.body.error).toBe('WORKSPACE_PROJECT_STATUS_DENIED'); + }); + + it('honors a team-share intent from a member with canShareProjects', async () => { + const api = await startSyncServer(fixedShareContextProvider(true)); + const res = await api.json('/api/projects/p1/collab/sync-intent', { + method: 'POST', + body: { event: 'project_team_share_requested', projectId: 'p1' }, + }); + expect(res.status).toBe(200); + expect(res.body.syncState).toBe('synced'); + expect(res.body.publishedVersion).toBe(1); + }); + + it('treats an already shared project owned by another member as shared instead of republishing it', async () => { + let publishCalls = 0; + const api = await startSyncServer( + fixedShareContextProvider(true), + { + resolveSharedProject: async () => ({ + projectId: 'p1', + ownerMemberId: 'wm-owner', + sharedAt: new Date(1).toISOString(), + name: 'Owner Project', + }), + }, + { + adapter: { + publish: async () => { + publishCalls += 1; + throw new Error('resource hub should not be called'); + }, + syncLatest: async () => null, + pull: async () => null, + unpublish: async () => {}, + }, + }, + ); + + const res = await api.json('/api/projects/p1/collab/sync-intent', { + method: 'POST', + body: { event: 'project_team_share_requested', projectId: 'p1' }, + }); + + expect(res.status).toBe(200); + expect(res.body.syncState).toBe('synced'); + expect(publishCalls).toBe(0); + }); + + it('refuses to unshare a project owned by another member', async () => { + const api = await startSyncServer(fixedShareContextProvider(true), { + resolveSharedProject: async () => ({ + projectId: 'p1', + ownerMemberId: 'wm-owner', + sharedAt: new Date(1).toISOString(), + name: 'Owner Project', + }), + }); + + const res = await api.json('/api/projects/p1/collab/sync-intent', { + method: 'POST', + body: { event: 'project_team_unshare_requested', projectId: 'p1' }, + }); + + expect(res.status).toBe(403); + expect(res.body.error).toBe('WORKSPACE_PROJECT_UNSHARE_DENIED'); + }); + + it('publishes a public file from a personal workspace, scoped by its workspace id', async () => { + // The public-file routes require A workspace, not a TEAM workspace. + // + // They were briefly team-only, on the premise that a hub snapshot is keyed + // by teamId and a personal session had nothing to publish under. B's + // control-key auth path stopped refusing non-team callers: it mints a + // principal whose teamId IS the workspace id, and its access check only + // compares that id against the resource's own — a partition of one. So a + // personal workspace must get through, and must be scoped by its OWN id. + const dir = await mkdtemp(path.join(tmpdir(), 'od-public-file-')); + tempDirs.push(dir); + await writeFile(path.join(dir, 'index.html'), '

Published

'); + vi.mocked(readVelaControlApiContext).mockReturnValue({ + profile: 'test', + apiUrl: 'https://hub.example.test', + controlKey: 'ctrl-test', + user: null, + configMtimeMs: null, + }); + vi.mocked(runVelaResourceCommand).mockImplementation(async (args) => { + if (args[0] === 'snapshot') { + return JSON.stringify({ + slug: 'personal-slug', + name: 'index.html', + kind: 'project', + versionId: 'v1', + createdAt: new Date(1).toISOString(), + }); + } + return JSON.stringify({ version: 1 }); + }); + const api = await startSyncServer(personalContextProvider(), { + resolveProjectDir: () => dir, + resolveSharedProject: async () => null, + }); + + const publish = await api.json('/api/projects/p1/files/index.html/publish-public', { + method: 'POST', + }); + + expect(publish.status).toBe(200); + expect(publish.body).toEqual({ + url: 'https://hub.example.test/api/v1/public/snapshots/personal-slug/files/index.html', + slug: 'personal-slug', + fileName: 'index.html', + }); + // Every hub call carries the personal workspace's own id as the scope — + // there is no teamId on this context, and nothing may invent one. + expect(runVelaResourceCommand).toHaveBeenCalled(); + for (const call of vi.mocked(runVelaResourceCommand).mock.calls) { + expect(call[1]).toBe('ws-personal-1'); + } + + // The published link then reads back and clears like any other. + const current = await api.json('/api/projects/p1/files/index.html/publish-public'); + expect(current.body.publication?.slug).toBe('personal-slug'); + const unpublish = await api.json('/api/projects/p1/files/index.html/publish-public', { + method: 'DELETE', + body: { slug: 'personal-slug' }, + }); + expect(unpublish.status).toBe(200); + }); + + it('keeps public file ownership reads on request workspace A while ambient workspace B is active', async () => { + const workspaceA = teamContext('workspace-a', 'member-a'); + const dir = await mkdtemp(path.join(tmpdir(), 'od-public-file-')); + tempDirs.push(dir); + await writeFile(path.join(dir, 'index.html'), '

Published in A

'); + vi.mocked(readVelaControlApiContext).mockReturnValue({ + profile: 'test', + apiUrl: 'https://hub.example.test', + controlKey: 'ctrl-test', + user: null, + configMtimeMs: null, + }); + vi.mocked(runVelaResourceCommand).mockImplementation(async (args) => { + if (args[0] === 'snapshot') { + return JSON.stringify({ + slug: 'workspace-a-slug', + name: 'index.html', + kind: 'project', + versionId: 'v1', + createdAt: new Date(1).toISOString(), + }); + } + return JSON.stringify({ version: 1 }); + }); + const ownershipScopes: Array = []; + const resolveSharedProject = vi.fn(async ( + projectId: string, + scope?: TeamMirrorPullScope | null, + ) => { + ownershipScopes.push(scope); + // Production used to omit this scope, so the catalog adapter fell back + // to ambient B and returned B's owner for an explicit A request. + const workspaceId = scope?.workspaceId ?? 'workspace-b'; + return { + projectId, + ownerMemberId: workspaceId === 'workspace-a' ? 'member-a' : 'member-b', + sharedAt: '2026-07-30T00:00:00.000Z', + }; + }); + const api = await startSyncServer( + { current: async () => workspaceA }, + { + resolveProjectDir: () => dir, + resolveSharedProject, + }, + ); + + const publish = await api.json('/api/projects/p1/files/index.html/publish-public', { + method: 'POST', + }); + const current = await api.json('/api/projects/p1/files/index.html/publish-public'); + const unpublish = await api.json('/api/projects/p1/files/index.html/publish-public', { + method: 'DELETE', + body: { slug: 'workspace-a-slug' }, + }); + + expect(publish.status).toBe(200); + expect(current.status).toBe(200); + expect(current.body.publication?.slug).toBe('workspace-a-slug'); + expect(unpublish.status).toBe(200); + expect(resolveSharedProject).toHaveBeenCalledTimes(3); + expect(ownershipScopes).toHaveLength(3); + for (const scope of ownershipScopes) { + expect(scope).toMatchObject({ + workspaceId: 'workspace-a', + resourceTeamId: 'workspace-a', + viewerMemberId: 'member-a', + }); + } + for (const call of vi.mocked(runVelaResourceCommand).mock.calls) { + expect(call[1]).toBe('workspace-a'); + } + }); + + it('explains, rather than bare-codes, a public file publish with no workspace at all', async () => { + // The gate was widened, not removed. A signed-out caller (or a context read + // that came back empty) has no id to publish under and no member id to own + // the resource with, so all three handlers still refuse it — and must ship a + // human-readable reason alongside the code, since the `od` CLI and embedding + // agents surface the body verbatim. The sentence now says SIGN IN; telling a + // personal user to "switch to a team workspace" is no longer true. + const resolveProjectDir = vi.fn(() => { + throw new Error('project dir should not be read'); + }); + const api = await startSyncServer( + { current: async () => null }, + { resolveProjectDir, resolveSharedProject: async () => null }, + ); + + const publish = await api.json('/api/projects/p1/files/index.html/publish-public', { + method: 'POST', + }); + const read = await api.json('/api/projects/p1/files/index.html/publish-public'); + const unpublish = await api.json('/api/projects/p1/files/index.html/publish-public', { + method: 'DELETE', + body: { slug: 'public-slug' }, + }); + + for (const res of [publish, read, unpublish]) { + expect(res.status).toBe(409); + expect(res.body.error).toBe('WORKSPACE_IDENTITY_REQUIRED'); + // The load-bearing assertion: a human-readable reason ships with the code. + expect(typeof res.body.message).toBe('string'); + expect(res.body.message).toMatch(/sign in/i); + expect(res.body.message).not.toMatch(/team workspace/i); + } + // The gate must short-circuit before any project read or hub call. + expect(resolveProjectDir).not.toHaveBeenCalled(); + expect(runVelaResourceCommand).not.toHaveBeenCalled(); + }); + + it('refuses public file operations for a shared project owned by another member without side effects', async () => { + const resolveProjectDir = vi.fn(() => { + throw new Error('project dir should not be read'); + }); + vi.mocked(readVelaControlApiContext).mockReturnValue({ + profile: 'test', + apiUrl: 'https://hub.example.test', + controlKey: 'ctrl-test', + user: null, + configMtimeMs: null, + }); + const api = await startSyncServer(fixedShareContextProvider(true), { + resolveProjectDir, + resolveSharedProject: async () => ({ + projectId: 'p1', + ownerMemberId: 'wm-owner', + sharedAt: new Date(1).toISOString(), + name: 'Owner Project', + }), + }); + + const publish = await api.json('/api/projects/p1/files/index.html/publish-public', { + method: 'POST', + }); + const current = await api.json('/api/projects/p1/files/index.html/publish-public'); + const unpublish = await api.json('/api/projects/p1/files/index.html/publish-public', { + method: 'DELETE', + body: { slug: 'other-member-slug' }, + }); + + for (const res of [publish, current, unpublish]) { + expect(res.status).toBe(403); + expect(res.body.error).toBe('WORKSPACE_PROJECT_PUBLISH_DENIED'); + } + expect(resolveProjectDir).not.toHaveBeenCalled(); + expect(runVelaResourceCommand).not.toHaveBeenCalled(); + }); + + it('fails public file publish and unpublish when ownership lookup fails', async () => { + const resolveProjectDir = vi.fn(() => { + throw new Error('project dir should not be read'); + }); + vi.mocked(readVelaControlApiContext).mockReturnValue({ + profile: 'test', + apiUrl: 'https://hub.example.test', + controlKey: 'ctrl-test', + user: null, + configMtimeMs: null, + }); + const api = await startSyncServer(fixedShareContextProvider(true), { + resolveProjectDir, + resolveSharedProject: async () => { + throw new Error('catalog unavailable'); + }, + }); + + const publish = await api.json('/api/projects/p1/files/index.html/publish-public', { + method: 'POST', + }); + const unpublish = await api.json('/api/projects/p1/files/index.html/publish-public', { + method: 'DELETE', + body: { slug: 'public-slug' }, + }); + + expect(publish.status).toBe(503); + expect(publish.body.error).toBe('WORKSPACE_PROJECT_OWNERSHIP_UNAVAILABLE'); + expect(unpublish.status).toBe(503); + expect(unpublish.body.error).toBe('WORKSPACE_PROJECT_OWNERSHIP_UNAVAILABLE'); + expect(resolveProjectDir).not.toHaveBeenCalled(); + expect(runVelaResourceCommand).not.toHaveBeenCalled(); + }); + + it('does not create a public snapshot when no public base URL is configured', async () => { + const resolveProjectDir = vi.fn(() => { + throw new Error('project dir should not be read'); + }); + const api = await startSyncServer(fixedShareContextProvider(true), { + resolveProjectDir, + resolveSharedProject: async () => null, + }); + + const res = await api.json('/api/projects/p1/files/index.html/publish-public', { + method: 'POST', + }); + + expect(res.status).toBe(502); + expect(res.body.error).toBe('PUBLIC_FILE_URL_UNAVAILABLE'); + expect(resolveProjectDir).not.toHaveBeenCalled(); + expect(runVelaResourceCommand).not.toHaveBeenCalled(); + }); + + it('hydrates and clears public file publication state', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'od-public-file-')); + tempDirs.push(dir); + await writeFile(path.join(dir, 'index.html'), '

Published

'); + vi.mocked(readVelaControlApiContext).mockReturnValue({ + profile: 'test', + apiUrl: 'https://hub.example.test', + controlKey: 'ctrl-test', + user: null, + configMtimeMs: null, + }); + vi.mocked(runVelaResourceCommand).mockImplementation(async (args) => { + if (args[0] === 'snapshot') { + return JSON.stringify({ + slug: 'public-slug', + name: 'index.html', + kind: 'project', + versionId: 'v1', + createdAt: new Date(1).toISOString(), + }); + } + return JSON.stringify({ version: 1 }); + }); + const api = await startSyncServer(fixedShareContextProvider(true), { + resolveProjectDir: () => dir, + resolveSharedProject: async () => null, + }); + + const publish = await api.json('/api/projects/p1/files/index.html/publish-public', { method: 'POST' }); + const current = await api.json('/api/projects/p1/files/index.html/publish-public'); + const unpublish = await api.json('/api/projects/p1/files/index.html/publish-public', { + method: 'DELETE', + body: { slug: 'public-slug' }, + }); + const afterUnpublish = await api.json('/api/projects/p1/files/index.html/publish-public'); + + const publication = { + url: 'https://hub.example.test/api/v1/public/snapshots/public-slug/files/index.html', + slug: 'public-slug', + fileName: 'index.html', + }; + expect(publish.status).toBe(200); + expect(publish.body).toEqual(publication); + expect(current.body.publication).toEqual(publication); + expect(unpublish.status).toBe(200); + expect(afterUnpublish.body.publication).toBeNull(); + }); + + it('publishes a public file when the project-dir resolver is async (production wiring)', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'od-public-file-')); + tempDirs.push(dir); + await writeFile(path.join(dir, 'index.html'), '

Published

'); + vi.mocked(readVelaControlApiContext).mockReturnValue({ + profile: 'test', + apiUrl: 'https://hub.example.test', + controlKey: 'ctrl-test', + user: null, + configMtimeMs: null, + }); + vi.mocked(runVelaResourceCommand).mockImplementation(async (args) => { + if (args[0] === 'snapshot') { + return JSON.stringify({ + slug: 'public-slug', + name: 'index.html', + kind: 'project', + versionId: 'v1', + createdAt: new Date(1).toISOString(), + }); + } + return JSON.stringify({ version: 1 }); + }); + // Production injects resolveProjectDir as an async resolver (it awaits + // ensureProject before returning the share dir). The handler must await it; + // otherwise the raw Promise reaches realpath and the owner gets a spurious + // FILE_UNAVAILABLE even though the file is present and readable. + const api = await startSyncServer(fixedShareContextProvider(true), { + resolveProjectDir: async () => dir, + resolveSharedProject: async () => null, + }); + + const publish = await api.json('/api/projects/p1/files/index.html/publish-public', { method: 'POST' }); + + expect(publish.status).toBe(200); + expect(publish.body).toEqual({ + url: 'https://hub.example.test/api/v1/public/snapshots/public-slug/files/index.html', + slug: 'public-slug', + fileName: 'index.html', + }); + }); + + it('rejects escaped and symlinked public file paths before publishing', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'od-public-file-')); + const outsideDir = await mkdtemp(path.join(tmpdir(), 'od-public-outside-')); + tempDirs.push(dir, outsideDir); + await writeFile(path.join(outsideDir, 'secret.html'), '

Secret

'); + await symlink(path.join(outsideDir, 'secret.html'), path.join(dir, 'secret-link.html')); + vi.mocked(readVelaControlApiContext).mockReturnValue({ + profile: 'test', + apiUrl: 'https://hub.example.test', + controlKey: 'ctrl-test', + user: null, + configMtimeMs: null, + }); + const api = await startSyncServer(fixedShareContextProvider(true), { + resolveProjectDir: () => dir, + resolveSharedProject: async () => null, + }); + + const backslash = await api.json('/api/projects/p1/files/nested%5Csecret.html/publish-public', { method: 'POST' }); + const symlinked = await api.json('/api/projects/p1/files/secret-link.html/publish-public', { method: 'POST' }); + + expect(backslash.status).toBe(400); + expect(backslash.body.error).toBe('invalid_file_path'); + expect(symlinked.status).toBe(400); + expect(symlinked.body.error).toBe('FILE_UNAVAILABLE'); + expect(runVelaResourceCommand).not.toHaveBeenCalled(); + }); + + it('writes and removes the Vela team-project catalog around share intents', async () => { + const writes: unknown[] = []; + const removes: string[] = []; + const api = await startSyncServer( + fixedShareContextProvider(true), + undefined, + { + adapter: { + publish: async () => ({ version: 1, versionId: 'version-1' }), + unpublish: async () => {}, + }, + describeProject: () => ({ + name: 'Electric Studio 2', + skillId: null, + designSystemId: null, + createdAt: 1, + updatedAt: 2, + }), + teamProjectCatalog: { + upsert: async (input) => { + writes.push(input); + }, + remove: async (projectId) => { + removes.push(projectId); + }, + }, + }, + ); + + const share = await api.json('/api/projects/p1/collab/sync-intent', { + method: 'POST', + body: { event: 'project_team_share_requested', projectId: 'p1' }, + }); + expect(share.status).toBe(200); + expect(writes).toEqual([ + { + projectId: 'p1', + resourceId: projectResourceIdFor('p1', { + teamId: 'team-1', + memberId: 'wm-1', + role: 'member', + lifecycleState: 'active', + }), + displayName: 'Electric Studio 2', + syncState: 'synced', + lastSyncedVersionId: 'version-1', + metadata: { + name: 'Electric Studio 2', + skillId: null, + designSystemId: null, + createdAt: 1, + updatedAt: 2, + }, + }, + ]); + + const unshare = await api.json('/api/projects/p1/collab/sync-intent', { + method: 'POST', + body: { event: 'project_team_unshare_requested', projectId: 'p1' }, + }); + expect(unshare.status).toBe(200); + expect(removes).toEqual(['p1']); + }); + + it('does not pretend a project is shared when the Vela catalog write fails', async () => { + const unpublish = vi.fn(async () => undefined); + const api = await startSyncServer( + fixedShareContextProvider(true), + undefined, + { + adapter: { + publish: async () => ({ version: 1, versionId: 'version-1' }), + unpublish, + }, + teamProjectCatalog: { + upsert: async () => { + throw new Error('catalog unavailable'); + }, + remove: async () => {}, + }, + }, + ); + + const res = await api.json('/api/projects/p1/collab/sync-intent', { + method: 'POST', + body: { event: 'project_team_share_requested', projectId: 'p1' }, + }); + expect(res.status).toBe(502); + expect(res.body.error).toBe('TEAM_PROJECT_PUBLISH_UNAVAILABLE'); + expect(unpublish).toHaveBeenCalledTimes(1); + expect((await api.json('/api/projects/p1/collab/status')).body.syncState).toBe('sync_failed'); + }); + + it('does not write the team catalog when resource publishing fails', async () => { + const writes: unknown[] = []; + const api = await startSyncServer( + fixedShareContextProvider(true), + undefined, + { + teamProjectCatalog: { + upsert: async (input) => { + writes.push(input); + }, + remove: async () => {}, + }, + adapter: { + publish: async () => { + throw new Error('resource hub unavailable'); + }, + syncLatest: async () => null, + pull: async () => null, + unpublish: async () => {}, + }, + }, + ); + + const res = await api.json('/api/projects/p1/collab/sync-intent', { + method: 'POST', + body: { event: 'project_team_share_requested', projectId: 'p1' }, + }); + + expect(res.status).toBe(502); + expect(res.body.error).toBe('TEAM_PROJECT_PUBLISH_UNAVAILABLE'); + expect(writes).toEqual([]); + expect((await api.json('/api/projects/p1/collab/status')).body.syncState).toBe('sync_failed'); + }); + + it('rejects spoofed workspace headers without publishing, pulling, or materializing', async () => { + const authoritativeContext = + await fixedShareContextProvider(true).current({}); + if (!authoritativeContext) { + throw new Error('expected authoritative Team context fixture'); + } + const projectStore = fakeProjectStore(); + const publish = vi.fn(async () => ({ version: 1 })); + const pull = vi.fn(async () => ({ version: 1 })); + const api = await startSyncServer( + { current: async () => null }, + { + projectStore, + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + verifyWorkspaceRequest: async (req) => + req.get('x-od-workspace-id') === authoritativeContext.workspaceId + && req.get('x-od-workspace-member-id') + === authoritativeContext.workspaceMemberId + ? authoritativeContext + : null, + verifyWorkspaceScope: async (scope) => + scope.workspaceId === authoritativeContext.workspaceId + && scope.viewerMemberId + === authoritativeContext.workspaceMemberId, + resolveSharedProjectOwner: async () => + authoritativeContext.workspaceMemberId, + resolveSharedProject: async (projectId, scope) => ({ + projectId, + ownerMemberId: + scope?.ownerMemberId ?? authoritativeContext.workspaceMemberId, + sharedAt: '2026-07-30T00:00:00.000Z', + }), + }, + { + adapter: { + publish, + pull, + syncLatest: vi.fn(async () => ({ version: 1 })), + }, + }, + ); + const headers = { + 'x-od-workspace-id': 'ws-spoofed', + 'x-od-workspace-member-id': 'member-spoofed', + 'x-od-workspace-role': 'owner', + }; + + const intent = await api.json('/api/projects/spoofed-project/collab/sync-intent', { + method: 'POST', + workspaceScope: false, + headers, + body: { + event: 'project_team_share_requested', + projectId: 'spoofed-project', + }, + }); + const status = await api.json( + '/api/projects/spoofed-project/collab/status', + { + workspaceScope: false, + headers, + }, + ); + const pullResponse = await api.json( + '/api/projects/spoofed-project/collab/pull', + { + method: 'POST', + workspaceScope: false, + headers, + }, + ); + + expect(intent.status).toBe(403); + expect(status.status).toBe(403); + expect(pullResponse.status).toBe(403); + expect(publish).not.toHaveBeenCalled(); + expect(pull).not.toHaveBeenCalled(); + expect(projectStore.has('spoofed-project')).toBe(false); + expect(runtime!.projectSyncState( + 'spoofed-project', + contextToResourceHubPrincipal(authoritativeContext)!, + )).toBe('local_only'); + }); + + it('returns retryable 503s before collab side effects when Workspace authority is unavailable', async () => { + const projectStore = fakeProjectStore(); + const resolveSharedProjectOwner = vi.fn(async () => 'member-1'); + const resolveSharedProject = vi.fn(async () => ({ + projectId: 'authority-outage', + ownerMemberId: 'member-1', + sharedAt: '2026-07-30T00:00:00.000Z', + })); + const api = await startSyncServer(undefined, { + projectStore, + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProjectOwner, + resolveSharedProject, + verifyWorkspaceRequest: async () => ({ + ok: false, + status: 503, + code: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + message: 'workspace membership authority is temporarily unavailable', + retryable: true, + }), + }); + const notifyChanged = vi.spyOn(runtime!.scheduler, 'notifyChanged'); + + const responses = [ + await api.json('/api/projects/authority-outage/collab/publish', { + method: 'POST', + }), + await api.json('/api/projects/authority-outage/collab/status'), + await api.json('/api/projects/authority-outage/collab/pull', { + method: 'POST', + }), + ]; + + for (const response of responses) { + expect(response.status).toBe(503); + expect(response.body).toMatchObject({ + error: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + retryable: true, + }); + } + expect(notifyChanged).not.toHaveBeenCalled(); + expect(resolveSharedProjectOwner).not.toHaveBeenCalled(); + expect(resolveSharedProject).not.toHaveBeenCalled(); + expect(projectStore.has('authority-outage')).toBe(false); + }); + + it('pulls the published head for a member (null before any publish)', async () => { + const api = await startSyncServer(undefined, { + projectStore: fakeProjectStore(), + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + }); + const before = await api.json('/api/projects/p1/collab/pull', { method: 'POST' }); + expect(before.status).toBe(403); + expect(before.body.error).toBe('WORKSPACE_PROJECT_PULL_DENIED'); + + await api.json('/api/projects/p1/collab/sync-intent', { + method: 'POST', + body: { event: 'project_team_share_requested', projectId: 'p1' }, + }); + await api.awaitPublishedVersion('/api/projects/p1/collab/status', null); + const after = await api.json('/api/projects/p1/collab/pull', { method: 'POST' }); + expect(after.body.version).toBe(1); + }); + + it('finishes the exact scoped transfer token when a shared pull succeeds', async () => { + const pullScope: TeamMirrorPullScope = { + workspaceId: 'ws-1', + resourceTeamId: 'team-1', + viewerMemberId: 'wm-1', + ownerMemberId: 'wm-owner', + }; + const transferStates = createProjectContentTransferStateStore(); + const beginContentTransfer = vi.fn( + (projectId: string, scope: TeamMirrorPullScope, version?: number) => + transferStates.begin({ projectId, ...scope }, version).token, + ); + const finishContentTransfer = vi.fn( + ( + projectId: string, + scope: TeamMirrorPullScope, + token: ReturnType, + version?: number, + ) => { + transferStates.finish({ projectId, ...scope }, token, version); + }, + ); + const api = await startSyncServer(fixedShareContextProvider(true), { + beginContentTransfer, + finishContentTransfer, + projectStore: fakeProjectStore(), + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProject: async (projectId) => ({ + projectId, + ownerMemberId: pullScope.ownerMemberId, + sharedAt: '2026-07-25T00:00:00.000Z', + }), + }); + await runtime!.requestTeamShare('p1', { + teamId: pullScope.resourceTeamId, + memberId: pullScope.ownerMemberId, + role: 'owner', + lifecycleState: 'active', + workspaceType: 'team', + }); + + const pull = await api.handle.pullSharedProject('p1', pullScope); + + expect(pull).toEqual({ status: 'pulled', version: 1 }); + expect(beginContentTransfer).toHaveBeenCalledTimes(1); + const [projectId, scope, version] = + beginContentTransfer.mock.calls[0]!; + const token = beginContentTransfer.mock.results[0]!.value; + expect(projectId).toBe('p1'); + expect(scope).toEqual(pullScope); + expect(version).toBeUndefined(); + expect(finishContentTransfer).toHaveBeenCalledWith( + 'p1', + scope, + token, + 1, + ); + expect(transferStates.read({ projectId, ...scope })).toMatchObject({ + status: 'idle', + version: 1, + }); + }); + + it('refuses to pull a project that is no longer team-shared (revocation)', async () => { + // The team catalog no longer lists this project (the owner moved it out of + // the team). A stale local copy on a former member's daemon must not be able + // to keep pulling fresh content, and the mirror is flagged revoked so its + // files stop being served. + const revoked: Array<{ projectId: string; revoked: boolean }> = []; + const api = await startSyncServer(fixedShareContextProvider(true), { + resolveSharedProjectOwner: async () => 'wm-1', + resolveSharedProject: async () => null, + markTeamProjectRevoked: (projectId, value) => revoked.push({ projectId, revoked: value }), + }); + + const pull = await api.json('/api/projects/moved-out-project/collab/pull', { method: 'POST' }); + + expect(pull.status).toBe(403); + expect(pull.body.error).toBe('WORKSPACE_PROJECT_PULL_DENIED'); + expect(revoked).toContainEqual({ projectId: 'moved-out-project', revoked: true }); + }); + + it('does not register a placeholder project when there is no published version to pull', async () => { + const store = fakeProjectStore(); + const api = await startSyncServer(undefined, { + projectStore: store, + resolveSharedProject: async (projectId) => ({ + projectId, + ownerMemberId: 'wm-1', + sharedAt: '2026-07-30T00:00:00.000Z', + }), + }); + + const pull = await api.json('/api/projects/unpublished-shared/collab/pull', { method: 'POST' }); + expect(pull.status).toBe(200); + expect(pull.body.version).toBeNull(); + expect(store.has('unpublished-shared')).toBe(false); + }); + + it('fails the pull route when a pulled shared project cannot be registered locally', async () => { + const api = await startSyncServer(undefined, { + projectStore: { + get: () => ({ name: 'Existing project' }), + has: () => true, + register: () => {}, + materializeTeamMirror: () => { + throw new Error('project store unavailable'); + }, + }, + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + }); + + await api.json('/api/projects/shared-register-fail/collab/sync-intent', { + method: 'POST', + body: { event: 'project_team_share_requested' }, + }); + await api.awaitPublishedVersion('/api/projects/shared-register-fail/collab/status', null); + const pull = await api.json('/api/projects/shared-register-fail/collab/pull', { method: 'POST' }); + expect(pull.status).toBe(502); + expect(pull.body.error).toBe('TEAM_PROJECT_PULL_REGISTER_UNAVAILABLE'); + }); + + it('registers a pulled shared project locally so it appears in the project store', async () => { + const store = fakeProjectStore(); + const api = await startSyncServer(undefined, { + projectStore: store, + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + }); + + expect(store.has('shared-1')).toBe(false); + await api.json('/api/projects/shared-1/collab/sync-intent', { + method: 'POST', + body: { event: 'project_team_share_requested' }, + }); + await api.awaitPublishedVersion('/api/projects/shared-1/collab/status', null); + const pull = await api.json('/api/projects/shared-1/collab/pull', { method: 'POST' }); + expect(pull.status).toBe(200); + + // The pull registered a local project record. With no manifest under the + // (non-existent) pull dir, it falls back to the placeholder name. + expect(store.has('shared-1')).toBe(true); + expect(store.projects.get('shared-1')?.name).toBe('共享项目'); + }); + + it('notifies notifyFilesChanged after a successful pull, so an open FileViewer refreshes without depending on chokidar surviving the pull\'s directory-replace (recvq6CIesNvWZ)', async () => { + const store = fakeProjectStore(); + const notifyFilesChanged = vi.fn(); + const api = await startSyncServer(undefined, { + projectStore: store, + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + notifyFilesChanged, + }); + + await api.json('/api/projects/shared-notify/collab/sync-intent', { + method: 'POST', + body: { event: 'project_team_share_requested' }, + }); + await api.awaitPublishedVersion('/api/projects/shared-notify/collab/status', null); + expect(notifyFilesChanged).not.toHaveBeenCalled(); + const pull = await api.json('/api/projects/shared-notify/collab/pull', { method: 'POST' }); + expect(pull.status).toBe(200); + expect(notifyFilesChanged).toHaveBeenCalledTimes(1); + expect(notifyFilesChanged).toHaveBeenCalledWith('shared-notify'); + }); + + it('does not call notifyFilesChanged when the pulled project fails to register locally', async () => { + const notifyFilesChanged = vi.fn(); + const api = await startSyncServer(undefined, { + projectStore: { + get: () => ({ name: 'Existing project' }), + has: () => true, + register: () => {}, + materializeTeamMirror: () => { + throw new Error('project store unavailable'); + }, + }, + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + notifyFilesChanged, + }); + + await api.json('/api/projects/shared-notify-fail/collab/sync-intent', { + method: 'POST', + body: { event: 'project_team_share_requested' }, + }); + await api.awaitPublishedVersion('/api/projects/shared-notify-fail/collab/status', null); + const pull = await api.json('/api/projects/shared-notify-fail/collab/pull', { method: 'POST' }); + expect(pull.status).toBe(502); + expect(notifyFilesChanged).not.toHaveBeenCalled(); + }); + + // recvqhwv6RPU1j: replacing the "共享项目" placeholder record with the real + // project name happens only in the daemon DB (registerPulledProject). The + // only post-pull signal used to be `file-changed`, which makes the web + // refresh the FILE LIST but never re-read the project record — so a member's + // sidebar/tab title stayed on the placeholder until a manual page reload. + // A pull that registered or updated the local record must also emit the + // existing `project-metadata-changed` thin signal (notifyProjectMetadataChanged) + // so the open project view re-fetches the record and the title follows. + it('notifies notifyProjectMetadataChanged when a pull replaces the placeholder record with the real name (recvqhwv6RPU1j)', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'od-pull-')); + tempDirs.push(dir); + await writeProjectManifest(dir, { + schemaVersion: 1, + id: 'shared-title-notify', + name: 'Q3 Marketing Site', + createdAt: 111, + updatedAt: 222, + }); + + const store = fakeProjectStore(); + store.register({ + id: 'shared-title-notify', + name: '共享项目', + skillId: null, + designSystemId: null, + createdAt: 1, + updatedAt: 1, + }); + + const notifyProjectMetadataChanged = vi.fn(); + const api = await startSyncServer(undefined, { + projectStore: store, + resolvePullDir: () => dir, + notifyProjectMetadataChanged, + }); + + await api.json('/api/projects/shared-title-notify/collab/sync-intent', { + method: 'POST', + body: { event: 'project_team_share_requested' }, + }); + await api.awaitPublishedVersion('/api/projects/shared-title-notify/collab/status', null); + expect(notifyProjectMetadataChanged).not.toHaveBeenCalled(); + const pull = await api.json('/api/projects/shared-title-notify/collab/pull', { method: 'POST' }); + expect(pull.status).toBe(200); + expect(store.projects.get('shared-title-notify')?.name).toBe('Q3 Marketing Site'); + expect(notifyProjectMetadataChanged).toHaveBeenCalledTimes(1); + expect(notifyProjectMetadataChanged).toHaveBeenCalledWith('shared-title-notify'); + }); + + it('does not notify notifyProjectMetadataChanged when the pulled project already has its real name locally', async () => { + const store = fakeProjectStore(); + store.register({ + id: 'shared-title-steady', + name: 'Already Local', + skillId: null, + designSystemId: null, + createdAt: 1, + updatedAt: 1, + }); + + const notifyProjectMetadataChanged = vi.fn(); + const api = await startSyncServer(undefined, { + projectStore: store, + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + notifyProjectMetadataChanged, + }); + + await api.json('/api/projects/shared-title-steady/collab/sync-intent', { + method: 'POST', + body: { event: 'project_team_share_requested' }, + }); + await api.awaitPublishedVersion('/api/projects/shared-title-steady/collab/status', null); + const pull = await api.json('/api/projects/shared-title-steady/collab/pull', { method: 'POST' }); + expect(pull.status).toBe(200); + // A content-only pull of an already-named local project changes no + // metadata the web renders; no spurious refetch signal. + expect(notifyProjectMetadataChanged).not.toHaveBeenCalled(); + }); + + it('prefers the hub project name and metadata when registering a pulled project', async () => { + const store = fakeProjectStore(); + const api = await startSyncServer(undefined, { + projectStore: store, + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProject: async (projectId) => ({ + projectId, + ownerMemberId: 'wm-owner', + sharedAt: '2026-07-09T00:00:00.000Z', + name: 'Emerald Editorial', + skillId: 'deck-builder', + designSystemId: 'ds-emerald', + createdAt: 123, + updatedAt: 456, + metadata: { kind: 'deck', entryFile: 'index.html' }, + }), + }); + + await runtime!.requestTeamShare('shared-from-hub', { + teamId: 'team-1', + memberId: 'wm-owner', + role: 'owner', + lifecycleState: 'active', + workspaceType: 'team', + }); + const pull = await api.json('/api/projects/shared-from-hub/collab/pull', { method: 'POST' }); + expect(pull.status).toBe(200); + + const registered = store.projects.get('shared-from-hub'); + expect(registered?.name).toBe('Emerald Editorial'); + expect(registered?.skillId).toBe('deck-builder'); + expect(registered?.designSystemId).toBe('ds-emerald'); + expect(registered?.createdAt).toBe(123); + expect(registered?.updatedAt).toBe(456); + expect(registered?.metadata).toEqual({ kind: 'deck', entryFile: 'index.html' }); + }); + + it('registers a pulled shared project under its real name from the manifest', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'od-pull-')); + tempDirs.push(dir); + // The shared tree carries the owner's project manifest; register-on-pull + // reads it so the local record shows the real name after opening. + await writeProjectManifest(dir, { + schemaVersion: 1, + id: 'shared-2', + name: 'Team Roadmap', + createdAt: 111, + updatedAt: 222, + skillId: 'live-artifact', + designSystemId: 'ds-9', + }); + + const store = fakeProjectStore(); + const api = await startSyncServer(undefined, { + projectStore: store, + resolvePullDir: () => dir, + }); + + await api.json('/api/projects/shared-2/collab/sync-intent', { + method: 'POST', + body: { event: 'project_team_share_requested' }, + }); + await api.awaitPublishedVersion('/api/projects/shared-2/collab/status', null); + await api.json('/api/projects/shared-2/collab/pull', { method: 'POST' }); + const registered = store.projects.get('shared-2'); + expect(registered?.name).toBe('Team Roadmap'); + expect(registered?.skillId).toBe('live-artifact'); + expect(registered?.designSystemId).toBe('ds-9'); + expect(registered?.createdAt).toBe(111); + expect(registered?.updatedAt).toBe(222); + }); + + it('infers a pulled shared project name from the bundled skill manifest when no project manifest exists', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'od-pull-')); + tempDirs.push(dir); + await mkdir(path.join(dir, '.od-skills', 'fs-emerald'), { recursive: true }); + await writeFile( + path.join(dir, '.od-skills', 'fs-emerald', 'open-design.json'), + JSON.stringify({ title: 'Emerald Editorial', name: 'example-fs-emerald-editorial' }), + ); + + const store = fakeProjectStore(); + const api = await startSyncServer(undefined, { + projectStore: store, + resolvePullDir: () => dir, + }); + + await api.json('/api/projects/shared-skill/collab/sync-intent', { + method: 'POST', + body: { event: 'project_team_share_requested' }, + }); + await api.awaitPublishedVersion('/api/projects/shared-skill/collab/status', null); + await api.json('/api/projects/shared-skill/collab/pull', { method: 'POST' }); + expect(store.projects.get('shared-skill')?.name).toBe('Emerald Editorial'); + }); + + it('repairs an existing placeholder pulled project name once pulled files expose a title', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'od-pull-')); + tempDirs.push(dir); + await mkdir(path.join(dir, '.od-skills', 'fs-emerald'), { recursive: true }); + await writeFile( + path.join(dir, '.od-skills', 'fs-emerald', 'open-design.json'), + JSON.stringify({ title: 'Emerald Editorial' }), + ); + + const store = fakeProjectStore(); + store.register({ + id: 'shared-placeholder', + name: '共享项目', + skillId: null, + designSystemId: null, + createdAt: 1, + updatedAt: 1, + }); + + const api = await startSyncServer(undefined, { + projectStore: store, + resolvePullDir: () => dir, + }); + + await api.json('/api/projects/shared-placeholder/collab/sync-intent', { + method: 'POST', + body: { event: 'project_team_share_requested' }, + }); + await api.awaitPublishedVersion('/api/projects/shared-placeholder/collab/status', null); + await api.json('/api/projects/shared-placeholder/collab/pull', { method: 'POST' }); + expect(store.registerCalls).toBe(1); + expect(store.projects.get('shared-placeholder')?.name).toBe('Emerald Editorial'); + }); + + it('is idempotent — a pull for an already-local project does not re-register it', async () => { + const store = fakeProjectStore(); + store.register({ + id: 'shared-3', + name: 'Already Local', + skillId: null, + designSystemId: null, + createdAt: 1, + updatedAt: 1, + }); + expect(store.registerCalls).toBe(1); + + const api = await startSyncServer(undefined, { + projectStore: store, + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + }); + + await api.json('/api/projects/shared-3/collab/pull', { method: 'POST' }); + // Still exactly one registration; the existing record is left untouched. + expect(store.registerCalls).toBe(1); + expect(store.projects.get('shared-3')?.name).toBe('Already Local'); + }); + + it('derives read-only from the hub at status time — no pull or in-memory record needed', async () => { + const api = await startSyncServer(undefined, { + resolveSharedProjectOwner: async (projectId) => + projectId === 'shared-ro' ? 'wm-owner' : null, + }); + + // Straight to status: no pull, no in-memory share record. A project the hub + // lists as shared by wm-owner reports synced + that owner, so a non-owner + // member's client (`shared && !isOwner`) renders it single-writer read-only. + // Deriving every read is what makes read-only survive a daemon restart (which + // clears the in-memory maps) and an already-pulled project opened without a + // re-pull — the bug was the pull never recording this at all. + const status = await api.json('/api/projects/shared-ro/collab/status'); + expect(status.body.syncState).toBe('synced'); + expect(status.body.ownerMemberId).toBe('wm-owner'); + }); + + it('leaves a project the hub does not list editable (local_only)', async () => { + const api = await startSyncServer(undefined, { + resolveSharedProjectOwner: async () => null, + }); + const status = await api.json('/api/projects/not-shared/collab/status'); + // Not team-shared → no read-only: the member keeps full edit on their own + // local project. Read-only never fires just because a status probe ran. + expect(status.body.syncState).toBe('local_only'); + expect(status.body.ownerMemberId).toBeNull(); + }); +}); + +// recvqmKQRiIlYf: the hub push channel needs a daemon-internal way to run the +// SAME pull flow POST /collab/pull runs (revocation gate → pull → register → +// file/metadata signals) without an HTTP request, and racing pulls for one +// project (proactive pull vs the member web's poll-triggered POST) must +// coalesce onto one materialization instead of two full-tree pulls. +describe('collab sync pull handle (daemon-internal proactive pull)', () => { + const pullScope: TeamMirrorPullScope = { + workspaceId: 'ws-1', + resourceTeamId: 'team-1', + viewerMemberId: 'wm-1', + ownerMemberId: 'wm-owner', + }; + const resolvePulledSharedProject = async (projectId: string) => ({ + projectId, + ownerMemberId: pullScope.ownerMemberId, + sharedAt: '2026-07-25T00:00:00.000Z', + }); + const authorizedReceipt = ( + projectId: string, + version: number, + ): AuthorizedTeamProjectPullReceipt => { + const now = Date.now(); + return { + schemaVersion: 1, + ...pullScope, + projectId, + resourceId: projectResourceIdFor(projectId, { + teamId: pullScope.resourceTeamId, + memberId: pullScope.ownerMemberId, + role: 'member', + lifecycleState: 'active', + workspaceType: 'team', + }), + ref: 'published', + version, + versionId: `version-${version}`, + manifestDigest: `sha256:${'a'.repeat(64)}`, + lifecycleState: 'active', + // Exercise the allowed small positive server/local clock skew while + // leaving enough wall-clock headroom for parallel test scheduling. + authorizedAt: new Date(now + 500).toISOString(), + expiresAt: new Date(now + 2_500).toISOString(), + }; + }; + + it('materializes content and fires the same post-pull signals as POST /collab/pull', async () => { + const store = fakeProjectStore(); + const notifyFilesChanged = vi.fn(); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: store, + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProject: resolvePulledSharedProject, + notifyFilesChanged, + }); + + await runtime!.requestTeamShare('handle-pull', { + teamId: pullScope.resourceTeamId, + memberId: pullScope.ownerMemberId, + role: 'owner', + lifecycleState: 'active', + workspaceType: 'team', + }); + + const outcome = await api.handle.pullSharedProject('handle-pull', pullScope); + expect(outcome).toEqual({ status: 'pulled', version: 1 }); + expect(store.has('handle-pull')).toBe(true); + expect(store.bindings.get('handle-pull')).toEqual(pullScope); + expect(notifyFilesChanged).toHaveBeenCalledTimes(1); + expect(notifyFilesChanged).toHaveBeenCalledWith('handle-pull'); + }); + + it('fails both pull surfaces before notifications when the durable cursor cannot commit', async () => { + const materializedVersion = 4; + const notifyFilesChanged = vi.fn(); + const notifyProjectMetadataChanged = vi.fn(); + const writeMaterializedVersion = vi.fn(async () => { + throw new Error('cursor disk unavailable'); + }); + const store = fakeProjectStore(); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: store, + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProjectOwner: async () => pullScope.ownerMemberId, + resolveSharedProject: resolvePulledSharedProject, + readMaterializedVersion: () => materializedVersion, + writeMaterializedVersion, + notifyFilesChanged, + notifyProjectMetadataChanged, + }, { + adapter: { + publish: vi.fn(async () => ({ version: 5 })), + pull: vi.fn(async () => ({ version: 5 })), + syncLatest: vi.fn(async () => ({ version: 5 })), + }, + }); + + expect(await api.awaitPublishedVersion( + '/api/projects/cursor-fail/collab/status', + null, + )).toBe(5); + const before = await api.json('/api/projects/cursor-fail/collab/status'); + expect(before.body.materializedVersion).toBe(4); + + const viaHandle = await api.handle.pullSharedProject('cursor-fail', pullScope); + expect(viaHandle).toEqual({ status: 'register_failed' }); + const viaRoute = await api.json('/api/projects/cursor-fail/collab/pull', { + method: 'POST', + headers: { + 'x-od-workspace-id': pullScope.workspaceId, + 'x-od-workspace-member-id': pullScope.viewerMemberId, + 'x-od-workspace-role': 'member', + }, + }); + expect(viaRoute.status).toBe(502); + expect(notifyFilesChanged).not.toHaveBeenCalled(); + expect(notifyProjectMetadataChanged).not.toHaveBeenCalled(); + + // A failed disk commit never advances the value status exposes, so both + // proactive events and the web floor remain free to retry version 5. + const after = await api.json('/api/projects/cursor-fail/collab/status'); + expect(after.body.materializedVersion).toBe(4); + }); + + it('fails closed when a scoped pull cannot prove the team mirror binding', async () => { + const notifyFilesChanged = vi.fn(); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: { + get: () => null, + has: () => false, + register: () => undefined, + }, + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProject: resolvePulledSharedProject, + notifyFilesChanged, + }, { + adapter: { + publish: vi.fn(async () => ({ version: 5 })), + pull: vi.fn(async () => ({ version: 5 })), + syncLatest: vi.fn(async () => ({ version: 5 })), + }, + }); + + const outcome = await api.handle.pullSharedProject('handle-unbound', pullScope); + expect(outcome).toEqual({ status: 'register_failed' }); + expect(notifyFilesChanged).not.toHaveBeenCalled(); + }); + + it('fails closed when the authoritative shared-project lookup throws for a scoped pull', async () => { + const adapterPull = vi.fn(async () => ({ version: 5 })); + const store = fakeProjectStore(); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: store, + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProject: async () => { + throw new Error('catalog unavailable'); + }, + }, { + adapter: { + publish: vi.fn(async () => ({ version: 5 })), + pull: adapterPull, + syncLatest: vi.fn(async () => ({ version: 5 })), + }, + }); + + const outcome = await api.handle.pullSharedProject('handle-catalog-error', pullScope); + expect(outcome).toEqual({ status: 'register_failed' }); + expect(adapterPull).not.toHaveBeenCalled(); + expect(store.has('handle-catalog-error')).toBe(false); + }); + + it('starts the independent initial scope and catalog guards concurrently', async () => { + let releaseInitialScope!: () => void; + let initialScopeStarted!: () => void; + const initialScopeGate = new Promise((resolve) => { + releaseInitialScope = resolve; + }); + const initialScopeStart = new Promise((resolve) => { + initialScopeStarted = resolve; + }); + let scopeCalls = 0; + const verifyWorkspaceScope = vi.fn(async () => { + scopeCalls += 1; + if (scopeCalls === 1) { + initialScopeStarted(); + await initialScopeGate; + } + return true; + }); + const resolveSharedProject = vi.fn(async (projectId: string) => ({ + projectId, + ownerMemberId: pullScope.ownerMemberId, + sharedAt: '2026-07-25T00:00:00.000Z', + })); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: fakeProjectStore(), + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProject, + verifyWorkspaceScope, + }, { + adapter: { + publish: vi.fn(async () => ({ version: 5 })), + pull: vi.fn(async () => ({ version: 5 })), + syncLatest: vi.fn(async () => ({ version: 5 })), + }, + }); + + const outcomePromise = api.handle.pullSharedProject( + 'handle-parallel-guards', + pullScope, + ); + await initialScopeStart; + await Promise.resolve(); + const catalogStartedBeforeInitialScopeFinished = + resolveSharedProject.mock.calls.length; + releaseInitialScope(); + + await expect(outcomePromise).resolves.toEqual({ + status: 'pulled', + version: 5, + }); + expect(catalogStartedBeforeInitialScopeFinished).toBe(1); + }); + + it('reuses a fresh internal guard witness but keeps post-pull reauthorization', async () => { + const resolveSharedProject = vi.fn(resolvePulledSharedProject); + const verifyWorkspaceScope = vi.fn(async () => true); + const onPullTiming = vi.fn(); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: fakeProjectStore(), + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProject, + verifyWorkspaceScope, + onPullTiming, + }, { + adapter: { + publish: vi.fn(async () => ({ version: 5 })), + pull: vi.fn(async () => ({ version: 5 })), + syncLatest: vi.fn(async () => ({ version: 5 })), + }, + }); + + const projectId = 'handle-fresh-witness'; + const witness = await mintProactivePullWitness(projectId, pullScope, 5); + const outcome = await api.handle.pullSharedProject( + projectId, + pullScope, + witness, + 5, + ); + + expect(outcome).toEqual({ status: 'pulled', version: 5 }); + // The witness replaces only the duplicate PRE-transport checks. The final + // uncached catalog + exact-scope gates still run immediately before the + // mirror transaction. + expect(resolveSharedProject).toHaveBeenCalledTimes(1); + expect(verifyWorkspaceScope).toHaveBeenCalledTimes(1); + expect(onPullTiming).toHaveBeenCalledWith(expect.objectContaining({ + phase: 'initial-authorization-reused', + projectId: 'handle-fresh-witness', + version: 5, + })); + }); + + it('falls back to all initial gates for a copied witness', async () => { + const resolveSharedProject = vi.fn(resolvePulledSharedProject); + const verifyWorkspaceScope = vi.fn(async () => true); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: fakeProjectStore(), + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProject, + verifyWorkspaceScope, + }, { + adapter: { + publish: vi.fn(async () => ({ version: 5 })), + pull: vi.fn(async () => ({ version: 5 })), + syncLatest: vi.fn(async () => ({ version: 5 })), + }, + }); + + const projectId = 'handle-copied-witness'; + const witness = await mintProactivePullWitness(projectId, pullScope, 5); + const outcome = await api.handle.pullSharedProject( + projectId, + pullScope, + { ...witness }, + 5, + ); + + expect(outcome).toEqual({ status: 'pulled', version: 5 }); + expect(resolveSharedProject).toHaveBeenCalledTimes(2); + expect(verifyWorkspaceScope).toHaveBeenCalledTimes(3); + }); + + it('ignores an authorization-witness-shaped HTTP body', async () => { + const resolveSharedProject = vi.fn(resolvePulledSharedProject); + const onPullTiming = vi.fn(); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: fakeProjectStore(), + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProjectOwner: async () => pullScope.ownerMemberId, + resolveSharedProject, + onPullTiming, + }, { + adapter: { + publish: vi.fn(async () => ({ version: 5 })), + pull: vi.fn(async () => ({ version: 5 })), + syncLatest: vi.fn(async () => ({ version: 5 })), + }, + }); + const projectId = 'http-witness-injection'; + const witness = await mintProactivePullWitness(projectId, pullScope, 5); + + const response = await api.json( + `/api/projects/${projectId}/collab/pull`, + { + method: 'POST', + headers: { + 'x-od-workspace-id': pullScope.workspaceId, + 'x-od-workspace-member-id': pullScope.viewerMemberId, + 'x-od-workspace-role': 'member', + }, + body: { + authorizationWitness: witness, + expectedVersion: 5, + }, + }, + ); + + expect(response.status).toBe(200); + expect(resolveSharedProject).toHaveBeenCalledTimes(2); + expect( + onPullTiming.mock.calls.some( + ([event]) => event.phase === 'initial-authorization-reused', + ), + ).toBe(false); + }); + + it('uses the authorized staged transport only for a branded proactive invocation', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'od-authorized-route-')); + tempDirs.push(root); + const liveDir = path.join(root, 'project'); + const stageDir = path.join(root, '.project.od-pull-stage-test'); + await mkdir(stageDir); + await writeFile(path.join(stageDir, 'index.html'), 'Staged project'); + const receipt = authorizedReceipt('authorized-fast', 5); + const stage = vi.fn(async () => ({ + stageDir, + identity: { dev: '1', ino: '2' }, + receipt, + cleanup: vi.fn(async () => undefined), + })); + const promote = vi.fn(async (input: { + commit: () => { localRecordChanged: boolean }; + isExpectedVersion: () => boolean; + validateReceipt: () => void; + }) => { + expect(input.isExpectedVersion()).toBe(true); + input.validateReceipt(); + return input.commit(); + }); + const adapterPull = vi.fn(async () => ({ version: 5 })); + const store = fakeProjectStore(); + const onPullTiming = vi.fn(); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: store, + resolvePullDir: () => liveDir, + resolveSharedProject: resolvePulledSharedProject, + onPullTiming, + authorizedTeamProjectPull: { + journalDir: path.join(root, '.journals'), + getActiveWorkspaceSnapshot: () => ({ + workspaceId: pullScope.workspaceId, + generation: 7, + }), + stage, + promote, + }, + }, { + adapter: { + publish: vi.fn(async () => ({ version: 5 })), + pull: adapterPull, + syncLatest: vi.fn(async () => ({ version: 5 })), + }, + }); + const profileReceivedAtMs = Date.now() - 100; + const outcome = await invokeThroughProactivePull( + api.handle, + 'authorized-fast', + pullScope, + 5, + profileReceivedAtMs, + ); + + expect(outcome).toEqual({ status: 'pulled', version: 5 }); + expect(stage).toHaveBeenCalledWith(expect.objectContaining({ + projectId: 'authorized-fast', + liveDir, + scope: pullScope, + expectedVersion: 5, + })); + expect(promote).toHaveBeenCalledTimes(1); + expect(adapterPull).not.toHaveBeenCalled(); + expect(store.projects.get('authorized-fast')?.name).toBe('Staged project'); + expect(onPullTiming.mock.calls.map(([event]) => event.phase)).toEqual([ + 'route-started', + 'authorized-stage-started', + 'authorized-stage-done', + 'authorized-receipt-validated', + 'authorized-scope-revalidated', + 'promotion-started', + 'version-persisted', + 'promotion-done', + 'route-completed', + ]); + expect(onPullTiming.mock.calls.map(([event]) => event.receivedAtMs)) + .toEqual(Array(9).fill(profileReceivedAtMs)); + }); + + it('re-acquires one fresh authorized stage when promotion outlives the receipt', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'od-authorized-retry-')); + tempDirs.push(root); + const projectId = 'authorized-retry'; + const stageDirs = [ + path.join(root, `.${projectId}.od-pull-stage-first`), + path.join(root, `.${projectId}.od-pull-stage-second`), + ]; + await Promise.all(stageDirs.map(async (stageDir) => { + await mkdir(stageDir); + await writeFile(path.join(stageDir, 'index.html'), 'Fresh stage'); + })); + let now = Date.parse('2026-08-02T10:00:00.000Z'); + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); + const cleanups = [vi.fn(async () => undefined), vi.fn(async () => undefined)]; + const stage = vi.fn(async () => { + const index = stage.mock.calls.length - 1; + return { + stageDir: stageDirs[index]!, + identity: { dev: String(index + 1), ino: String(index + 10) }, + receipt: authorizedReceipt(projectId, 5), + cleanup: cleanups[index]!, + }; + }); + const promote = vi.fn(async (input: { + commit: () => { localRecordChanged: boolean }; + validateReceipt: () => void; + }) => { + if (promote.mock.calls.length === 1) now += 3_000; + input.validateReceipt(); + return input.commit(); + }); + const store = fakeProjectStore(); + const materialize = vi.spyOn(store, 'materializeAuthorizedTeamMirror'); + + try { + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: store, + resolvePullDir: (id) => path.join(root, id), + resolveSharedProject: resolvePulledSharedProject, + authorizedTeamProjectPull: { + journalDir: path.join(root, '.journals'), + getActiveWorkspaceSnapshot: () => ({ + workspaceId: pullScope.workspaceId, + generation: 7, + }), + stage, + promote, + }, + }); + + await expect(invokeThroughProactivePull( + api.handle, + projectId, + pullScope, + 5, + )).resolves.toEqual({ status: 'pulled', version: 5 }); + } finally { + nowSpy.mockRestore(); + } + + expect(stage).toHaveBeenCalledTimes(2); + expect(promote).toHaveBeenCalledTimes(2); + expect(materialize).toHaveBeenCalledTimes(1); + expect(cleanups[0]).toHaveBeenCalledTimes(1); + expect(cleanups[1]).toHaveBeenCalledTimes(1); + }); + + it('bounds stale authorized-stage recovery to one retry', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'od-authorized-retry-bound-')); + tempDirs.push(root); + const projectId = 'authorized-retry-bound'; + const stageDirs = [ + path.join(root, `.${projectId}.od-pull-stage-first`), + path.join(root, `.${projectId}.od-pull-stage-second`), + ]; + await Promise.all(stageDirs.map(async (stageDir) => { + await mkdir(stageDir); + await writeFile(path.join(stageDir, 'index.html'), 'Bounded stage'); + })); + let now = Date.parse('2026-08-02T11:00:00.000Z'); + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); + const cleanups = [vi.fn(async () => undefined), vi.fn(async () => undefined)]; + const stage = vi.fn(async () => { + const index = stage.mock.calls.length - 1; + return { + stageDir: stageDirs[index]!, + identity: { dev: String(index + 1), ino: String(index + 10) }, + receipt: authorizedReceipt(projectId, 5), + cleanup: cleanups[index]!, + }; + }); + const promote = vi.fn(async (input: { validateReceipt: () => void }) => { + now += 3_000; + input.validateReceipt(); + throw new Error('unreachable'); + }); + const store = fakeProjectStore(); + const materialize = vi.spyOn(store, 'materializeAuthorizedTeamMirror'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + try { + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: store, + resolvePullDir: (id) => path.join(root, id), + resolveSharedProject: resolvePulledSharedProject, + authorizedTeamProjectPull: { + journalDir: path.join(root, '.journals'), + getActiveWorkspaceSnapshot: () => ({ + workspaceId: pullScope.workspaceId, + generation: 7, + }), + stage, + promote, + }, + }); + + await expect(invokeThroughProactivePull( + api.handle, + projectId, + pullScope, + 5, + )).resolves.toEqual({ status: 'register_failed' }); + } finally { + nowSpy.mockRestore(); + warn.mockRestore(); + } + + expect(stage).toHaveBeenCalledTimes(2); + expect(promote).toHaveBeenCalledTimes(2); + expect(materialize).not.toHaveBeenCalled(); + expect(cleanups[0]).toHaveBeenCalledTimes(1); + expect(cleanups[1]).toHaveBeenCalledTimes(1); + }); + + it('does not reacquire a receipt when the expired stage cannot be cleaned', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'od-authorized-retry-cleanup-')); + tempDirs.push(root); + const projectId = 'authorized-retry-cleanup'; + const stageDir = path.join(root, `.${projectId}.od-pull-stage-first`); + await mkdir(stageDir); + await writeFile(path.join(stageDir, 'index.html'), 'Unclean stage'); + let now = Date.parse('2026-08-02T12:00:00.000Z'); + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); + const cleanup = vi.fn(async () => { + throw new Error('stage cleanup failed'); + }); + const stage = vi.fn(async () => ({ + stageDir, + identity: { dev: '1', ino: '10' }, + receipt: authorizedReceipt(projectId, 5), + cleanup, + })); + const promote = vi.fn(async (input: { validateReceipt: () => void }) => { + now += 3_000; + input.validateReceipt(); + throw new Error('unreachable'); + }); + const store = fakeProjectStore(); + const materialize = vi.spyOn(store, 'materializeAuthorizedTeamMirror'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + try { + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: store, + resolvePullDir: (id) => path.join(root, id), + resolveSharedProject: resolvePulledSharedProject, + authorizedTeamProjectPull: { + journalDir: path.join(root, '.journals'), + getActiveWorkspaceSnapshot: () => ({ + workspaceId: pullScope.workspaceId, + generation: 7, + }), + stage, + promote, + }, + }); + + await expect(invokeThroughProactivePull( + api.handle, + projectId, + pullScope, + 5, + )).resolves.toEqual({ status: 'register_failed' }); + } finally { + nowSpy.mockRestore(); + warn.mockRestore(); + } + + expect(stage).toHaveBeenCalledTimes(1); + expect(promote).toHaveBeenCalledTimes(1); + expect(cleanup).toHaveBeenCalledTimes(1); + expect(materialize).not.toHaveBeenCalled(); + }); + + it('coalesces direct, targeted, and broad recovery onto one stable authorized promotion', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'od-authorized-overlap-')); + tempDirs.push(root); + const projectId = 'authorized-overlap'; + const liveDir = path.join(root, projectId); + const stageDir = path.join( + root, + `.${projectId}.od-pull-stage-overlap`, + ); + await mkdir(liveDir); + await writeFile(path.join(liveDir, 'index.html'), 'old'); + await mkdir(stageDir); + await writeFile(path.join(stageDir, 'index.html'), 'new'); + let releaseStage!: () => void; + const stageGate = new Promise((resolve) => { + releaseStage = resolve; + }); + const stage = vi.fn(async () => { + await stageGate; + const identity = await lstat(stageDir); + return { + stageDir, + identity: { + dev: String(identity.dev), + ino: String(identity.ino), + }, + receipt: authorizedReceipt(projectId, 5), + cleanup: vi.fn(async () => undefined), + }; + }); + const observedLiveVersions: string[] = []; + const promote = vi.fn(( + input: PromoteAuthorizedTeamProjectStageInput<{ + localRecordChanged: boolean; + }>, + ) => + promoteAuthorizedTeamProjectStage({ + ...input, + durability: { + syncDirectory: async () => { + observedLiveVersions.push( + await readFile(path.join(liveDir, 'index.html'), 'utf8'), + ); + }, + }, + })); + const store = fakeProjectStore(); + const materializeAuthorized = vi.spyOn( + store, + 'materializeAuthorizedTeamMirror', + ); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: store, + resolvePullDir: () => liveDir, + resolveSharedProject: resolvePulledSharedProject, + authorizedTeamProjectPull: { + journalDir: path.join(root, '.journals'), + getActiveWorkspaceSnapshot: () => ({ + workspaceId: pullScope.workspaceId, + generation: 1, + }), + stage, + promote, + }, + }); + const proactive = createProactiveContentPull({ + getLocalBinding: () => ({ + workspaceId: pullScope.workspaceId, + visibility: 'team', + }), + getWorkspaceIdentity: async () => ({ + workspaceId: pullScope.workspaceId, + resourceTeamId: pullScope.resourceTeamId, + workspaceMemberId: pullScope.viewerMemberId, + }), + resolveSharedProjectOwner: async () => pullScope.ownerMemberId, + pullSharedProject: (target, version) => + api.handle.pullSharedProject( + target.projectId, + pullScope, + target.authorizationWitness, + version, + target.authorizedStageInvocation, + ), + listSharedProjects: async () => [{ + projectId, + ownerMemberId: pullScope.ownerMemberId, + }], + hasMaterializedProject: () => false, + publishedHead: async () => 5, + materializedVersion: () => '4', + }); + + try { + const direct = proactive.handleContentChanged({ + projectId, + workspaceId: pullScope.workspaceId, + version: 5, + }); + await vi.waitFor(() => expect(stage).toHaveBeenCalledTimes(1)); + expect(await readFile(path.join(liveDir, 'index.html'), 'utf8')) + .toBe('old'); + + const targeted = proactive.materializeMissingProjects( + pullScope.workspaceId, + projectId, + ); + const broad = proactive.catchUpPublishedHeads( + pullScope.workspaceId, + ); + await Promise.resolve(); + expect(stage).toHaveBeenCalledTimes(1); + releaseStage(); + await Promise.all([direct, targeted, broad]); + } finally { + proactive.dispose(); + } + + expect(stage).toHaveBeenCalledTimes(1); + expect(promote).toHaveBeenCalledTimes(1); + expect(materializeAuthorized).toHaveBeenCalledTimes(1); + expect(observedLiveVersions.every( + (version) => version === 'old' || version === 'new', + )).toBe(true); + expect(observedLiveVersions).toContain('old'); + expect(observedLiveVersions.at(-1)).toBe('new'); + expect(await readFile(path.join(liveDir, 'index.html'), 'utf8')) + .toBe('new'); + }); + + it('coalesces an authorized stage and legacy POST for the same scope and version', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'od-cross-lane-pull-')); + tempDirs.push(root); + const projectId = 'cross-lane-pull'; + const liveDir = path.join(root, projectId); + const stageDir = path.join(root, `.${projectId}.od-pull-stage-test`); + await mkdir(liveDir); + await writeFile(path.join(liveDir, 'index.html'), 'Version four'); + await mkdir(stageDir); + await writeFile(path.join(stageDir, 'index.html'), 'Version five'); + let releaseStage!: () => void; + const stageGate = new Promise((resolve) => { + releaseStage = resolve; + }); + const stage = vi.fn(async () => { + await stageGate; + const identity = await lstat(stageDir); + return { + stageDir, + identity: { + dev: String(identity.dev), + ino: String(identity.ino), + }, + receipt: authorizedReceipt(projectId, 5), + cleanup: vi.fn(async () => undefined), + }; + }); + const promote = vi.fn(( + input: PromoteAuthorizedTeamProjectStageInput<{ + localRecordChanged: boolean; + }>, + ) => promoteAuthorizedTeamProjectStage(input)); + const adapterPull = vi.fn(async () => ({ version: 5 })); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: fakeProjectStore(), + resolvePullDir: () => liveDir, + resolveSharedProjectOwner: async () => pullScope.ownerMemberId, + resolveSharedProject: resolvePulledSharedProject, + authorizedTeamProjectPull: { + journalDir: path.join(root, '.journals'), + getActiveWorkspaceSnapshot: () => ({ + workspaceId: pullScope.workspaceId, + generation: 1, + }), + stage, + promote, + }, + }, { + adapter: { + publish: vi.fn(async () => ({ version: 5 })), + pull: adapterPull, + syncLatest: vi.fn(async () => ({ version: 5 })), + }, + }); + const proactive = createProactiveContentPull({ + getLocalBinding: () => ({ + workspaceId: pullScope.workspaceId, + visibility: 'team', + }), + getWorkspaceIdentity: async () => ({ + workspaceId: pullScope.workspaceId, + resourceTeamId: pullScope.resourceTeamId, + workspaceMemberId: pullScope.viewerMemberId, + }), + resolveSharedProjectOwner: async () => pullScope.ownerMemberId, + pullSharedProject: (target, version) => + api.handle.pullSharedProject( + target.projectId, + pullScope, + target.authorizationWitness, + version, + target.authorizedStageInvocation, + ), + }); + + try { + const authorized = proactive.handleContentChanged({ + projectId, + workspaceId: pullScope.workspaceId, + version: 5, + }); + await vi.waitFor(() => expect(stage).toHaveBeenCalledTimes(1)); + const legacy = api.json(`/api/projects/${projectId}/collab/pull`, { + method: 'POST', + headers: { + 'x-od-workspace-id': pullScope.workspaceId, + 'x-od-workspace-member-id': pullScope.viewerMemberId, + 'x-od-workspace-role': 'member', + }, + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + releaseStage(); + + const [, legacyResponse] = await Promise.all([authorized, legacy]); + expect(legacyResponse.status).toBe(200); + expect(legacyResponse.body.version).toBe(5); + } finally { + proactive.dispose(); + } + + expect(stage).toHaveBeenCalledTimes(1); + expect(promote).toHaveBeenCalledTimes(1); + expect(adapterPull).not.toHaveBeenCalled(); + }); + + it('fails a joined authorized waiter closed when it becomes stale before legacy completion', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'od-cross-lane-stale-')); + tempDirs.push(root); + const projectId = 'cross-lane-stale'; + const liveDir = path.join(root, projectId); + await mkdir(liveDir); + await writeFile(path.join(liveDir, 'index.html'), 'Version five'); + let releaseLegacy!: () => void; + const legacyGate = new Promise((resolve) => { + releaseLegacy = resolve; + }); + let reportLegacyStarted!: () => void; + const legacyStarted = new Promise((resolve) => { + reportLegacyStarted = resolve; + }); + const adapterPull = vi.fn(async () => { + reportLegacyStarted(); + await legacyGate; + return { version: 5 }; + }); + const stage = vi.fn(); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: fakeProjectStore(), + resolvePullDir: () => liveDir, + resolveSharedProjectOwner: async () => pullScope.ownerMemberId, + resolveSharedProject: resolvePulledSharedProject, + authorizedTeamProjectPull: { + journalDir: path.join(root, '.journals'), + getActiveWorkspaceSnapshot: () => ({ + workspaceId: pullScope.workspaceId, + generation: 1, + }), + stage, + }, + }, { + adapter: { + publish: vi.fn(async () => ({ version: 5 })), + pull: adapterPull, + syncLatest: vi.fn(async () => ({ version: 5 })), + }, + }); + const onPulled = vi.fn(); + let reportAuthorizedJoined!: () => void; + const authorizedJoined = new Promise((resolve) => { + reportAuthorizedJoined = resolve; + }); + const proactive = createProactiveContentPull({ + getLocalBinding: () => ({ + workspaceId: pullScope.workspaceId, + visibility: 'team', + }), + getWorkspaceIdentity: async () => ({ + workspaceId: pullScope.workspaceId, + resourceTeamId: pullScope.resourceTeamId, + workspaceMemberId: pullScope.viewerMemberId, + }), + resolveSharedProjectOwner: async () => pullScope.ownerMemberId, + pullSharedProject: (target, version) => { + reportAuthorizedJoined(); + return api.handle.pullSharedProject( + target.projectId, + pullScope, + target.authorizationWitness, + version, + target.authorizedStageInvocation, + ); + }, + onPulled, + }); + + const legacy = api.json(`/api/projects/${projectId}/collab/pull`, { + method: 'POST', + headers: { + 'x-od-workspace-id': pullScope.workspaceId, + 'x-od-workspace-member-id': pullScope.viewerMemberId, + 'x-od-workspace-role': 'member', + }, + }); + await legacyStarted; + const authorized = proactive.handleContentChanged({ + projectId, + workspaceId: pullScope.workspaceId, + version: 5, + }); + await authorizedJoined; + proactive.dispose(); + releaseLegacy(); + + const [legacyResponse] = await Promise.all([legacy, authorized]); + expect(legacyResponse.status).toBe(200); + expect(stage).not.toHaveBeenCalled(); + expect(adapterPull).toHaveBeenCalledTimes(1); + expect(onPulled).not.toHaveBeenCalled(); + }); + + it('adopts a durable legacy success before a queued authorized retry', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'od-cross-lane-retry-')); + tempDirs.push(root); + const projectId = 'cross-lane-retry'; + const liveDir = path.join(root, projectId); + const stageDir = path.join(root, `.${projectId}.od-pull-stage-test`); + await mkdir(stageDir); + await writeFile(path.join(stageDir, 'index.html'), 'Version five'); + let durableVersion = 4; + const stage = vi.fn(async () => ({ + stageDir, + identity: { dev: '1', ino: '2' }, + receipt: authorizedReceipt(projectId, 5), + cleanup: vi.fn(async () => undefined), + })); + const promote = vi.fn(async () => { + throw new Error('promotion journal unavailable'); + }); + const adapterPull = vi.fn(async () => ({ version: 5 })); + const retryCallbacks: Array<() => void | Promise> = []; + let observeLegacyPull = async ( + _observedProjectId: string, + _observedScope: TeamMirrorPullScope, + _version: number, + ): Promise => {}; + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: fakeProjectStore(), + resolvePullDir: () => liveDir, + resolveSharedProjectOwner: async () => pullScope.ownerMemberId, + resolveSharedProject: resolvePulledSharedProject, + readMaterializedVersion: () => durableVersion, + writeMaterializedVersion: async (_id, _scope, version) => { + durableVersion = version; + }, + onLegacyPullMaterialized: (observedProjectId, observedScope, version) => + observeLegacyPull(observedProjectId, observedScope, version), + authorizedTeamProjectPull: { + journalDir: path.join(root, '.journals'), + getActiveWorkspaceSnapshot: () => ({ + workspaceId: pullScope.workspaceId, + generation: 1, + }), + stage, + promote, + }, + }, { + adapter: { + publish: vi.fn(async () => ({ version: 5 })), + pull: adapterPull, + syncLatest: vi.fn(async () => ({ version: 5 })), + }, + }); + const proactive = createProactiveContentPull({ + getLocalBinding: () => ({ + workspaceId: pullScope.workspaceId, + visibility: 'team', + }), + getWorkspaceIdentity: async () => ({ + workspaceId: pullScope.workspaceId, + resourceTeamId: pullScope.resourceTeamId, + workspaceMemberId: pullScope.viewerMemberId, + }), + resolveSharedProjectOwner: async () => pullScope.ownerMemberId, + pullSharedProject: (target, version) => + api.handle.pullSharedProject( + target.projectId, + pullScope, + target.authorizationWitness, + version, + target.authorizedStageInvocation, + ), + materializedVersion: () => String(durableVersion), + scheduler: { + setTimeout: (callback) => { + retryCallbacks.push(callback); + return callback; + }, + clearTimeout: (handle) => { + const index = retryCallbacks.indexOf( + handle as () => void | Promise, + ); + if (index >= 0) retryCallbacks.splice(index, 1); + }, + }, + }); + observeLegacyPull = (observedProjectId, observedScope, version) => + proactive.observeMaterialized( + { projectId: observedProjectId, ...observedScope }, + version, + ); + + try { + await proactive.handleContentChanged({ + projectId, + workspaceId: pullScope.workspaceId, + version: 5, + }); + expect(stage).toHaveBeenCalledTimes(1); + expect(promote).toHaveBeenCalledTimes(1); + expect(retryCallbacks).toHaveLength(1); + const queuedRetry = retryCallbacks[0]; + + const legacy = await api.json(`/api/projects/${projectId}/collab/pull`, { + method: 'POST', + headers: { + 'x-od-workspace-id': pullScope.workspaceId, + 'x-od-workspace-member-id': pullScope.viewerMemberId, + 'x-od-workspace-role': 'member', + }, + }); + expect(legacy.status).toBe(200); + expect(durableVersion).toBe(5); + expect(retryCallbacks).toHaveLength(0); + + // Even a timer callback that was already dequeued by the event loop must + // see the intent was settled by the durable legacy commit. + await queuedRetry?.(); + } finally { + proactive.dispose(); + } + + expect(adapterPull).toHaveBeenCalledTimes(1); + expect(stage).toHaveBeenCalledTimes(1); + expect(promote).toHaveBeenCalledTimes(1); + expect(durableVersion).toBe(5); + }); + + it('commits an exact-scope stage through a context gap and global Workspace switch', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'od-authorized-transient-route-')); + tempDirs.push(root); + const projectId = 'authorized-transient-route'; + const liveDir = path.join(root, projectId); + const stageDir = path.join(root, `.${projectId}.od-pull-stage-test`); + await mkdir(liveDir); + await writeFile(path.join(liveDir, 'index.html'), 'Old project'); + await mkdir(stageDir); + await writeFile(path.join(stageDir, 'index.html'), 'Staged project'); + + const activeContext = await fixedShareContextProvider(true).current({}); + if (!activeContext) throw new Error('expected active team context'); + let currentContext: WorkspaceCollabContext | null = activeContext; + const workspaceContext = withLastKnownWorkspaceContext({ + current: async () => currentContext, + }); + await workspaceContext.current({}); + const capturedSnapshot = workspaceContext.lastKnownSnapshot!(); + const activeWorkspaceSelection = { + workspaceId: pullScope.workspaceId, + generation: 0, + }; + const getActiveWorkspaceSnapshot = () => + resolveAuthorizedActiveTeamWorkspaceSnapshot( + activeWorkspaceSelection, + workspaceContext.lastKnownSnapshot!(), + ); + + const db = openDatabase(root, { dataDir: root }); + const projectStore: PulledProjectStore = { + get: (id) => getProject(db, id), + has: (id) => getProject(db, id) != null, + register: () => { + throw new Error('authorized route must use the transactional materializer'); + }, + materializeAuthorizedTeamMirror: (input, scope, pullReceipt) => + materializePulledTeamMirror(db, input, scope, pullReceipt), + }; + let pullReceipt: AuthorizedTeamProjectPullReceipt | null = null; + const stage = vi.fn(async () => { + // Reproduce the production failure window while the Vela child owns the + // staged bytes: the same active identity briefly becomes unavailable, + // then recovers before the atomic promotion boundary. + currentContext = null; + await workspaceContext.current({}); + currentContext = activeContext; + await workspaceContext.current({}); + // The operation remains bound to pullScope. A concurrent UI switch is + // control-plane state and must not cancel or retarget these staged bytes. + activeWorkspaceSelection.workspaceId = 'workspace-other'; + const now = Date.now(); + pullReceipt = { + ...authorizedReceipt(projectId, 5), + authorizedAt: new Date(now - 100).toISOString(), + expiresAt: new Date(now + 1_900).toISOString(), + }; + const stat = await lstat(stageDir); + return { + stageDir, + identity: { dev: String(stat.dev), ino: String(stat.ino) }, + receipt: pullReceipt, + cleanup: async () => { + await rm(stageDir, { recursive: true, force: true }); + }, + }; + }); + const notifyFilesChanged = vi.fn(); + + try { + const api = await startSyncServer(workspaceContext, { + projectStore, + resolvePullDir: () => liveDir, + resolveSharedProject: resolvePulledSharedProject, + notifyFilesChanged, + authorizedTeamProjectPull: { + journalDir: path.join(root, '.journals'), + getActiveWorkspaceSnapshot, + stage, + promote: promoteAuthorizedTeamProjectStage, + }, + }); + + await expect(invokeThroughProactivePull( + api.handle, + projectId, + pullScope, + 5, + )).resolves.toEqual({ status: 'pulled', version: 5 }); + + expect(workspaceContext.lastKnownSnapshot!()).toEqual(capturedSnapshot); + expect(await readFile(path.join(liveDir, 'index.html'), 'utf8')) + .toContain('Staged project'); + expect(getProject(db, projectId)?.name).toBe('Staged project'); + expect( + getTeamProjectMaterialization(db, pullScope.workspaceId, projectId), + ).toEqual(pullReceipt); + expect(notifyFilesChanged).toHaveBeenCalledOnce(); + expect(notifyFilesChanged).toHaveBeenCalledWith(projectId); + } finally { + closeDatabase(); + } + }); + + it('logs the project, version, redacted message/cause, and snapshot reason when promotion fails', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'od-authorized-log-')); + tempDirs.push(root); + const liveDir = path.join(root, 'project'); + const stageDir = path.join(root, '.project.od-pull-stage-test'); + await mkdir(stageDir); + await writeFile(path.join(stageDir, 'index.html'), 'Staged project'); + let snapshot = { + workspaceId: pullScope.workspaceId as string | null, + generation: 7, + }; + const stage = vi.fn(async () => ({ + stageDir, + identity: { dev: '1', ino: '2' }, + receipt: authorizedReceipt('authorized-log', 5), + cleanup: vi.fn(async () => undefined), + })); + const promote = vi.fn(async () => { + snapshot = { workspaceId: null, generation: 7 }; + throw new Error( + 'active workspace changed while Bearer abcdefghijklmnopqrstuvwxyz', + { + cause: new Error( + 'journal rename failed for sk-live-abcdefghijklmnopqrstuvwxyz', + ), + }, + ); + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: fakeProjectStore(), + resolvePullDir: () => liveDir, + resolveSharedProject: resolvePulledSharedProject, + authorizedTeamProjectPull: { + journalDir: path.join(root, '.journals'), + getActiveWorkspaceSnapshot: () => snapshot, + stage, + promote, + }, + }); + + await expect(invokeThroughProactivePull( + api.handle, + 'authorized-log', + pullScope, + 5, + )).resolves.toEqual({ status: 'register_failed' }); + + expect(stage).toHaveBeenCalledTimes(1); + expect(promote).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + '[od] failed to promote authorized team project', + { + projectId: 'authorized-log', + version: 5, + reason: 'promotion-failed', + errorName: 'Error', + errorMessage: + 'active workspace changed while Bearer [REDACTED:bearer_token]', + errorCause: + 'journal rename failed for [REDACTED:sk_key]', + }, + ); + } finally { + warn.mockRestore(); + } + }); + + it('never accepts an authorized-stage-shaped HTTP body', async () => { + const stage = vi.fn(); + const adapterPull = vi.fn(async () => ({ version: 5 })); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: fakeProjectStore(), + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProjectOwner: async () => pullScope.ownerMemberId, + resolveSharedProject: resolvePulledSharedProject, + authorizedTeamProjectPull: { + journalDir: '/unused', + getActiveWorkspaceSnapshot: () => ({ + workspaceId: pullScope.workspaceId, + generation: 1, + }), + stage, + promote: vi.fn(), + }, + }, { + adapter: { + publish: vi.fn(async () => ({ version: 5 })), + pull: adapterPull, + syncLatest: vi.fn(async () => ({ version: 5 })), + }, + }); + + const response = await api.json('/api/projects/http-stage-injection/collab/pull', { + method: 'POST', + headers: { + 'x-od-workspace-id': pullScope.workspaceId, + 'x-od-workspace-member-id': pullScope.viewerMemberId, + 'x-od-workspace-role': 'member', + }, + body: { + authorizedStageInvocation: { + kind: 'authorized-proactive-stage', + expectedVersion: 5, + isStillExpected: true, + }, + }, + }); + + expect(response.status).toBe(200); + expect(adapterPull).toHaveBeenCalledTimes(1); + expect(stage).not.toHaveBeenCalled(); + }); + + it('fails a real but already-stale authorized invocation without legacy fallback', async () => { + const adapterPull = vi.fn(async () => ({ version: 5 })); + const writeMaterializedVersion = vi.fn(async () => undefined); + const stage = vi.fn(); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: fakeProjectStore(), + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProject: resolvePulledSharedProject, + writeMaterializedVersion, + authorizedTeamProjectPull: { + journalDir: '/unused', + getActiveWorkspaceSnapshot: () => ({ + workspaceId: pullScope.workspaceId, + generation: 1, + }), + stage, + promote: vi.fn(), + }, + }, { + adapter: { + publish: vi.fn(async () => ({ version: 5 })), + pull: adapterPull, + syncLatest: vi.fn(async () => ({ version: 5 })), + }, + }); + // The helper's proactive intent has completed before it returns, so this + // is a genuine WeakSet-issued invocation whose liveness closure is stale. + const stale = await mintProactivePullTarget( + 'stale-before-route', + pullScope, + 5, + ); + + await expect(api.handle.pullSharedProject( + 'stale-before-route', + pullScope, + stale.authorizationWitness, + 5, + stale.authorizedStageInvocation, + )).resolves.toEqual({ status: 'register_failed' }); + expect(stage).not.toHaveBeenCalled(); + expect(adapterPull).not.toHaveBeenCalled(); + expect(writeMaterializedVersion).not.toHaveBeenCalled(); + }); + + it.each([ + ['scope mismatch', false], + ['spread forgery', true], + ])('fails a nonempty authorized invocation with %s without legacy fallback', async ( + _case, + spreadInvocation, + ) => { + const adapterPull = vi.fn(async () => ({ version: 5 })); + const writeMaterializedVersion = vi.fn(async () => undefined); + const stage = vi.fn(); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: fakeProjectStore(), + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProject: resolvePulledSharedProject, + writeMaterializedVersion, + authorizedTeamProjectPull: { + journalDir: '/unused', + getActiveWorkspaceSnapshot: () => ({ + workspaceId: pullScope.workspaceId, + generation: 1, + }), + stage, + promote: vi.fn(), + }, + }, { + adapter: { + publish: vi.fn(async () => ({ version: 5 })), + pull: adapterPull, + syncLatest: vi.fn(async () => ({ version: 5 })), + }, + }); + const issuedProjectId = spreadInvocation + ? 'spread-forgery' + : 'scope-mismatch-source'; + const routedProjectId = spreadInvocation + ? issuedProjectId + : 'scope-mismatch-target'; + const target = await mintProactivePullTarget( + issuedProjectId, + pullScope, + 5, + ); + const invocation = spreadInvocation + ? { ...target.authorizedStageInvocation! } + : target.authorizedStageInvocation; + + await expect(api.handle.pullSharedProject( + routedProjectId, + pullScope, + target.authorizationWitness, + 5, + invocation, + )).resolves.toEqual({ status: 'register_failed' }); + expect(stage).not.toHaveBeenCalled(); + expect(adapterPull).not.toHaveBeenCalled(); + expect(writeMaterializedVersion).not.toHaveBeenCalled(); + }); + + it.each([ + ['unknown command "pull" for "team-projects"', true], + ['HTTP 403 forbidden', false], + ['HTTP 500 unavailable', false], + ['network timeout', false], + ['authorized pull response is not valid JSON', false], + ])('falls back only for old-CLI capability absence: %s', async ( + message, + shouldFallback, + ) => { + const adapterPull = vi.fn(async () => ({ version: 5 })); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: fakeProjectStore(), + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProject: resolvePulledSharedProject, + authorizedTeamProjectPull: { + journalDir: '/unused', + getActiveWorkspaceSnapshot: () => ({ + workspaceId: pullScope.workspaceId, + generation: 1, + }), + stage: vi.fn(async () => { + throw new Error(message); + }), + promote: vi.fn(), + }, + }, { + adapter: { + publish: vi.fn(async () => ({ version: 5 })), + pull: adapterPull, + syncLatest: vi.fn(async () => ({ version: 5 })), + }, + }); + const outcome = await invokeThroughProactivePull( + api.handle, + `fallback-${shouldFallback}`, + pullScope, + 5, + ); + + expect(adapterPull).toHaveBeenCalledTimes(shouldFallback ? 1 : 0); + expect(outcome).toEqual( + shouldFallback + ? { status: 'pulled', version: 5 } + : { status: 'register_failed' }, + ); + }); + + it('uses the captured expected version as a legacy lower bound after capability fallback', async () => { + const writeMaterializedVersion = vi.fn(async () => undefined); + const adapterPull = vi.fn(async () => null); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: fakeProjectStore(), + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProject: resolvePulledSharedProject, + writeMaterializedVersion, + authorizedTeamProjectPull: { + journalDir: '/unused', + getActiveWorkspaceSnapshot: () => ({ + workspaceId: pullScope.workspaceId, + generation: 1, + }), + stage: vi.fn(async () => { + throw new Error('unknown command "pull" for "team-projects"'); + }), + promote: vi.fn(), + }, + }, { + adapter: { + publish: vi.fn(async () => ({ version: 5 })), + pull: adapterPull, + syncLatest: vi.fn(async () => ({ version: 5 })), + }, + }); + + await expect(invokeThroughProactivePull( + api.handle, + 'legacy-null-version', + pullScope, + 5, + )).resolves.toEqual({ status: 'pulled', version: 5 }); + expect(writeMaterializedVersion).toHaveBeenCalledWith( + 'legacy-null-version', + pullScope, + 5, + ); + await expect(invokeThroughProactivePull( + api.handle, + 'legacy-null-version', + pullScope, + 6, + )).resolves.toEqual({ status: 'pulled', version: 6 }); + expect(writeMaterializedVersion).toHaveBeenLastCalledWith( + 'legacy-null-version', + pullScope, + 6, + ); + }); + + it('prefers a real legacy transport version over the fallback lower bound', async () => { + const writeMaterializedVersion = vi.fn(async () => undefined); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: fakeProjectStore(), + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProject: resolvePulledSharedProject, + writeMaterializedVersion, + authorizedTeamProjectPull: { + journalDir: '/unused', + getActiveWorkspaceSnapshot: () => ({ + workspaceId: pullScope.workspaceId, + generation: 1, + }), + stage: vi.fn(async () => { + throw new Error('unknown flag: --expected-version'); + }), + promote: vi.fn(), + }, + }, { + adapter: { + publish: vi.fn(async () => ({ version: 8 })), + pull: vi.fn(async () => ({ version: 8 })), + syncLatest: vi.fn(async () => ({ version: 8 })), + }, + }); + + await expect(invokeThroughProactivePull( + api.handle, + 'legacy-real-version', + pullScope, + 5, + )).resolves.toEqual({ status: 'pulled', version: 8 }); + expect(writeMaterializedVersion).toHaveBeenCalledWith( + 'legacy-real-version', + pullScope, + 8, + ); + }); + + it('does not advance the fallback lower bound when post-pull authorization fails', async () => { + const writeMaterializedVersion = vi.fn(async () => undefined); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: fakeProjectStore(), + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProject: async () => null, + writeMaterializedVersion, + authorizedTeamProjectPull: { + journalDir: '/unused', + getActiveWorkspaceSnapshot: () => ({ + workspaceId: pullScope.workspaceId, + generation: 1, + }), + stage: vi.fn(async () => { + throw new Error('unknown command "pull" for "team-projects"'); + }), + promote: vi.fn(), + }, + }, { + adapter: { + publish: vi.fn(async () => null), + pull: vi.fn(async () => null), + syncLatest: vi.fn(async () => null), + }, + }); + + await expect(invokeThroughProactivePull( + api.handle, + 'legacy-post-auth-fail', + pullScope, + 5, + )).resolves.toEqual({ status: 'revoked' }); + expect(writeMaterializedVersion).not.toHaveBeenCalled(); + }); + + it('preempts an old authorized stage and commits only the newer receipt', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'od-authorized-preempt-')); + tempDirs.push(root); + const liveDir = path.join(root, 'preempt'); + const stage4 = path.join(root, '.preempt.od-pull-stage-v4'); + await mkdir(liveDir); + await writeFile(path.join(liveDir, 'index.html'), 'Version three'); + await mkdir(stage4); + await writeFile(path.join(stage4, 'index.html'), 'Version four'); + const stage4Stat = await lstat(stage4); + let firstCleaned = false; + const stage = vi.fn(async (input: { + expectedVersion: number; + signal?: AbortSignal; + }) => { + if (input.expectedVersion === 3) { + await new Promise((_resolve, reject) => { + input.signal?.addEventListener('abort', () => { + firstCleaned = true; + reject(new DOMException('aborted', 'AbortError')); + }, { once: true }); + }); + } + return { + stageDir: stage4, + identity: { + dev: String(stage4Stat.dev), + ino: String(stage4Stat.ino), + }, + receipt: authorizedReceipt('preempt', 4), + cleanup: vi.fn(async () => undefined), + }; + }); + const observedLiveVersions: string[] = []; + const promote = vi.fn(( + input: PromoteAuthorizedTeamProjectStageInput<{ + localRecordChanged: boolean; + }>, + ) => + promoteAuthorizedTeamProjectStage({ + ...input, + durability: { + syncDirectory: async () => { + observedLiveVersions.push( + await readFile(path.join(liveDir, 'index.html'), 'utf8'), + ); + }, + }, + })); + const adapterPull = vi.fn(async () => ({ version: 99 })); + const store = fakeProjectStore(); + const materializeAuthorized = vi.spyOn( + store, + 'materializeAuthorizedTeamMirror', + ); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: store, + resolvePullDir: () => liveDir, + resolveSharedProject: resolvePulledSharedProject, + authorizedTeamProjectPull: { + journalDir: path.join(root, '.journals'), + getActiveWorkspaceSnapshot: () => ({ + workspaceId: pullScope.workspaceId, + generation: 1, + }), + stage, + promote, + }, + }, { + adapter: { + publish: vi.fn(async () => ({ version: 99 })), + pull: adapterPull, + syncLatest: vi.fn(async () => ({ version: 99 })), + }, + }); + const proactive = createProactiveContentPull({ + getLocalBinding: () => ({ + workspaceId: pullScope.workspaceId, + visibility: 'team', + }), + getWorkspaceIdentity: async () => ({ + workspaceId: pullScope.workspaceId, + resourceTeamId: pullScope.resourceTeamId, + workspaceMemberId: pullScope.viewerMemberId, + }), + resolveSharedProjectOwner: async () => pullScope.ownerMemberId, + pullSharedProject: (target, version) => + api.handle.pullSharedProject( + target.projectId, + pullScope, + target.authorizationWitness, + version, + target.authorizedStageInvocation, + ), + }); + + try { + const first = proactive.handleContentChanged({ + projectId: 'preempt', + workspaceId: pullScope.workspaceId, + version: 3, + }); + await vi.waitFor(() => expect(stage).toHaveBeenCalledTimes(1)); + expect(await readFile(path.join(liveDir, 'index.html'), 'utf8')) + .toBe('Version three'); + const second = proactive.handleContentChanged({ + projectId: 'preempt', + workspaceId: pullScope.workspaceId, + version: 4, + }); + await Promise.all([first, second]); + } finally { + proactive.dispose(); + } + + expect(firstCleaned).toBe(true); + expect(stage.mock.calls.map(([input]) => input.expectedVersion)).toEqual([ + 3, + 4, + ]); + expect(promote).toHaveBeenCalledTimes(1); + expect(materializeAuthorized).toHaveBeenCalledTimes(1); + expect(materializeAuthorized.mock.calls[0]?.[2].version).toBe(4); + expect(adapterPull).not.toHaveBeenCalled(); + expect(observedLiveVersions.every( + (version) => + version === 'Version three' || + version === 'Version four', + )).toBe(true); + expect(observedLiveVersions).toContain('Version three'); + expect(observedLiveVersions.at(-1)).toBe('Version four'); + expect(await readFile(path.join(liveDir, 'index.html'), 'utf8')) + .toBe('Version four'); + }); + + it('keeps an authorized exact-scope pull alive when the ambient active workspace changes', async () => { + const activeContext = await fixedShareContextProvider(true).current({}); + const current = vi.fn() + .mockResolvedValueOnce(activeContext) + .mockResolvedValue({ + ...activeContext, + workspaceId: 'ws-other', + teamId: 'ws-other', + }); + const adapterPull = vi.fn(async () => ({ version: 5 })); + const store = fakeProjectStore(); + const api = await startSyncServer({ current }, { + verifyWorkspaceScope: async (scope) => + scope.workspaceId === pullScope.workspaceId + && scope.viewerMemberId === pullScope.viewerMemberId, + projectStore: store, + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProject: async (projectId, scope) => ({ + projectId, + ownerMemberId: scope?.ownerMemberId ?? pullScope.ownerMemberId, + sharedAt: '2026-07-25T00:00:00.000Z', + }), + }, { + adapter: { + publish: vi.fn(async () => ({ version: 5 })), + pull: adapterPull, + syncLatest: vi.fn(async () => ({ version: 5 })), + }, + }); + + const outcome = await api.handle.pullSharedProject('handle-scope-drift', pullScope); + expect(outcome).toEqual({ status: 'pulled', version: 5 }); + expect(adapterPull).toHaveBeenCalledTimes(1); + expect(store.has('handle-scope-drift')).toBe(true); + }); + + it('fails closed when the project is unshared while the scoped pull is in flight', async () => { + let releasePull!: () => void; + const pullGate = new Promise((resolve) => { + releasePull = resolve; + }); + let shared = true; + const resolveSharedProject = vi.fn(async (projectId: string) => shared + ? { + projectId, + ownerMemberId: pullScope.ownerMemberId, + sharedAt: '2026-07-25T00:00:00.000Z', + } + : null); + const store = fakeProjectStore(); + const notifyFilesChanged = vi.fn(); + const revoked = vi.fn(); + const adapterPull = vi.fn(async () => { + await pullGate; + return { version: 5 }; + }); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: store, + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProject, + markTeamProjectRevoked: revoked, + notifyFilesChanged, + }, { + adapter: { + publish: vi.fn(async () => ({ version: 5 })), + pull: adapterPull, + syncLatest: vi.fn(async () => ({ version: 5 })), + }, + }); + + const projectId = 'handle-unshared-during-pull'; + const witness = await mintProactivePullWitness(projectId, pullScope, 5); + const outcomePromise = api.handle.pullSharedProject( + projectId, + pullScope, + witness, + 5, + ); + await vi.waitFor(() => expect(adapterPull).toHaveBeenCalledTimes(1)); + shared = false; + releasePull(); + + await expect(outcomePromise).resolves.toEqual({ status: 'revoked' }); + expect(resolveSharedProject).toHaveBeenCalledTimes(1); + expect(store.has(projectId)).toBe(false); + expect(notifyFilesChanged).not.toHaveBeenCalled(); + expect(revoked).toHaveBeenCalledWith('handle-unshared-during-pull', true); + }); + + it('rechecks workspace identity after the post-pull authoritative lookup before materializing', async () => { + let drifted = false; + const verifyWorkspaceScope = vi.fn(async () => !drifted); + let releaseFinalCatalog!: () => void; + const finalCatalogGate = new Promise((resolve) => { + releaseFinalCatalog = resolve; + }); + let catalogCalls = 0; + const resolveSharedProject = vi.fn(async (projectId: string) => { + catalogCalls += 1; + if (catalogCalls === 1) await finalCatalogGate; + return { + projectId, + ownerMemberId: pullScope.ownerMemberId, + sharedAt: '2026-07-25T00:00:00.000Z', + }; + }); + const store = fakeProjectStore(); + const notifyFilesChanged = vi.fn(); + const onPullTiming = vi.fn(); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: store, + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProject, + verifyWorkspaceScope, + notifyFilesChanged, + onPullTiming, + }, { + adapter: { + publish: vi.fn(async () => ({ version: 5 })), + pull: vi.fn(async () => ({ version: 5 })), + syncLatest: vi.fn(async () => ({ version: 5 })), + }, + }); + + const projectId = 'handle-second-catalog-drift'; + const witness = await mintProactivePullWitness(projectId, pullScope, 5); + const outcomePromise = api.handle.pullSharedProject( + projectId, + pullScope, + witness, + 5, + ); + await vi.waitFor(() => expect(resolveSharedProject).toHaveBeenCalledTimes(1)); + drifted = true; + releaseFinalCatalog(); + + await expect(outcomePromise).resolves.toEqual({ status: 'register_failed' }); + expect(store.has(projectId)).toBe(false); + expect(notifyFilesChanged).not.toHaveBeenCalled(); + expect(onPullTiming).toHaveBeenLastCalledWith(expect.objectContaining({ + phase: 'route-completed', + projectId: 'handle-second-catalog-drift', + status: 'register_failed', + })); + }); + + it('reports revoked (and flags the mirror) when the project is no longer team-shared', async () => { + const revoked: Array<{ projectId: string; revoked: boolean }> = []; + const onPullTiming = vi.fn(); + const api = await startSyncServer(fixedShareContextProvider(true), { + resolveSharedProject: async () => null, + markTeamProjectRevoked: (projectId, value) => revoked.push({ projectId, revoked: value }), + onPullTiming, + }); + + const outcome = await api.handle.pullSharedProject('handle-revoked', pullScope); + expect(outcome).toEqual({ status: 'revoked' }); + expect(revoked).toContainEqual({ projectId: 'handle-revoked', revoked: true }); + expect(onPullTiming).toHaveBeenLastCalledWith(expect.objectContaining({ + phase: 'route-completed', + projectId: 'handle-revoked', + status: 'revoked', + })); + }); + + it('coalesces a racing POST /collab/pull and handle pull into one adapter pull', async () => { + let releasePull!: () => void; + const pullGate = new Promise((resolve) => { + releasePull = resolve; + }); + const adapterPull = vi.fn(async () => { + await pullGate; + return { version: 5 }; + }); + const syncLatest = vi.fn(async () => ({ version: 5 })); + const publish = vi.fn(async () => ({ version: 5 })); + const store = fakeProjectStore(); + const writeMaterializedVersion = vi.fn(async () => undefined); + const onPullTiming = vi.fn(); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: store, + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProjectOwner: async () => pullScope.ownerMemberId, + resolveSharedProject: resolvePulledSharedProject, + writeMaterializedVersion, + onPullTiming, + }, { + adapter: { publish, pull: adapterPull, syncLatest }, + }); + + const viaHandle = api.handle.pullSharedProject('race-pull', pullScope); + const viaRoute = api.json('/api/projects/race-pull/collab/pull', { + method: 'POST', + headers: { + 'x-od-workspace-id': pullScope.workspaceId, + 'x-od-workspace-member-id': pullScope.viewerMemberId, + 'x-od-workspace-role': 'member', + }, + }); + // Let the POST reach the route (and the shared in-flight map) before the + // gate opens; the coalescing must hold with both callers waiting. + await new Promise((resolve) => setTimeout(resolve, 50)); + releasePull(); + + const [handleOutcome, routeResponse] = await Promise.all([viaHandle, viaRoute]); + expect(handleOutcome).toEqual({ status: 'pulled', version: 5 }); + expect(routeResponse.status).toBe(200); + expect(routeResponse.body.version).toBe(5); + expect(adapterPull).toHaveBeenCalledTimes(1); + expect(writeMaterializedVersion).toHaveBeenCalledTimes(1); + expect(writeMaterializedVersion).toHaveBeenCalledWith('race-pull', pullScope, 5); + expect(onPullTiming.mock.calls.map(([event]) => event.phase)).toEqual([ + 'route-started', + 'transport-invoke', + 'transport-done', + 'registration-prepared', + 'catalog-revalidated', + 'scope-revalidated', + 'mirror-materialized', + 'version-write-started', + 'persisted', + 'route-completed', + ]); + }); + + it('does not coalesce the same project across different workspace scopes', async () => { + let releasePull!: () => void; + const pullGate = new Promise((resolve) => { + releasePull = resolve; + }); + const adapterPull = vi.fn(async () => { + await pullGate; + return { version: 5 }; + }); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: fakeProjectStore(), + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProject: resolvePulledSharedProject, + }, { + adapter: { + publish: vi.fn(async () => ({ version: 5 })), + pull: adapterPull, + syncLatest: vi.fn(async () => ({ version: 5 })), + }, + }); + const otherWorkspaceScope = { + ...pullScope, + workspaceId: 'ws-other', + }; + + const active = api.handle.pullSharedProject( + 'cross-workspace-pull', + pullScope, + ); + await vi.waitFor(() => expect(adapterPull).toHaveBeenCalledTimes(1)); + const mismatched = api.handle.pullSharedProject( + 'cross-workspace-pull', + otherWorkspaceScope, + ); + + let mismatchedSettled = false; + void mismatched.finally(() => { + mismatchedSettled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(mismatchedSettled).toBe(false); + expect(adapterPull).toHaveBeenCalledTimes(1); + releasePull(); + await expect(mismatched).resolves.toEqual({ + status: 'register_failed', + }); + await expect(active).resolves.toEqual({ status: 'pulled', version: 5 }); + expect(adapterPull).toHaveBeenCalledTimes(1); + }); + + it('allows different project ids to enter transport concurrently', async () => { + let releasePull!: () => void; + const pullGate = new Promise((resolve) => { + releasePull = resolve; + }); + const adapterPull = vi.fn(async () => { + await pullGate; + return { version: 5 }; + }); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: fakeProjectStore(), + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProject: resolvePulledSharedProject, + }, { + adapter: { + publish: vi.fn(async () => ({ version: 5 })), + pull: adapterPull, + syncLatest: vi.fn(async () => ({ version: 5 })), + }, + }); + + const first = api.handle.pullSharedProject('parallel-a', pullScope); + const second = api.handle.pullSharedProject('parallel-b', pullScope); + await vi.waitFor(() => expect(adapterPull).toHaveBeenCalledTimes(2)); + releasePull(); + + await expect(Promise.all([first, second])).resolves.toEqual([ + { status: 'pulled', version: 5 }, + { status: 'pulled', version: 5 }, + ]); + }); + + it('releases the per-project pull lock after a transport deadline rejects', async () => { + const adapterPull = vi.fn() + .mockRejectedValueOnce(new Error('vela resource pull timed out')) + .mockResolvedValueOnce({ version: 5 }); + const store = fakeProjectStore(); + const onPullTiming = vi.fn(); + const api = await startSyncServer(fixedShareContextProvider(true), { + projectStore: store, + resolvePullDir: (projectId) => `/does/not/exist/${projectId}`, + resolveSharedProjectOwner: async () => pullScope.ownerMemberId, + resolveSharedProject: resolvePulledSharedProject, + writeMaterializedVersion: async () => undefined, + onPullTiming, + }, { + adapter: { + publish: vi.fn(async () => ({ version: 5 })), + pull: adapterPull, + syncLatest: vi.fn(async () => ({ version: 5 })), + }, + }); + + await expect( + api.handle.pullSharedProject('deadline-retry', pullScope), + ).rejects.toThrow('timed out'); + expect(onPullTiming.mock.calls.map(([event]) => event)).toEqual([ + expect.objectContaining({ phase: 'route-started' }), + expect.objectContaining({ phase: 'transport-invoke' }), + expect.objectContaining({ phase: 'transport-done', status: 'threw' }), + expect.objectContaining({ phase: 'route-completed', status: 'threw' }), + ]); + onPullTiming.mockClear(); + await expect( + api.handle.pullSharedProject('deadline-retry', pullScope), + ).resolves.toEqual({ status: 'pulled', version: 5 }); + + expect(adapterPull).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/daemon/tests/collab-team-projects-routes.test.ts b/apps/daemon/tests/collab-team-projects-routes.test.ts new file mode 100644 index 00000000000..b25bdc53124 --- /dev/null +++ b/apps/daemon/tests/collab-team-projects-routes.test.ts @@ -0,0 +1,219 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import express from 'express'; +import http from 'node:http'; +import { + buildWorkspacePermissions, + buildWorkspaceSeatSummary, + type TeamProject, + type WorkspaceCollabContext, +} from '@open-design/contracts'; +import { createTeamProjectsLister } from '../src/collab/team-projects.js'; +import type { WorkspaceContextProvider } from '../src/collab/workspace-context.js'; +import { registerCollabContextRoutes } from '../src/routes/collab-context.js'; + +const PROJECTS: TeamProject[] = [ + { + projectId: 'p1', + ownerMemberId: 'wm-owner', + sharedAt: '2026-07-01T00:00:00.000Z', + name: 'Launch Deck', + }, +]; + +function teamContextProvider(): WorkspaceContextProvider { + const context: WorkspaceCollabContext = { + workspaceId: 'ws-1', + workspaceType: 'team', + workspaceMemberId: 'wm-1', + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + billingState: 'active', + planId: null, + providerMode: 'platform_credits', + seatSummary: buildWorkspaceSeatSummary({ seatLimit: 5, usedSeats: 1 }), + permissions: buildWorkspacePermissions({ + role: 'member', + lifecycleState: 'active', + }), + teamId: 't1', + }; + return { current: async () => context }; +} + +function personalContextProvider(): WorkspaceContextProvider { + return { current: async () => null }; +} + +let server: http.Server | null = null; + +afterEach(async () => { + if (!server) return; + const toClose = server; + server = null; + await new Promise((resolve) => toClose.close(() => resolve())); +}); + +async function startServer(deps: { + workspaceContext: WorkspaceContextProvider; + listTeamProjects: (context: WorkspaceCollabContext) => Promise; + fetchWorkspaceDirectory?: () => Promise<{ + ok: boolean; + items: Array<{ + workspaceId: string; + workspaceName: string; + workspaceType: 'personal' | 'team'; + workspaceMemberId: string; + role: 'owner' | 'admin' | 'member'; + memberStatus: 'active' | 'removed'; + lifecycleState: 'active' | 'billing_past_due' | 'locked' | 'deleting' | 'deleted'; + }>; + }>; +}) { + const app = express(); + app.use(express.json()); + registerCollabContextRoutes(app, deps); + server = http.createServer(app); + await new Promise((resolve) => server!.listen(0, resolve)); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('server did not bind to a TCP port'); + } + return async (headers?: Record) => { + const response = await fetch( + `http://127.0.0.1:${address.port}/api/workspace/projects/team`, + headers ? { headers } : undefined, + ); + return { + status: response.status, + body: (await response.json()) as Record, + }; + }; +} + +describe('GET /api/workspace/projects/team', () => { + it('uses the request workspace rather than the daemon ambient workspace', async () => { + const seen: unknown[] = []; + const listTeamProjects = async (scope: WorkspaceCollabContext) => { + seen.push(scope); + return PROJECTS; + }; + const get = await startServer({ + workspaceContext: teamContextProvider(), + listTeamProjects, + fetchWorkspaceDirectory: async () => ({ + ok: true, + items: [ + { + workspaceId: 'team-a', + workspaceName: 'Team A', + workspaceType: 'team', + workspaceMemberId: 'member-a', + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + }, + ], + }), + }); + + const response = await get({ + 'x-od-workspace-id': 'team-a', + 'x-od-workspace-member-id': 'member-a', + 'x-od-workspace-type': 'team', + }); + + expect(response.status).toBe(200); + expect(seen).toEqual([ + expect.objectContaining({ + workspaceId: 'team-a', + workspaceMemberId: 'member-a', + }), + ]); + }); + + it('lists projects through the injected Vela team-project catalog', async () => { + const workspaceContext = teamContextProvider(); + const calls: string[] = []; + const listTeamProjects = createTeamProjectsLister({ + env: { OD_WORKSPACE_CONTEXT_SOURCE: 'vela' }, + teamProjectCatalog: { + list: async (workspaceId) => { + calls.push(workspaceId ?? ''); + return PROJECTS; + }, + get: async () => null, + upsert: async () => {}, + remove: async () => {}, + }, + }); + const get = await startServer({ + workspaceContext, + listTeamProjects: (context) => listTeamProjects(context.workspaceId), + fetchWorkspaceDirectory: async () => ({ + ok: true, + items: [ + { + workspaceId: 'ws-1', + workspaceName: 'Workspace 1', + workspaceType: 'team', + workspaceMemberId: 'wm-1', + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + }, + ], + }), + }); + + const response = await get({ + 'x-od-workspace-id': 'ws-1', + 'x-od-workspace-member-id': 'wm-1', + 'x-od-workspace-type': 'team', + }); + expect(response.status).toBe(200); + expect(calls).toEqual(['ws-1']); + expect(response.body).toEqual({ projects: PROJECTS }); + }); + + it('returns an empty list off-team without invoking Vela', async () => { + const workspaceContext = personalContextProvider(); + const listTeamProjects = createTeamProjectsLister({ + env: { OD_WORKSPACE_CONTEXT_SOURCE: 'vela' }, + teamProjectCatalog: { + list: async () => { + throw new Error('catalog should not be read off-team'); + }, + get: async () => null, + upsert: async () => {}, + remove: async () => {}, + }, + }); + const get = await startServer({ + workspaceContext, + listTeamProjects: (context) => listTeamProjects(context.workspaceId), + fetchWorkspaceDirectory: async () => ({ + ok: true, + items: [ + { + workspaceId: 'personal-1', + workspaceName: 'Personal', + workspaceType: 'personal', + workspaceMemberId: 'wm-personal', + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + }, + ], + }), + }); + + const response = await get({ + 'x-od-workspace-id': 'personal-1', + 'x-od-workspace-member-id': 'wm-personal', + 'x-od-workspace-type': 'personal', + }); + expect(response.status).toBe(403); + expect(response.body).toMatchObject({ error: 'WORKSPACE_ACCESS_DENIED' }); + }); +}); diff --git a/apps/daemon/tests/collab-workspace-events-route.test.ts b/apps/daemon/tests/collab-workspace-events-route.test.ts new file mode 100644 index 00000000000..7b89dd92767 --- /dev/null +++ b/apps/daemon/tests/collab-workspace-events-route.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import express from 'express'; +import http from 'node:http'; +import type { Response } from 'express'; +import { + emitWorkspaceEventToScope, + registerCollabContextRoutes, + type WorkspaceEventSinksByWorkspace, +} from '../src/routes/collab-context.js'; +import { createDevWorkspaceContextProvider } from '../src/collab/workspace-context.js'; + +// A minimal `createSseResponse` matching the daemon contract the route relies on +// (text/event-stream headers + `send(event, data)` writing one SSE frame). Keeps +// this route test independent of the full server bootstrap. +function makeSseResponse(res: Response) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.flushHeaders?.(); + return { + send(event: string, data: unknown): boolean { + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + return true; + }, + }; +} + +let server: http.Server | null = null; + +afterEach(async () => { + if (server) { + const toClose = server; + server = null; + await new Promise((resolve) => toClose.close(() => resolve())); + } +}); + +async function startServer(workspaceEventSinks: WorkspaceEventSinksByWorkspace) { + const app = express(); + app.use(express.json()); + registerCollabContextRoutes(app, { + workspaceContext: createDevWorkspaceContextProvider(), + fetchWorkspaceDirectory: async () => ({ + ok: true, + items: [{ + workspaceId: 'workspace-a', + workspaceName: 'Workspace A', + workspaceType: 'team', + workspaceMemberId: 'member-a', + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + }], + }), + createSseResponse: (res) => makeSseResponse(res as Response), + workspaceEventSinks, + }); + server = http.createServer(app); + await new Promise((resolve) => server!.listen(0, resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('no TCP port'); + return `http://127.0.0.1:${address.port}`; +} + +/** Read from an SSE body reader until `predicate(accumulated)` is true. */ +async function readUntil( + reader: ReadableStreamDefaultReader, + predicate: (text: string) => boolean, + timeoutMs = 2000, +): Promise { + const decoder = new TextDecoder(); + let buffer = ''; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate(buffer)) return buffer; + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + } + if (!predicate(buffer)) throw new Error(`timed out; buffer so far:\n${buffer}`); + return buffer; +} + +describe('GET /api/workspace/events', () => { + it('registers a sink, streams a pushed thin event, and drops the sink on disconnect', async () => { + const sinks: WorkspaceEventSinksByWorkspace = new Map(); + const base = await startServer(sinks); + const controller = new AbortController(); + + const resp = await fetch( + `${base}/api/workspace/events` + + '?workspaceId=workspace-a&workspaceMemberId=member-a', + { signal: controller.signal }, + ); + expect(resp.status).toBe(200); + expect(resp.headers.get('content-type')).toContain('text/event-stream'); + const reader = resp.body!.getReader(); + + // The route sends a `ready` handshake and registers exactly one sink. + await readUntil(reader, (text) => text.includes('event: ready')); + expect(sinks.size).toBe(1); + + // Emitting into the sinks streams the thin event with its `type` as the SSE + // event name (the client re-fetches on receipt; no body is required). + emitWorkspaceEventToScope( + sinks, + 'workspace-a', + { type: 'team-projects-changed', at: 123 }, + ); + const framed = await readUntil(reader, (text) => text.includes('event: team-projects-changed')); + expect(framed).toContain('"type":"team-projects-changed"'); + + // Disconnect → the route's res.on('close') cleanup drops the sink. + controller.abort(); + await reader.cancel().catch(() => {}); + const deadline = Date.now() + 2000; + while (sinks.size !== 0 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 25)); + } + expect(sinks.size).toBe(0); + }); + + it('404-free no-op: the route is simply absent when the SSE seams are omitted', async () => { + // Without createSseResponse + workspaceEventSinks the route is never + // registered, so a request 404s instead of throwing. + const app = express(); + app.use(express.json()); + registerCollabContextRoutes(app, { workspaceContext: createDevWorkspaceContextProvider() }); + server = http.createServer(app); + await new Promise((resolve) => server!.listen(0, resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('no TCP port'); + const resp = await fetch(`http://127.0.0.1:${address.port}/api/workspace/events`); + expect(resp.status).toBe(404); + }); +}); diff --git a/apps/daemon/tests/collab-workspace-invalidation-poller.test.ts b/apps/daemon/tests/collab-workspace-invalidation-poller.test.ts new file mode 100644 index 00000000000..71f94ce4d1b --- /dev/null +++ b/apps/daemon/tests/collab-workspace-invalidation-poller.test.ts @@ -0,0 +1,452 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { + CollabCloudMemberDirectoryEntry, + TeamProject, + WorkspaceCollabContext, + WorkspaceInvalidationSsePayload, +} from '@open-design/contracts'; +import { createProactiveContentPull } from '../src/collab/proactive-content-pull.js'; +import { createWorkspaceInvalidationPoller } from '../src/collab/workspace-invalidation-poller.js'; + +// Minimal team context — `isTeamContext` only reads `workspaceType`/`teamId`, +// and `contextSignature` stringifies the whole object, so a partial cast is a +// faithful stand-in for the diff logic under test. +function teamContext(overrides: Partial = {}): WorkspaceCollabContext { + return { + workspaceId: 'ws-1', + workspaceType: 'team', + teamId: 'team-1', + workspaceMemberId: 'wm-1', + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + ...overrides, + } as WorkspaceCollabContext; +} + +function personalContext(): WorkspaceCollabContext { + return { workspaceId: 'ws-1', workspaceType: 'personal', workspaceMemberId: 'wm-1', role: 'member' } as WorkspaceCollabContext; +} + +function project(id: string, extra: Partial = {}): TeamProject { + return { projectId: id, ownerMemberId: 'wm-1', sharedAt: '2026-01-01T00:00:00Z', ...extra }; +} + +function member(id: string, extra: Partial = {}): CollabCloudMemberDirectoryEntry { + return { memberId: id, displayName: id, role: 'member', ...extra } as CollabCloudMemberDirectoryEntry; +} + +interface Harness { + emitted: WorkspaceInvalidationSsePayload[]; + observed: Array<{ workspaceId: string; projectIds: string[] }>; + types: () => string[]; + context: WorkspaceCollabContext | null; + projects: TeamProject[] | null; // null simulates a transient read failure + members: CollabCloudMemberDirectoryEntry[] | null; + contextCalls: number; + teamListCalls: number; + memberListCalls: number; + listedWorkspaceIds: string[]; + emittedWorkspaceIds: Array; + errors: unknown[]; + poller: ReturnType; +} + +function harness(initial: { + context?: WorkspaceCollabContext | null; + projects?: TeamProject[] | null; + members?: CollabCloudMemberDirectoryEntry[] | null; + pollIntervalMs?: number; + recoveryFloorIntervalMs?: number; + now?: () => number; + onTeamProjectsObserved?: (input: { + workspaceId: string; + projects: readonly TeamProject[]; + }) => void | Promise; +}): Harness { + const h: Harness = { + emitted: [], + observed: [], + types: () => h.emitted.map((e) => e.type), + context: initial.context === undefined ? teamContext() : initial.context, + projects: initial.projects === undefined ? [] : initial.projects, + members: initial.members === undefined ? [] : initial.members, + contextCalls: 0, + teamListCalls: 0, + memberListCalls: 0, + listedWorkspaceIds: [], + emittedWorkspaceIds: [], + errors: [], + poller: null as unknown as ReturnType, + }; + h.poller = createWorkspaceInvalidationPoller({ + getWorkspaceContext: async () => { + h.contextCalls += 1; + return h.context; + }, + listTeamProjects: async (context) => { + h.teamListCalls += 1; + h.listedWorkspaceIds.push(context.workspaceId); + if (h.projects === null) throw new Error('team-projects read failed'); + return h.projects; + }, + listMembers: async () => { + h.memberListCalls += 1; + if (h.members === null) throw new Error('members read failed'); + return h.members; + }, + emit: (payload, context) => { + h.emitted.push(payload); + h.emittedWorkspaceIds.push(context?.workspaceId ?? null); + }, + onError: (error) => h.errors.push(error), + ...(initial.pollIntervalMs != null + ? { pollIntervalMs: initial.pollIntervalMs } + : {}), + ...(initial.recoveryFloorIntervalMs != null + ? { recoveryFloorIntervalMs: initial.recoveryFloorIntervalMs } + : {}), + ...(initial.now ? { now: initial.now } : {}), + onTeamProjectsObserved: + initial.onTeamProjectsObserved ?? + ((input) => { + h.observed.push({ + workspaceId: input.workspaceId, + projectIds: input.projects.map((candidate) => candidate.projectId), + }); + }), + }); + return h; +} + +describe('workspace invalidation poller', () => { + it('establishes a baseline on the first cycle without emitting', async () => { + const h = harness({ projects: [project('p1')], members: [member('m1')] }); + await h.poller.pollOnce(); + expect(h.emitted).toEqual([]); + }); + + it('emits only team-projects-changed when the shared project list changes', async () => { + const h = harness({ projects: [project('p1')] }); + await h.poller.pollOnce(); // baseline + h.projects = [project('p1'), project('p2')]; + await h.poller.pollOnce(); + expect(h.types()).toEqual(['team-projects-changed']); + }); + + it('carries one captured context through catalog read and emission when ambient selection changes', async () => { + const contextA = teamContext({ workspaceId: 'team-a', teamId: 'team-a' }); + const contextB = teamContext({ workspaceId: 'team-b', teamId: 'team-b' }); + const h = harness({ context: contextA, projects: [project('a')] }); + await h.poller.pollOnce(); + + h.context = contextA; + h.projects = [project('a'), project('a2')]; + let catalogReads = 0; + let memberReads = 0; + const poller = createWorkspaceInvalidationPoller({ + getWorkspaceContext: async () => contextA, + listTeamProjects: async (captured) => { + h.context = contextB; + expect(captured.workspaceId).toBe('team-a'); + catalogReads += 1; + return catalogReads === 1 + ? [project('a')] + : [project('a'), project('a2')]; + }, + listMembers: async (captured) => { + expect(captured.workspaceId).toBe('team-a'); + memberReads += 1; + return memberReads === 1 ? [] : [member('member-a')]; + }, + emit: (payload, captured) => { + h.emitted.push(payload); + h.emittedWorkspaceIds.push(captured?.workspaceId ?? null); + }, + }); + await poller.pollOnce(); + await poller.pollOnce(); + + expect(h.context?.workspaceId).toBe('team-b'); + expect(h.emittedWorkspaceIds.slice(-2)).toEqual(['team-a', 'team-a']); + }); + + it('does not emit when the team list is reordered but unchanged', async () => { + const h = harness({ projects: [project('a'), project('b')] }); + await h.poller.pollOnce(); + h.projects = [project('b'), project('a')]; + await h.poller.pollOnce(); + expect(h.emitted).toEqual([]); + }); + + it('emits only members-changed when the roster changes', async () => { + const h = harness({ members: [member('m1')] }); + await h.poller.pollOnce(); + h.members = [member('m1'), member('m2')]; + await h.poller.pollOnce(); + expect(h.types()).toEqual(['members-changed']); + }); + + it('emits workspace-context-changed when the context changes', async () => { + const h = harness({ context: teamContext({ role: 'member' }) }); + await h.poller.pollOnce(); + h.context = teamContext({ role: 'admin' }); + await h.poller.pollOnce(); + expect(h.types()).toEqual(['workspace-context-changed']); + }); + + it('never reads team projects/members while off-team', async () => { + const h = harness({ context: personalContext(), projects: [project('p1')] }); + await h.poller.pollOnce(); + await h.poller.pollOnce(); + expect(h.teamListCalls).toBe(0); + }); + + it('folds team projects/members to empty when leaving a team', async () => { + const h = harness({ context: teamContext(), projects: [project('p1')], members: [member('m1')] }); + await h.poller.pollOnce(); // baseline: team with 1 project + 1 member + h.context = personalContext(); + await h.poller.pollOnce(); + // The team list + roster clear, and the context itself changed. + expect(h.types().sort()).toEqual( + ['members-changed', 'team-projects-changed', 'workspace-context-changed'].sort(), + ); + }); + + it('keeps the last baseline on a transient read failure (no spurious emit)', async () => { + const h = harness({ projects: [project('p1')] }); + await h.poller.pollOnce(); // baseline + h.projects = null; // read fails this cycle + await h.poller.pollOnce(); + expect(h.types()).toEqual([]); + // Recovery with the SAME list must not emit either — baseline was preserved. + h.projects = [project('p1')]; + await h.poller.pollOnce(); + expect(h.types()).toEqual([]); + }); + + it('runs the missing-project recovery floor immediately, then every 30s despite a stable catalog', async () => { + let now = 0; + const h = harness({ + projects: [project('p1')], + recoveryFloorIntervalMs: 30_000, + now: () => now, + }); + + await h.poller.pollOnce(); + await h.poller.pollOnce(); + now = 29_999; + await h.poller.pollOnce(); + now = 30_000; + await h.poller.pollOnce(); + + expect(h.emitted).toEqual([]); + expect(h.observed).toEqual([ + { workspaceId: 'ws-1', projectIds: ['p1'] }, + { workspaceId: 'ws-1', projectIds: ['p1'] }, + ]); + }); + + it('uses stable recovery ticks to continue bounded full-head rotation after reconnect', async () => { + let now = 0; + const sharedProjects = Array.from({ length: 10 }, (_, index) => ({ + projectId: `p${index}`, + ownerMemberId: 'wm-owner', + })); + const headCalls: string[] = []; + const proactivePull = createProactiveContentPull({ + getLocalBinding: () => ({ workspaceId: 'ws-1', visibility: 'team' }), + getWorkspaceIdentity: async () => ({ + workspaceId: 'ws-1', + resourceTeamId: 'team-1', + workspaceMemberId: 'wm-1', + }), + resolveSharedProjectOwner: async () => 'wm-owner', + listSharedProjects: async () => sharedProjects, + hasMaterializedProject: () => true, + publishedHead: async (target) => { + headCalls.push(target.projectId); + return null; + }, + pullSharedProject: async () => ({ status: 'pulled', version: null }), + }); + const h = harness({ + projects: sharedProjects.map((candidate) => + project(candidate.projectId, { + ownerMemberId: candidate.ownerMemberId, + })), + recoveryFloorIntervalMs: 30_000, + now: () => now, + onTeamProjectsObserved: ({ workspaceId }) => + proactivePull.advanceRecoveryFloor(workspaceId), + }); + + // Hub connect/reconnect starts one bounded full batch. + await proactivePull.catchUpPublishedHeads('ws-1'); + expect(headCalls).toEqual(['p0', 'p1', 'p2', 'p3']); + + // The existing poller cadence, rather than a new timer, advances the same + // full cursor through stale existing projects. + await h.poller.pollOnce(); + await vi.waitFor(() => expect(headCalls).toHaveLength(8)); + expect(headCalls.slice(4)).toEqual(['p4', 'p5', 'p6', 'p7']); + + now = 30_000; + await h.poller.pollOnce(); + await vi.waitFor(() => expect(headCalls).toHaveLength(12)); + expect(headCalls.slice(8, 10)).toEqual(['p8', 'p9']); + expect(new Set(headCalls.slice(0, 10))).toEqual( + new Set(sharedProjects.map((candidate) => candidate.projectId)), + ); + proactivePull.dispose(); + }); + + it('materializes an absent local project through the bounded full recovery floor', async () => { + const pullCalls: string[] = []; + const proactivePull = createProactiveContentPull({ + getLocalBinding: () => null, + getWorkspaceIdentity: async () => ({ + workspaceId: 'ws-1', + resourceTeamId: 'team-1', + workspaceMemberId: 'wm-1', + }), + resolveSharedProjectOwner: async () => 'wm-owner', + listSharedProjects: async () => [ + { projectId: 'missing-project', ownerMemberId: 'wm-owner' }, + ], + hasMaterializedProject: () => false, + publishedHead: async () => 1, + pullSharedProject: async (target) => { + pullCalls.push(target.projectId); + return { status: 'pulled', version: 1 }; + }, + }); + const h = harness({ + projects: [ + project('missing-project', { ownerMemberId: 'wm-owner' }), + ], + onTeamProjectsObserved: ({ workspaceId }) => + proactivePull.advanceRecoveryFloor(workspaceId), + }); + + await h.poller.pollOnce(); + await vi.waitFor(() => expect(pullCalls).toEqual(['missing-project'])); + proactivePull.dispose(); + }); + + it('does not run the recovery floor off-team or after a failed catalog read', async () => { + const personal = harness({ + context: personalContext(), + projects: [project('p1')], + }); + await personal.poller.pollOnce(); + expect(personal.observed).toEqual([]); + + const failed = harness({ + context: teamContext(), + projects: null, + }); + await failed.poller.pollOnce(); + expect(failed.observed).toEqual([]); + }); + + it.each([ + ['personal context with stale team id', { workspaceType: 'personal' }], + ['removed member', { memberStatus: 'removed' }], + ['past-due workspace', { lifecycleState: 'billing_past_due' }], + ['locked workspace', { lifecycleState: 'locked' }], + ['deleting workspace', { lifecycleState: 'deleting' }], + ['deleted workspace', { lifecycleState: 'deleted' }], + ['missing workspace id', { workspaceId: ' ' }], + ['missing resource team id', { teamId: ' ' }], + ['missing workspace member id', { workspaceMemberId: ' ' }], + ] satisfies Array<[string, Partial]>)( + 'does not run broad recovery for %s', + async (_label, overrides) => { + const h = harness({ + context: teamContext(overrides), + projects: [project('p1')], + }); + + await h.poller.pollOnce(); + + expect(h.observed).toEqual([]); + }, + ); + + it('schedules immediately after a workspace switch inside the same throttle window', async () => { + let now = 0; + const h = harness({ + context: teamContext({ workspaceId: 'ws-1' }), + projects: [project('p1')], + recoveryFloorIntervalMs: 30_000, + now: () => now, + }); + await h.poller.pollOnce(); + + now = 1_000; + h.context = teamContext({ workspaceId: 'ws-2' }); + h.projects = [project('p2')]; + await h.poller.pollOnce(); + + expect(h.observed).toEqual([ + { workspaceId: 'ws-1', projectIds: ['p1'] }, + { workspaceId: 'ws-2', projectIds: ['p2'] }, + ]); + }); + + it('does not let a hanging recovery block polls, duplicate scheduling, or leak timers', async () => { + vi.useFakeTimers(); + let releaseObservation!: () => void; + const observationGate = new Promise((resolve) => { + releaseObservation = resolve; + }); + const onTeamProjectsObserved = vi.fn(async () => observationGate); + const h = harness({ + projects: [project('p1')], + pollIntervalMs: 100, + recoveryFloorIntervalMs: 30_000, + onTeamProjectsObserved, + }); + + try { + h.poller.start(); + h.poller.start(); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(100); + expect(onTeamProjectsObserved).toHaveBeenCalledTimes(1); + expect(h.contextCalls).toBe(1); + expect(h.teamListCalls).toBe(1); + expect(h.memberListCalls).toBe(1); + + await vi.advanceTimersByTimeAsync(300); + expect(onTeamProjectsObserved).toHaveBeenCalledTimes(1); + expect(h.contextCalls).toBe(4); + expect(h.teamListCalls).toBe(4); + expect(h.memberListCalls).toBe(4); + + h.poller.stop(); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(300); + expect(onTeamProjectsObserved).toHaveBeenCalledTimes(1); + expect(h.contextCalls).toBe(4); + } finally { + h.poller.stop(); + releaseObservation(); + vi.useRealTimers(); + } + }); + + it('reports an asynchronous recovery rejection without rejecting the poll', async () => { + const failure = new Error('recovery failed'); + const h = harness({ + projects: [project('p1')], + onTeamProjectsObserved: async () => { + throw failure; + }, + }); + + await expect(h.poller.pollOnce()).resolves.toBeUndefined(); + await vi.waitFor(() => expect(h.errors).toContain(failure)); + }); +}); diff --git a/apps/daemon/tests/collab/active-workspace-selection.test.ts b/apps/daemon/tests/collab/active-workspace-selection.test.ts new file mode 100644 index 00000000000..69d670a91b8 --- /dev/null +++ b/apps/daemon/tests/collab/active-workspace-selection.test.ts @@ -0,0 +1,216 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + createActiveWorkspaceSelectionStore, + resolveAuthorizedActiveTeamWorkspaceSnapshot, +} from '../../src/collab/active-workspace-selection.js'; +import { + createDevWorkspaceContextProvider, + withLastKnownWorkspaceContext, +} from '../../src/collab/workspace-context.js'; +import type { WorkspaceCollabContext } from '@open-design/contracts'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe('observed active team workspace snapshot', () => { + const activeIdentity = { + workspaceId: 'workspace-1', + teamId: 'team-1', + workspaceMemberId: 'member-1', + workspaceType: 'team', + memberStatus: 'active', + lifecycleState: 'active', + role: 'member', + billingState: 'active', + planId: null, + providerMode: 'platform_credits', + seatSummary: { seatLimit: 5, usedSeats: 2, availableSeats: 3 }, + permissions: { + canManageMembers: false, + canManageBilling: false, + canShareProjects: true, + canWriteSyncedFiles: true, + }, + } as WorkspaceCollabContext; + + it('uses the freshly verified team identity when no explicit pin exists', () => { + expect(resolveAuthorizedActiveTeamWorkspaceSnapshot( + { workspaceId: null, generation: 0 }, + { context: activeIdentity, generation: 1 }, + )).toEqual({ workspaceId: 'workspace-1', generation: 1 }); + }); + + it('fails closed when an explicit pin disagrees with verified identity', () => { + expect(resolveAuthorizedActiveTeamWorkspaceSnapshot( + { workspaceId: 'workspace-pinned', generation: 4 }, + { context: activeIdentity, generation: 2 }, + )).toEqual({ workspaceId: null, generation: 6 }); + }); + + it('records A to B to A even when no authorization snapshot was read at B', async () => { + const provider = withLastKnownWorkspaceContext( + createDevWorkspaceContextProvider(activeIdentity), + ); + await provider.current({}); + const captured = provider.lastKnownSnapshot!(); + + provider.set!({ ...activeIdentity, workspaceId: 'workspace-2' }); + provider.set!(activeIdentity); + + expect(provider.lastKnownSnapshot!()).toEqual({ + context: activeIdentity, + generation: captured.generation + 2, + }); + }); + + it('keeps the identity generation stable across a transient null that recovers to A', async () => { + let current: WorkspaceCollabContext | null = activeIdentity; + const provider = withLastKnownWorkspaceContext({ + current: async () => current, + }); + await provider.current({}); + const captured = provider.lastKnownSnapshot!(); + + current = null; + await provider.current({}); + expect(provider.lastKnown!()).toBeNull(); + expect(provider.lastKnownSnapshot!()).toEqual({ + context: null, + generation: captured.generation, + }); + + current = activeIdentity; + await provider.current({}); + expect(provider.lastKnownSnapshot!()).toEqual(captured); + }); + + it('keeps a persistent null unavailable so promotion still fails closed', async () => { + let current: WorkspaceCollabContext | null = activeIdentity; + const provider = withLastKnownWorkspaceContext({ + current: async () => current, + }); + await provider.current({}); + const captured = provider.lastKnownSnapshot!(); + + current = null; + await provider.current({}); + + expect(resolveAuthorizedActiveTeamWorkspaceSnapshot( + { workspaceId: 'workspace-1', generation: 0 }, + provider.lastKnownSnapshot!(), + )).toEqual({ + workspaceId: null, + generation: captured.generation, + }); + }); + + it('increments identity generation when authoritative current changes from A to B', async () => { + let current: WorkspaceCollabContext | null = activeIdentity; + const provider = withLastKnownWorkspaceContext({ + current: async () => current, + }); + await provider.current({}); + const captured = provider.lastKnownSnapshot!(); + + current = { ...activeIdentity, workspaceId: 'workspace-2' }; + await provider.current({}); + + expect(provider.lastKnownSnapshot!()).toMatchObject({ + context: { workspaceId: 'workspace-2' }, + generation: captured.generation + 1, + }); + }); + + it('increments identity generation for member and lifecycle drift', async () => { + let current: WorkspaceCollabContext | null = activeIdentity; + const provider = withLastKnownWorkspaceContext({ + current: async () => current, + }); + await provider.current({}); + const captured = provider.lastKnownSnapshot!(); + current = { ...activeIdentity, memberStatus: 'removed' }; + await provider.current({}); + current = { + ...activeIdentity, + lifecycleState: 'locked', + }; + await provider.current({}); + + expect(provider.lastKnownSnapshot!()).toMatchObject({ + generation: captured.generation + 2, + }); + }); + + it('increments identity generation when a dev provider explicitly clears context', async () => { + const provider = withLastKnownWorkspaceContext( + createDevWorkspaceContextProvider(activeIdentity), + ); + await provider.current({}); + const captured = provider.lastKnownSnapshot!(); + + provider.set!(null); + + expect(provider.lastKnownSnapshot!()).toEqual({ + context: null, + generation: captured.generation + 1, + }); + }); +}); + +describe('active workspace selection generation', () => { + it('notifies subscribers after persisted selection changes', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'od-workspace-selection-')); + roots.push(root); + const store = createActiveWorkspaceSelectionStore(root); + const selections: Array = []; + const unsubscribe = store.subscribe((workspaceId) => { + selections.push(workspaceId); + }); + + await store.set('workspace-1'); + await store.set('workspace-2'); + await store.clear(); + unsubscribe(); + await store.set('workspace-3'); + + expect(selections).toEqual(['workspace-1', 'workspace-2', null]); + }); + + it('detects away-and-back changes even when the final workspace id matches', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'od-workspace-selection-')); + roots.push(root); + const store = createActiveWorkspaceSelectionStore(root); + + await store.set('workspace-1'); + const captured = store.snapshot(); + await store.set('workspace-2'); + await store.set('workspace-1'); + + expect(store.snapshot()).toEqual({ + workspaceId: 'workspace-1', + generation: captured.generation + 2, + }); + }); + + it('increments generation when the selection is cleared', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'od-workspace-selection-')); + roots.push(root); + const store = createActiveWorkspaceSelectionStore(root); + await store.set('workspace-1'); + const captured = store.snapshot(); + + await store.clear(); + + expect(store.snapshot()).toEqual({ + workspaceId: null, + generation: captured.generation + 1, + }); + }); +}); diff --git a/apps/daemon/tests/collab/authorized-team-project-pull.test.ts b/apps/daemon/tests/collab/authorized-team-project-pull.test.ts new file mode 100644 index 00000000000..f631956c972 --- /dev/null +++ b/apps/daemon/tests/collab/authorized-team-project-pull.test.ts @@ -0,0 +1,347 @@ +import { + mkdtemp, + mkdir, + readFile, + readdir, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + isAuthorizedTeamProjectPullReceiptExpired, + isAuthorizedTeamProjectPullUnavailable, + stageAuthorizedTeamProjectPull, + validateAuthorizedTeamProjectPullReceipt, + type AuthorizedTeamProjectPullReceipt, +} from '../../src/collab/authorized-team-project-pull.js'; +import { projectResourceIdFor } from '../../src/integrations/vela-team-projects.js'; + +const roots: string[] = []; +const NOW = Date.parse('2026-07-26T10:00:00.500Z'); +const SCOPE = { + workspaceId: 'workspace-1', + resourceTeamId: 'workspace-1', + viewerMemberId: 'viewer-1', + ownerMemberId: 'owner-1', +} as const; +const RESOURCE_ID = projectResourceIdFor('project-1', { + teamId: SCOPE.resourceTeamId, + memberId: SCOPE.ownerMemberId, + role: 'member', + lifecycleState: 'active', + workspaceType: 'team', +}); + +function receipt( + overrides: Partial = {}, +): AuthorizedTeamProjectPullReceipt { + return { + schemaVersion: 1, + workspaceId: SCOPE.workspaceId, + resourceTeamId: SCOPE.resourceTeamId, + viewerMemberId: SCOPE.viewerMemberId, + ownerMemberId: SCOPE.ownerMemberId, + projectId: 'project-1', + resourceId: RESOURCE_ID, + ref: 'published', + version: 7, + versionId: 'version-7', + manifestDigest: `sha256:${'a'.repeat(64)}`, + lifecycleState: 'active', + authorizedAt: '2026-07-26T10:00:00.000Z', + expiresAt: '2026-07-26T10:00:02.000Z', + ...overrides, + }; +} + +async function fixture() { + const root = await mkdtemp(path.join(os.tmpdir(), 'od-authorized-pull-')); + roots.push(root); + const liveDir = path.join(root, 'project-1'); + await mkdir(liveDir); + await writeFile(path.join(liveDir, 'index.html'), 'old'); + return { root, liveDir }; +} + +afterEach(async () => { + await Promise.all( + roots.splice(0).map(async (root) => { + const { rm } = await import('node:fs/promises'); + await rm(root, { recursive: true, force: true }); + }), + ); +}); + +describe('authorized staged team-project pull', () => { + it('runs the exact-version Vela command into a random empty same-parent stage', async () => { + const { root, liveDir } = await fixture(); + const calls: Array<{ args: string[]; workspaceId: string | undefined }> = []; + + const staged = await stageAuthorizedTeamProjectPull({ + projectId: 'project-1', + liveDir, + scope: SCOPE, + expectedVersion: 7, + now: () => NOW, + run: async (args, workspaceId) => { + calls.push({ args, workspaceId }); + const stageDir = args[2]!; + expect(path.dirname(stageDir)).toBe(root); + expect(path.basename(stageDir)).toMatch(/^\.project-1\.od-pull-stage-/u); + expect(await readdir(stageDir)).toEqual([]); + await writeFile(path.join(stageDir, 'index.html'), 'new'); + return JSON.stringify(receipt()); + }, + }); + + expect(calls).toEqual([{ + args: [ + 'pull', + 'project-1', + staged.stageDir, + '--live-dir', + liveDir, + '--ref', + 'published', + '--expected-version', + '7', + '--json', + ], + workspaceId: 'workspace-1', + }]); + expect(staged.receipt).toEqual(receipt()); + expect(await readFile(path.join(staged.stageDir, 'index.html'), 'utf8')).toBe('new'); + expect(await readFile(path.join(liveDir, 'index.html'), 'utf8')).toBe('old'); + await staged.cleanup(); + expect((await readdir(root)).filter((name) => name.includes('.od-pull-stage-'))).toEqual([]); + }); + + it.each([ + ['workspaceId', { workspaceId: 'workspace-2' }], + ['resourceTeamId', { resourceTeamId: 'team-2' }], + ['viewerMemberId', { viewerMemberId: 'viewer-2' }], + ['ownerMemberId', { ownerMemberId: 'owner-2' }], + ['projectId', { projectId: 'project-2' }], + ['resourceId empty', { resourceId: '' }], + ['resourceId non-canonical', { resourceId: 'resource-1' }], + ['ref', { ref: 'draft' as 'published' }], + ['version', { version: 8 }], + ['versionId', { versionId: '' }], + ['manifestDigest', { manifestDigest: 'sha256:not-a-digest' }], + ['lifecycleState', { lifecycleState: 'inactive' as 'active' }], + ['schemaVersion', { schemaVersion: 2 as 1 }], + ])('rejects a receipt whose %s binding is invalid', (_field, overrides) => { + expect(() => + validateAuthorizedTeamProjectPullReceipt(receipt(overrides), { + projectId: 'project-1', + scope: SCOPE, + expectedVersion: 7, + nowMs: NOW, + }), + ).toThrow(); + }); + + it.each([ + ['expired', { authorizedAt: '2026-07-26T09:59:57.000Z', expiresAt: '2026-07-26T09:59:59.000Z' }], + ['overlong', { authorizedAt: '2026-07-26T10:00:00.000Z', expiresAt: '2026-07-26T10:00:02.001Z' }], + ['reverse', { authorizedAt: '2026-07-26T10:00:01.000Z', expiresAt: '2026-07-26T10:00:01.000Z' }], + ['malformed', { authorizedAt: 'not-a-date' }], + ])('rejects a %s receipt outside the two-second freshness envelope', (_case, overrides) => { + expect(() => + validateAuthorizedTeamProjectPullReceipt(receipt(overrides), { + projectId: 'project-1', + scope: SCOPE, + expectedVersion: 7, + nowMs: NOW, + }), + ).toThrow(); + }); + + it.each([ + ['at expiry', '2026-07-26T10:00:00.500Z'], + ['past expiry', '2026-07-26T10:00:00.499Z'], + ])('classifies only an actually expired receipt as retryable: %s', (_case, expiresAt) => { + let thrown: unknown; + try { + validateAuthorizedTeamProjectPullReceipt(receipt({ + authorizedAt: '2026-07-26T09:59:58.500Z', + expiresAt, + }), { + projectId: 'project-1', + scope: SCOPE, + expectedVersion: 7, + nowMs: NOW, + }); + } catch (error) { + thrown = error; + } + expect(isAuthorizedTeamProjectPullReceiptExpired(thrown)).toBe(true); + }); + + it('does not classify an overlong receipt envelope as retryable expiry', () => { + let thrown: unknown; + try { + validateAuthorizedTeamProjectPullReceipt(receipt({ + authorizedAt: '2026-07-26T10:00:00.000Z', + expiresAt: '2026-07-26T10:00:02.001Z', + }), { + projectId: 'project-1', + scope: SCOPE, + expectedVersion: 7, + nowMs: NOW, + }); + } catch (error) { + thrown = error; + } + expect(isAuthorizedTeamProjectPullReceiptExpired(thrown)).toBe(false); + }); + + it('accepts a fresh receipt whose authorization clock is slightly ahead locally', () => { + expect(() => + validateAuthorizedTeamProjectPullReceipt(receipt({ + authorizedAt: '2026-07-26T10:00:01.000Z', + expiresAt: '2026-07-26T10:00:03.000Z', + }), { + projectId: 'project-1', + scope: SCOPE, + expectedVersion: 7, + nowMs: NOW, + }), + ).not.toThrow(); + }); + + it('cleans the stage and leaves live untouched when stdout is malformed', async () => { + const { root, liveDir } = await fixture(); + await expect(stageAuthorizedTeamProjectPull({ + projectId: 'project-1', + liveDir, + scope: SCOPE, + expectedVersion: 7, + now: () => NOW, + run: async (args) => { + await writeFile(path.join(args[2]!, 'index.html'), 'untrusted'); + return '{bad json'; + }, + })).rejects.toThrow(); + + expect(await readFile(path.join(liveDir, 'index.html'), 'utf8')).toBe('old'); + expect((await readdir(root)).filter((name) => name.includes('.od-pull-stage-'))).toEqual([]); + }); + + it('cleans the owned stage and leaves live untouched when aborted', async () => { + const { root, liveDir } = await fixture(); + const controller = new AbortController(); + let started!: () => void; + const runStarted = new Promise((resolve) => { + started = resolve; + }); + const staged = stageAuthorizedTeamProjectPull({ + projectId: 'project-1', + liveDir, + scope: SCOPE, + expectedVersion: 7, + signal: controller.signal, + now: () => NOW, + run: async (args, _workspaceId, options) => { + await writeFile(path.join(args[2]!, 'partial'), 'untrusted'); + started(); + await new Promise((_resolve, reject) => { + options.signal?.addEventListener( + 'abort', + () => reject(new DOMException('aborted', 'AbortError')), + { once: true }, + ); + }); + return JSON.stringify(receipt()); + }, + }); + await runStarted; + + controller.abort(); + + await expect(staged).rejects.toMatchObject({ name: 'AbortError' }); + expect(await readFile(path.join(liveDir, 'index.html'), 'utf8')).toBe('old'); + expect((await readdir(root)).filter((name) => name.includes('.od-pull-stage-'))).toEqual([]); + }); + + it('adopts the materialized inode when Vela replaces the initially-empty stage', async () => { + const { root, liveDir } = await fixture(); + const staged = await stageAuthorizedTeamProjectPull({ + projectId: 'project-1', + liveDir, + scope: SCOPE, + expectedVersion: 7, + now: () => NOW, + run: async (args) => { + const stageDir = args[2]!; + const replacement = `${stageDir}.vela-materialized`; + await mkdir(replacement); + await writeFile(path.join(replacement, 'index.html'), 'new'); + await rm(stageDir, { recursive: true }); + await rename(replacement, stageDir); + return JSON.stringify(receipt()); + }, + }); + + await staged.cleanup(); + + expect(await readFile(path.join(liveDir, 'index.html'), 'utf8')).toBe('old'); + expect((await readdir(root)).filter((name) => name.includes('.od-pull-stage-'))).toEqual([]); + }); + + it('restores and preserves a swapped caller directory raced into cleanup', async () => { + const { root, liveDir } = await fixture(); + let raced = false; + const staged = await stageAuthorizedTeamProjectPull({ + projectId: 'project-1', + liveDir, + scope: SCOPE, + expectedVersion: 7, + now: () => NOW, + cleanupHooks: { + beforeQuarantineRename: async (stageDir) => { + if (raced) return; + raced = true; + const owned = `${stageDir}.owned`; + await rename(stageDir, owned); + await mkdir(stageDir); + await writeFile(path.join(stageDir, 'caller.txt'), 'preserve me'); + }, + }, + run: async (args) => { + await writeFile(path.join(args[2]!, 'index.html'), 'new'); + return JSON.stringify(receipt()); + }, + }); + + await expect(staged.cleanup()).rejects.toThrow(/identity changed/u); + + expect(await readFile(path.join(staged.stageDir, 'caller.txt'), 'utf8')).toBe('preserve me'); + expect((await readdir(root)).some((name) => name.endsWith('.owned'))).toBe(true); + }); + + it.each([ + 'unknown command "pull" for "vela team-projects"', + 'unknown command "team-projects" for "vela"', + 'unknown flag: --expected-version', + 'unknown flag: --live-dir', + ])('classifies only a missing local CLI capability as fallback-safe: %s', (message) => { + expect(isAuthorizedTeamProjectPullUnavailable(new Error(message))).toBe(true); + }); + + it.each([ + 'API request failed with status 401: unauthenticated', + 'API request failed with status 403: team_project_pull_owner', + 'API request failed with status 404: team_project_pull_unavailable', + 'API request failed with status 409: team_project_pull_drift', + 'API request failed with status 500: internal_error', + 'fetch failed: ECONNRESET', + 'vela command timed out after 30000ms', + ])('fails closed instead of falling back for transport/authority failure: %s', (message) => { + expect(isAuthorizedTeamProjectPullUnavailable(new Error(message))).toBe(false); + }); +}); diff --git a/apps/daemon/tests/collab/created-project-workspace.test.ts b/apps/daemon/tests/collab/created-project-workspace.test.ts new file mode 100644 index 00000000000..1b8902e0672 --- /dev/null +++ b/apps/daemon/tests/collab/created-project-workspace.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + authorizeCreatedProjectWorkspace, + createdProjectWorkspaceHome, + CreatedProjectWorkspaceResolutionError, + type CreatedProjectWorkspaceResolution, +} from '../../src/collab/created-project-workspace.js'; + +const ACTIVE_HEADERS: Record = { + 'x-od-workspace-id': 'workspace-a', + 'x-od-workspace-type': 'team', + 'x-od-workspace-member-id': 'member-a', + 'x-od-workspace-role': 'owner', + 'x-od-workspace-lifecycle-state': 'active', + 'x-od-workspace-member-status': 'active', + 'x-od-workspace-can-share-projects': 'true', + 'x-od-workspace-can-write-synced-files': 'true', +}; + +function request(headers: Record = ACTIVE_HEADERS) { + const normalized = new Map( + Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]), + ); + return { + get(name: string) { + return normalized.get(name.toLowerCase()); + }, + }; +} + +function directoryItem(overrides: Record = {}) { + return { + workspaceId: 'workspace-a', + workspaceName: 'Workspace A', + workspaceType: 'team' as const, + workspaceMemberId: 'member-a', + role: 'owner' as const, + memberStatus: 'active' as const, + lifecycleState: 'active' as const, + ...overrides, + }; +} + +function expectDenied( + result: CreatedProjectWorkspaceResolution, + status: number, + code: string, +): void { + expect(result).toMatchObject({ ok: false, status, code }); +} + +describe('authorizeCreatedProjectWorkspace', () => { + it('returns the exact authoritative workspace/member context, independent of ambient workspace', async () => { + const result = await authorizeCreatedProjectWorkspace( + request(), + async () => ({ + ok: true, + items: [ + directoryItem({ + workspaceId: 'workspace-b', + workspaceName: 'Workspace B', + workspaceMemberId: 'member-b', + role: 'member', + }), + directoryItem(), + ], + }), + ); + + expect(result).toMatchObject({ + ok: true, + context: { + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + workspaceType: 'team', + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + canWriteSyncedFiles: true, + }, + }); + }); + + it('rejects a workspace/member pair that exists only across different directory rows', async () => { + const result = await authorizeCreatedProjectWorkspace( + request({ + ...ACTIVE_HEADERS, + 'x-od-workspace-member-id': 'member-b', + }), + async () => ({ + ok: true, + items: [ + directoryItem(), + directoryItem({ + workspaceId: 'workspace-b', + workspaceName: 'Workspace B', + workspaceMemberId: 'member-b', + }), + ], + }), + ); + + expectDenied(result, 403, 'WORKSPACE_PROJECT_PERMISSION_DENIED'); + }); + + it.each([ + ['removed member', { memberStatus: 'removed' }], + ['locked workspace', { lifecycleState: 'locked' }], + ['deleting workspace', { lifecycleState: 'deleting' }], + ])('fails closed for an authoritative %s', async (_label, override) => { + const result = await authorizeCreatedProjectWorkspace( + request(), + async () => ({ ok: true, items: [directoryItem(override)] }), + ); + + expectDenied(result, 403, 'WORKSPACE_PROJECT_PERMISSION_DENIED'); + }); + + it('returns a retryable 503 when AMR workspace authority is unavailable', async () => { + const result = await authorizeCreatedProjectWorkspace( + request(), + async () => ({ ok: false, items: [] }), + ); + + expectDenied(result, 503, 'WORKSPACE_AUTHORITY_UNAVAILABLE'); + expect(result).toMatchObject({ ok: false, retryable: true }); + }); + + it('preserves explicitly anonymous/headerless compatibility without consulting AMR', async () => { + const fetchDirectory = vi.fn(async () => ({ ok: false, items: [] })); + const result = await authorizeCreatedProjectWorkspace( + request({}), + fetchDirectory, + ); + + expect(result).toEqual({ ok: true, context: null }); + expect(fetchDirectory).not.toHaveBeenCalled(); + }); + + it('rejects a partial workspace identity before consulting AMR', async () => { + const fetchDirectory = vi.fn(async () => ({ ok: true, items: [] })); + const result = await authorizeCreatedProjectWorkspace( + request({ 'x-od-workspace-id': 'workspace-a' }), + fetchDirectory, + ); + + expectDenied(result, 400, 'WORKSPACE_CONTEXT_INCOMPLETE'); + expect(fetchDirectory).not.toHaveBeenCalled(); + }); +}); + +// Resolver-style creation paths use the exact same authority result as direct +// POST /api/projects. Headerless legacy remains unbound; once identity is +// asserted, denial/outage must propagate before any project side effect. +describe('createdProjectWorkspaceHome', () => { + /** Headers naming a workspace/member pair the caller has no membership in. */ + const FOREIGN_HEADERS: Record = { + ...ACTIVE_HEADERS, + 'x-od-workspace-id': 'workspace-foreign', + 'x-od-workspace-member-id': 'member-foreign', + }; + + it('binds an asserted identity the directory confirms, using the DIRECTORY context', async () => { + const home = await createdProjectWorkspaceHome(request(), async () => ({ + ok: true, + items: [directoryItem()], + })); + + expect(home).toMatchObject({ + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + memberStatus: 'active', + }); + }); + + it('rejects an asserted workspace the caller has no membership in', async () => { + await expect(createdProjectWorkspaceHome( + request(FOREIGN_HEADERS), + async () => ({ ok: true, items: [directoryItem()] }), + )).rejects.toMatchObject({ + status: 403, + code: 'WORKSPACE_PROJECT_PERMISSION_DENIED', + }); + }); + + it('rejects an unreadable or throwing membership authority as retryable', async () => { + await expect(createdProjectWorkspaceHome(request(), async () => ({ + ok: false, + items: [], + }))).rejects.toMatchObject({ + status: 503, + code: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + retryable: true, + }); + await expect(createdProjectWorkspaceHome(request(), async () => { + throw new Error('authority exploded'); + })).rejects.toBeInstanceOf(CreatedProjectWorkspaceResolutionError); + }); + + it('leaves a completely headerless legacy request unbound', async () => { + const fetchDirectory = vi.fn(async () => ({ ok: true, items: [directoryItem()] })); + const home = await createdProjectWorkspaceHome(request({}), fetchDirectory); + + expect(home).toBeNull(); + expect(fetchDirectory).not.toHaveBeenCalled(); + }); + + it('rejects a partial asserted identity instead of dropping its scope', async () => { + await expect(createdProjectWorkspaceHome( + request({ 'x-od-workspace-id': 'workspace-a' }), + )).rejects.toMatchObject({ + status: 400, + code: 'WORKSPACE_CONTEXT_INCOMPLETE', + }); + }); +}); diff --git a/apps/daemon/tests/collab/hub-events-subscriber.test.ts b/apps/daemon/tests/collab/hub-events-subscriber.test.ts new file mode 100644 index 00000000000..c4d7c4ad968 --- /dev/null +++ b/apps/daemon/tests/collab/hub-events-subscriber.test.ts @@ -0,0 +1,623 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + parseHubWorkspaceEvent, + startHubEventsSubscriber, + type HubEventsSubscriber, +} from '../../src/collab/hub-events-subscriber.js'; +import { createWorkspaceBillingRuntimeCoordinator } from '../../src/collab/workspace-billing-runtime.js'; +import type { VelaWorkspaceBillingProjection } from '../../src/integrations/vela-billing.js'; + +function sseResponse(frames: string[], opts: { holdOpen?: boolean } = {}) { + const encoder = new TextEncoder(); + let started = false; + const stream = new ReadableStream({ + async pull(controller) { + if (!started) { + started = true; + for (const frame of frames) controller.enqueue(encoder.encode(frame)); + if (!opts.holdOpen) controller.close(); + return; + } + if (!opts.holdOpen) controller.close(); + // holdOpen: never enqueue again — simulates a silent zombie stream. + await new Promise(() => undefined); + }, + }); + return new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } }); +} + +function abortableSseResponse( + frames: string[], + signal: AbortSignal | null | undefined, + onAbort: () => void, +) { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (const frame of frames) controller.enqueue(encoder.encode(frame)); + const abort = () => { + onAbort(); + controller.close(); + }; + if (signal?.aborted) abort(); + else signal?.addEventListener('abort', abort, { once: true }); + }, + }); + return new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } }); +} + +const READY = 'event: ready\ndata: {"workspaceId":"w1"}\n\n'; +const HEARTBEAT = 'event: heartbeat\ndata: {}\n\n'; +const COMMENT_EVENT = + 'event: workspace-event\ndata: {"type":"comment-changed","workspaceId":"w1","projectId":"p1","seq":7}\n\n'; +const BILLING_KEY = { workspaceId: 'w1', workspaceMemberId: 'm1' }; + +function billingProjection(balanceUsd: string): VelaWorkspaceBillingProjection { + return { + snapshot: { + schemaVersion: 1, + workspaceId: BILLING_KEY.workspaceId, + workspaceMemberId: BILLING_KEY.workspaceMemberId, + billingScopeVersion: 2, + billing: { billingState: 'active', planId: 'team_plus' }, + wallet: { + balanceUsd, + expiresAt: null, + updatedAt: '2026-07-28T00:00:00.000Z', + }, + revisions: { billing: 'billing-1', wallet: `wallet-${balanceUsd}` }, + }, + workspaceBalance: { + ...BILLING_KEY, + billingScopeVersion: 2, + balanceUsd, + expiresAt: null, + updatedAt: '2026-07-28T00:00:00.000Z', + }, + }; +} + +let subscriber: HubEventsSubscriber | null = null; + +afterEach(() => { + subscriber?.stop(); + subscriber = null; + vi.useRealTimers(); +}); + +describe('parseHubWorkspaceEvent', () => { + it('parses a valid thin event and drops unknown types', () => { + expect(parseHubWorkspaceEvent('{"type":"comment-changed","projectId":"p","seq":3}')).toEqual({ + type: 'comment-changed', + projectId: 'p', + seq: 3, + }); + expect(parseHubWorkspaceEvent('{"type":"mystery"}')).toBeNull(); + expect(parseHubWorkspaceEvent('not json')).toBeNull(); + }); + + it('preserves v2 billing scope and revision fields for fail-closed consumers', () => { + expect( + parseHubWorkspaceEvent( + '{"type":"wallet-balance-changed","workspaceId":"w1","workspaceMemberId":"m1","revision":"wallet-2"}', + ), + ).toEqual({ + type: 'wallet-balance-changed', + workspaceId: 'w1', + workspaceMemberId: 'm1', + revision: 'wallet-2', + }); + expect( + parseHubWorkspaceEvent( + '{"type":"billing-subscription-changed","workspaceId":"w1","revision":"billing-3","revisionClock":{"epoch":"billing-epoch-a","counter":"3"}}', + ), + ).toEqual({ + type: 'billing-subscription-changed', + workspaceId: 'w1', + revision: 'billing-3', + revisionClock: { + epoch: 'billing-epoch-a', + counter: '3', + }, + }); + expect( + parseHubWorkspaceEvent( + '{"type":"billing-changed","workspaceId":"w1","revision":"billing-3"}', + ), + ).toEqual({ + type: 'billing-changed', + workspaceId: 'w1', + revision: 'billing-3', + }); + }); + + it('drops malformed additive revision clocks and preserves the legacy event', () => { + expect( + parseHubWorkspaceEvent( + '{"type":"billing-subscription-changed","workspaceId":"w1","revision":"billing:v1:3","revisionClock":{"epoch":"","counter":"-1"}}', + ), + ).toEqual({ + type: 'billing-subscription-changed', + workspaceId: 'w1', + revision: 'billing:v1:3', + }); + }); + + // workspace-team continuous-sync priority 3: the resource-hub's + // 'team-resources-changed' push (vela API PR: emits on a 'published' ref + // move or a resource soft-delete) needs resourceKind + resourceStatus to + // route to the right per-kind reconciler and to tell "just shared" from + // "just retracted" apart. + it('parses team-resources-changed with resourceKind and resourceStatus', () => { + expect( + parseHubWorkspaceEvent( + '{"type":"team-resources-changed","workspaceId":"w1","resourceId":"my-skill","resourceKind":"skill","resourceStatus":"shared","version":2}', + ), + ).toEqual({ + type: 'team-resources-changed', + workspaceId: 'w1', + resourceId: 'my-skill', + resourceKind: 'skill', + resourceStatus: 'shared', + version: 2, + }); + expect( + parseHubWorkspaceEvent( + '{"type":"team-resources-changed","workspaceId":"w1","resourceId":"my-skill","resourceKind":"skill","resourceStatus":"retracted"}', + ), + ).toMatchObject({ resourceStatus: 'retracted' }); + }); + + it('drops an unrecognized resourceStatus rather than passing it through', () => { + const event = parseHubWorkspaceEvent( + '{"type":"team-resources-changed","workspaceId":"w1","resourceId":"r1","resourceStatus":"mystery"}', + ); + expect(event).toEqual({ + type: 'team-resources-changed', + workspaceId: 'w1', + resourceId: 'r1', + }); + expect(event).not.toHaveProperty('resourceStatus'); + }); +}); + +describe('startHubEventsSubscriber', () => { + it('uses revision clocks only when the ready frame advertises the capability', async () => { + const clockEvent = + 'event: workspace-event\ndata: {"type":"billing-subscription-changed","workspaceId":"w1","revision":"billing:v1:2","revisionClock":{"epoch":"billing-epoch-a","counter":"2"}}\n\n'; + const events: unknown[] = []; + let fetches = 0; + let resolveBoth!: () => void; + const both = new Promise((resolve) => { + resolveBoth = resolve; + }); + + subscriber = startHubEventsSubscriber({ + resolveEndpoint: async () => ({ url: 'https://hub/events', headers: {} }), + onEvent: (event) => { + events.push(event); + if (events.length === 2) resolveBoth(); + }, + backoffMinMs: 1, + backoffMaxMs: 2, + fetchImpl: async () => { + fetches += 1; + return fetches === 1 + ? sseResponse([ + 'event: ready\ndata: {"workspaceId":"w1","capabilities":[]}\n\n', + clockEvent, + ]) + : sseResponse([ + 'event: ready\ndata: {"workspaceId":"w1","capabilities":["billing-revision-clocks-v1"]}\n\n', + clockEvent, + ], { holdOpen: true }); + }, + }); + + await both; + expect(events[0]).not.toHaveProperty('revisionClock'); + expect(events[1]).toMatchObject({ + revisionClock: { epoch: 'billing-epoch-a', counter: '2' }, + }); + }); + + it('reports one healthy source gap per listener epoch from status and heartbeat frames', async () => { + const gaps: unknown[] = []; + let resolveGaps!: () => void; + const twoGaps = new Promise((resolve) => { + resolveGaps = resolve; + }); + const recordGap = (gap: unknown) => { + gaps.push(gap); + if (gaps.length === 2) resolveGaps(); + }; + + subscriber = startHubEventsSubscriber({ + resolveEndpoint: async () => ({ + url: 'https://hub/events', + headers: {}, + workspaceId: 'w1', + }), + onEvent: () => undefined, + onSourceGap: recordGap, + fetchImpl: async () => sseResponse([ + 'event: source-status\ndata: {"listenerEpoch":"listener-before-ready","listenerHealth":"healthy","sourceGap":true}\n\n', + 'event: ready\ndata: {"workspaceId":"w1","capabilities":["billing-revision-clocks-v1"],"listenerEpoch":"listener-a","listenerHealth":"starting","sourceGap":true}\n\n', + 'event: source-status\ndata: {"listenerEpoch":"listener-a","listenerHealth":"mystery","sourceGap":true}\n\n', + 'event: heartbeat\ndata: {"listenerEpoch":"listener-a","listenerHealth":"healthy","sourceGap":true}\n\n', + 'event: source-status\ndata: {"listenerEpoch":"listener-a","listenerHealth":"healthy","sourceGap":true}\n\n', + 'event: source-status\ndata: {"listenerEpoch":"listener-b","listenerHealth":"healthy","sourceGap":true}\n\n', + ], { holdOpen: true }), + }); + + await twoGaps; + expect(gaps).toEqual([ + { workspaceId: 'w1', listenerEpoch: 'listener-a' }, + { workspaceId: 'w1', listenerEpoch: 'listener-b' }, + ]); + }); + + it('delivers workspace-events and reports connected state', async () => { + const events: unknown[] = []; + const states: string[] = []; + let resolveDone!: () => void; + const done = new Promise((r) => { + resolveDone = r; + }); + + subscriber = startHubEventsSubscriber({ + resolveEndpoint: async () => ({ url: 'https://hub/api/v1/collab/events', headers: {} }), + onEvent: (event) => { + events.push(event); + resolveDone(); + }, + onStateChange: (state) => states.push(state), + fetchImpl: async () => sseResponse([READY, HEARTBEAT, COMMENT_EVENT], { holdOpen: true }), + }); + + await done; + expect(events).toEqual([ + { type: 'comment-changed', workspaceId: 'w1', projectId: 'p1', seq: 7 }, + ]); + expect(states).toEqual(['connected']); + expect(subscriber.connected()).toBe(true); + }); + + it('fires onReconnect only from the second successful connect on', async () => { + let connects = 0; + const reconnects: number[] = []; + let resolveSecond!: () => void; + const second = new Promise((r) => { + resolveSecond = r; + }); + + subscriber = startHubEventsSubscriber({ + resolveEndpoint: async () => ({ url: 'https://hub/events', headers: {} }), + onEvent: () => undefined, + onReconnect: () => { + reconnects.push(connects); + resolveSecond(); + }, + backoffMinMs: 1, + backoffMaxMs: 2, + fetchImpl: async () => { + connects += 1; + return sseResponse([READY]); // closes immediately → next loop reconnects + }, + }); + + await second; + expect(reconnects[0]).toBeGreaterThanOrEqual(2); + }); + + it('fires the content catch-up hook on both the first connection and a reconnect', async () => { + const connections: boolean[] = []; + let fetches = 0; + let resolveSecond!: () => void; + const second = new Promise((resolve) => { + resolveSecond = resolve; + }); + const options = { + resolveEndpoint: async () => ({ url: 'https://hub/events', headers: {} }), + onEvent: () => undefined, + onConnect: ({ reconnect }: { reconnect: boolean }) => { + connections.push(reconnect); + if (connections.length === 2) resolveSecond(); + }, + backoffMinMs: 1, + backoffMaxMs: 2, + fetchImpl: async () => { + fetches += 1; + return sseResponse([READY]); + }, + }; + + // `onReconnect` deliberately skips the first successful connection. The + // content catch-up hook must not: a published head may already exist when + // this daemon establishes its very first stream. + subscriber = startHubEventsSubscriber(options as Parameters[0]); + + await second; + subscriber.stop(); + expect(fetches).toBeGreaterThanOrEqual(2); + expect(connections).toEqual([false, true]); + }); + + it('authoritatively catches up an interested wallet mutation made while SSE is disconnected', async () => { + let upstreamBalance = '1.00'; + let projectionReads = 0; + let endpointResolutions = 0; + const timeline: string[] = []; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + projectionReads += 1; + timeline.push(`projection:${upstreamBalance}`); + return billingProjection(upstreamBalance); + }, + }); + + await runtime.read(BILLING_KEY, { + clientId: 'renderer-1', + clientGeneration: '1', + }); + + let resolveRecovered!: () => void; + const recovered = new Promise((resolve) => { + resolveRecovered = resolve; + }); + + subscriber = startHubEventsSubscriber({ + resolveEndpoint: async () => { + endpointResolutions += 1; + timeline.push(`resolve:${endpointResolutions}`); + if (endpointResolutions === 2) { + // The wallet commits after the first stream has closed and before + // the reconnect becomes ready. No wallet invalidation is delivered. + upstreamBalance = '2.00'; + timeline.push('wallet:2.00'); + } + return { url: 'https://hub/events', headers: {} }; + }, + onEvent: () => undefined, + onConnect: ({ reconnect }) => { + timeline.push(reconnect ? 'ready:reconnect' : 'ready:first'); + if (!reconnect) return; + runtime.reconnect(BILLING_KEY.workspaceId); + void runtime + .read(BILLING_KEY, { + clientId: 'renderer-1', + clientGeneration: '1', + }) + .then((result) => { + timeline.push( + `fresh:${result.state.status}:${result.projection.workspaceBalance?.balanceUsd}`, + ); + resolveRecovered(); + }); + }, + backoffMinMs: 1, + backoffMaxMs: 2, + fetchImpl: async () => + endpointResolutions === 1 + ? sseResponse([READY]) + : sseResponse([READY], { holdOpen: true }), + }); + + await recovered; + expect(projectionReads).toBe(2); + expect(runtime.peek(BILLING_KEY)).toMatchObject({ + projection: { workspaceBalance: { balanceUsd: '2.00' } }, + state: { status: 'fresh', reason: 'reconnect' }, + }); + expect(timeline).toEqual([ + 'projection:1.00', + 'resolve:1', + 'ready:first', + 'resolve:2', + 'wallet:2.00', + 'ready:reconnect', + 'projection:2.00', + 'fresh:fresh:2.00', + ]); + runtime.dispose(); + }); + + it('reports ready capabilities per verified connection without retaining stale values', async () => { + const capabilities: string[][] = []; + let fetches = 0; + let resolveSecond!: () => void; + const second = new Promise((resolve) => { + resolveSecond = resolve; + }); + subscriber = startHubEventsSubscriber({ + resolveEndpoint: async () => ({ + url: 'https://hub/events', + headers: {}, + workspaceId: 'w1', + }), + onEvent: () => undefined, + onConnect: ({ capabilities: readyCapabilities }) => { + capabilities.push([...readyCapabilities]); + if (capabilities.length === 2) resolveSecond(); + }, + backoffMinMs: 1, + backoffMaxMs: 2, + fetchImpl: async () => { + fetches += 1; + return sseResponse([ + fetches === 1 + ? 'event: ready\ndata: {"workspaceId":"w1","capabilities":["authoritative-project-presence-v1"]}\n\n' + : 'event: ready\ndata: {"workspaceId":"w1","capabilities":[]}\n\n', + ]); + }, + }); + + await second; + expect(capabilities).toEqual([['authoritative-project-presence-v1'], []]); + }); + + it('immediately re-resolves and reconnects when the active workspace changes', async () => { + let activeWorkspaceId = 'w1'; + const resolvedScopes: string[] = []; + const connectedScopes: string[] = []; + const abortedScopes: string[] = []; + const errors: unknown[] = []; + let resolveFirst!: () => void; + const first = new Promise((resolve) => { + resolveFirst = resolve; + }); + let resolveSecond!: () => void; + const second = new Promise((resolve) => { + resolveSecond = resolve; + }); + + subscriber = startHubEventsSubscriber({ + resolveEndpoint: async () => { + const workspaceId = activeWorkspaceId; + resolvedScopes.push(workspaceId); + return { + url: 'https://hub/events', + headers: { 'x-vela-workspace-id': workspaceId }, + workspaceId, + }; + }, + onEvent: () => undefined, + onConnect: ({ workspaceId }) => { + if (!workspaceId) return; + connectedScopes.push(workspaceId); + if (workspaceId === 'w1') resolveFirst(); + if (workspaceId === 'w2') resolveSecond(); + }, + onError: (error) => errors.push(error), + backoffMinMs: 1_000_000, + backoffMaxMs: 1_000_000, + fetchImpl: async (_url, init) => { + const workspaceId = String( + (init?.headers as Record)?.['x-vela-workspace-id'], + ); + return abortableSseResponse( + [`event: ready\ndata: {"workspaceId":"${workspaceId}"}\n\n`], + init?.signal, + () => abortedScopes.push(workspaceId), + ); + }, + }); + + await first; + activeWorkspaceId = 'w2'; + subscriber.refreshEndpoint(); + await second; + + expect(resolvedScopes.slice(0, 2)).toEqual(['w1', 'w2']); + expect(connectedScopes).toEqual(['w1', 'w2']); + expect(abortedScopes).toContain('w1'); + expect(errors).toEqual([]); + }); + + it('does not verify or dispatch a workspace event before the ready frame', async () => { + const events: unknown[] = []; + const connections: boolean[] = []; + const drops: string[] = []; + let resolveReady!: () => void; + const ready = new Promise((resolve) => { + resolveReady = resolve; + }); + subscriber = startHubEventsSubscriber({ + resolveEndpoint: async () => ({ + url: 'https://hub/events', + headers: {}, + workspaceId: 'w1', + }), + onEvent: (event) => events.push(event), + onConnect: ({ reconnect }) => { + connections.push(reconnect); + resolveReady(); + }, + onDrop: ({ reason }) => drops.push(reason), + fetchImpl: async () => sseResponse([COMMENT_EVENT, READY, COMMENT_EVENT], { holdOpen: true }), + }); + + await ready; + await vi.waitFor(() => expect(events).toHaveLength(1)); + expect(connections).toEqual([false]); + expect(drops).toContain('unverified-scope'); + }); + + it('reports a ready workspace mismatch and never runs connection catch-up', async () => { + const onConnect = vi.fn(); + const onDrop = vi.fn(); + subscriber = startHubEventsSubscriber({ + resolveEndpoint: async () => ({ + url: 'https://hub/events', + headers: {}, + workspaceId: 'w1', + }), + onEvent: () => undefined, + onConnect, + onDrop, + backoffMinMs: 1_000_000, + fetchImpl: async () => + sseResponse(['event: ready\ndata: {"workspaceId":"w2"}\n\n'], { holdOpen: true }), + }); + + await vi.waitFor(() => { + expect(onDrop).toHaveBeenCalledWith({ + reason: 'workspace-mismatch', + eventName: 'ready', + expectedWorkspaceId: 'w1', + actualWorkspaceId: 'w2', + }); + }); + expect(onConnect).not.toHaveBeenCalled(); + }); + + + it('idles when the endpoint resolves null and stops cleanly', async () => { + let resolved = 0; + subscriber = startHubEventsSubscriber({ + resolveEndpoint: async () => { + resolved += 1; + return null; + }, + onEvent: () => undefined, + backoffMaxMs: 5, + fetchImpl: async () => { + throw new Error('must not fetch'); + }, + }); + await new Promise((r) => setTimeout(r, 30)); + expect(resolved).toBeGreaterThanOrEqual(2); + subscriber.stop(); + const after = resolved; + await new Promise((r) => setTimeout(r, 20)); + expect(resolved).toBe(after); + }); + + it('aborts a silent stream once the heartbeat watchdog expires', async () => { + let aborted = false; + let resolveAborted!: () => void; + const abortedOnce = new Promise((r) => { + resolveAborted = r; + }); + + subscriber = startHubEventsSubscriber({ + resolveEndpoint: async () => ({ url: 'https://hub/events', headers: {} }), + onEvent: () => undefined, + heartbeatTimeoutMs: 20, + backoffMinMs: 1_000_000, // park after the abort so we observe exactly one cycle + fetchImpl: async (_url, init) => { + init?.signal?.addEventListener('abort', () => { + if (!aborted) { + aborted = true; + resolveAborted(); + } + }); + return sseResponse([READY], { holdOpen: true }); // then silence + }, + }); + + await abortedOnce; + expect(aborted).toBe(true); + }); +}); diff --git a/apps/daemon/tests/collab/hub-workspace-context-changed-poll.test.ts b/apps/daemon/tests/collab/hub-workspace-context-changed-poll.test.ts new file mode 100644 index 00000000000..e8d6b148d6a --- /dev/null +++ b/apps/daemon/tests/collab/hub-workspace-context-changed-poll.test.ts @@ -0,0 +1,106 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it, vi } from 'vitest'; + +import { + handleHubVerifiedConnection, + handleHubWorkspaceContextChanged, +} from '../../src/server.js'; + +describe('handleHubVerifiedConnection', () => { + it('catches up billing on the first workspace-verified connection', () => { + const catchUpPublishedHeads = vi.fn(async () => undefined); + const catchUpWorkspaceBilling = vi.fn(); + + handleHubVerifiedConnection( + 'workspace-1', + catchUpPublishedHeads, + catchUpWorkspaceBilling, + ); + + expect(catchUpPublishedHeads).toHaveBeenCalledWith('workspace-1'); + expect(catchUpWorkspaceBilling).toHaveBeenCalledWith('workspace-1'); + }); +}); + +// Regression coverage for the fix wiring the hub's real `workspace-context-changed` +// push (`startHubEventsSubscriber`'s `onEvent` in server.ts) to an immediate +// `workspaceInvalidationPoller.pollOnce()` — the same catch-up `onReconnect` +// already runs. Before this fix, the event only forwarded a thin SSE nudge to +// the web; the daemon's own last-known-membership cache (consumed by +// `enforceWorkspaceProjectMutation`'s mutation gate) had no accelerated path +// and stayed stale for up to the poller's ~15s cadence — e.g. a member removed +// from a team kept passing the mutation gate for that long even though Vela +// had already told this daemon something changed. +describe('handleHubWorkspaceContextChanged', () => { + it('triggers an immediate workspace-invalidation poll cycle', async () => { + const pollWorkspaceInvalidation = vi.fn(async () => undefined); + + handleHubWorkspaceContextChanged('workspace-1', pollWorkspaceInvalidation); + + expect(pollWorkspaceInvalidation).toHaveBeenCalledTimes(1); + }); + + it('never lets a poll failure throw or reject out of the hub event handler', async () => { + const pollWorkspaceInvalidation = vi.fn(() => Promise.reject(new Error('vela unreachable'))); + const unhandled = vi.fn(); + process.once('unhandledRejection', unhandled); + + expect(() => + handleHubWorkspaceContextChanged('workspace-1', pollWorkspaceInvalidation) + ).not.toThrow(); + // Let the fire-and-forget `.catch()` settle before asserting nothing leaked. + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(pollWorkspaceInvalidation).toHaveBeenCalledTimes(1); + expect(unhandled).not.toHaveBeenCalled(); + process.removeListener('unhandledRejection', unhandled); + }); +}); + +// Scope-boundary guard (real source, not a re-implementation): the fix is +// deliberately scoped to ONLY the `workspace-context-changed` hub event. +// `team-projects-changed`, `comment-changed`, `presence-changed`, +// `billing-changed`, `project-metadata-changed`, and `project-content-changed` +// already have their own handling and must not gain a redundant immediate +// poll trigger as a side effect of this change (or of some later edit next to +// it) — each keeps costing exactly the requests its own case already made. +describe('hub events onEvent switch (source boundary)', () => { + const serverSourcePath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../src/server.ts', + ); + const source = fs.readFileSync(serverSourcePath, 'utf8'); + + function extractOnEventSwitchBody(): string { + const anchor = 'onEvent: (event) => {'; + const start = source.indexOf(anchor); + expect(start, 'expected to find the hub events onEvent handler in server.ts').toBeGreaterThan(-1); + const switchStart = source.indexOf('switch (event.type) {', start); + expect(switchStart, 'expected a switch(event.type) right after onEvent').toBeGreaterThan(-1); + // Walk brace depth from the switch's opening `{` to find its matching close. + let depth = 0; + let i = switchStart + 'switch (event.type) {'.length - 1; // position of the opening brace + for (; i < source.length; i += 1) { + if (source[i] === '{') depth += 1; + else if (source[i] === '}') { + depth -= 1; + if (depth === 0) break; + } + } + expect(depth, 'expected the switch braces to balance').toBe(0); + return source.slice(switchStart, i + 1); + } + + it('calls the immediate poll trigger from exactly one case: workspace-context-changed', () => { + const switchBody = extractOnEventSwitchBody(); + const cases = switchBody.split(/(?=case '[a-z-]+':)/g).filter((chunk) => chunk.startsWith("case '")); + expect(cases.length).toBeGreaterThanOrEqual(7); + + const casesCallingPoll = cases.filter((chunk) => /handleHubWorkspaceContextChanged|workspaceInvalidationPoller\.pollOnce\(/.test(chunk)); + const caseNames = casesCallingPoll.map((chunk) => chunk.match(/^case '([a-z-]+)':/)?.[1]); + + expect(caseNames).toEqual(['workspace-context-changed']); + }); +}); diff --git a/apps/daemon/tests/collab/persisted-team-share.test.ts b/apps/daemon/tests/collab/persisted-team-share.test.ts new file mode 100644 index 00000000000..f7f73144f53 --- /dev/null +++ b/apps/daemon/tests/collab/persisted-team-share.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { recoverPersistedTeamShareOwnership } from '../../src/collab/persisted-team-share.js'; + +describe('recoverPersistedTeamShareOwnership', () => { + it('does not restore a consumer mirror updater as the project owner after restart', () => { + expect( + recoverPersistedTeamShareOwnership({ + projectId: 'shared-project', + workspaceId: 'team-workspace', + createdByWorkspaceMemberId: null, + updatedByWorkspaceMemberId: 'viewer-member', + }), + ).toBeNull(); + }); + + it('restores the persisted creator without letting a later updater replace it', () => { + expect( + recoverPersistedTeamShareOwnership({ + projectId: 'owned-project', + workspaceId: 'team-workspace', + createdByWorkspaceMemberId: 'owner-member', + updatedByWorkspaceMemberId: 'viewer-member', + }), + ).toEqual({ + projectId: 'owned-project', + principal: { + memberId: 'owner-member', + teamId: 'team-workspace', + role: 'member', + lifecycleState: 'active', + }, + }); + }); +}); diff --git a/apps/daemon/tests/collab/persistent-sync-cache.test.ts b/apps/daemon/tests/collab/persistent-sync-cache.test.ts new file mode 100644 index 00000000000..4c87e1028dd --- /dev/null +++ b/apps/daemon/tests/collab/persistent-sync-cache.test.ts @@ -0,0 +1,399 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { closeDatabase, openDatabase } from '../../src/db.js'; +import { + createCollabSyncSnapshotStore, + parseTeamProjectSnapshot, + type CollabSyncSnapshotStore, +} from '../../src/collab/sync-snapshot-store.js'; +import { createPersistentSyncCache } from '../../src/collab/persistent-sync-cache.js'; +import type { SyncDigest, SyncDigestReading } from '../../src/collab/sync-digest.js'; +import type { TeamProject } from '@open-design/contracts'; + +// The persistent half of the workspace sync design: SSE marks dirty, B's +// sync-digest hands out opaque comparison tokens, and this layer decides +// whether the payload already on disk can stand in for a real round-trip. +// +// The reuse rule under test is three conditions AND-ed — local token, local +// snapshot, token equality — with EVERY other combination falling through to a +// real fetch. Account isolation is the load-bearing one: a snapshot must never +// be readable by a different signed-in user. + +function digest(overrides: Partial = {}): SyncDigest { + return { + catalogToken: '2026-07-20T00:00:00Z:2', + membersToken: '2026-07-20T00:00:00Z:3', + contextToken: '2026-07-20T00:00:00Z', + billingToken: '', + ...overrides, + }; +} + +function reading( + accountId: string, + workspaceId: string, + overrides: Partial = {}, +): SyncDigestReading { + return { accountId, workspaceId, digest: digest(overrides) }; +} + +function project(projectId: string): TeamProject { + return { + projectId, + ownerMemberId: 'member-1', + sharedAt: '2026-07-20T00:00:00Z', + }; +} + +describe('createPersistentSyncCache', () => { + let tempDir: string; + let store: CollabSyncSnapshotStore; + + beforeEach(() => { + tempDir = mkdtempSync(path.join(os.tmpdir(), 'od-sync-snapshot-')); + // Real schema through the daemon's own migration path, so the account id is + // genuinely part of the primary key rather than a fake in-memory map. + store = createCollabSyncSnapshotStore(openDatabase(tempDir, { dataDir: tempDir })); + }); + + afterEach(() => { + closeDatabase(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + /** A cache over a counting fetcher, with a mutable digest reading. */ + function harness(options: { + read: () => SyncDigestReading | null | Promise; + list?: () => TeamProject[]; + shouldCache?: () => boolean | Promise; + }) { + const calls: number[] = []; + let fetchCount = 0; + const cache = createPersistentSyncCache({ + face: 'catalog', + fetch: async () => { + fetchCount += 1; + calls.push(fetchCount); + return options.list ? options.list() : [project('p1')]; + }, + readDigest: async () => options.read(), + store, + parseSnapshot: parseTeamProjectSnapshot, + ...(options.shouldCache ? { shouldCache: options.shouldCache } : {}), + }); + return { cache, fetchCount: () => fetchCount }; + } + + it('cold start has no snapshot, so the first read is a real fetch', async () => { + const { cache, fetchCount } = harness({ read: () => reading('user-1', 'ws-1') }); + + await expect(cache()).resolves.toEqual([project('p1')]); + + expect(fetchCount()).toBe(1); + }); + + it('reuses the snapshot without a real fetch when the token is unchanged', async () => { + const { cache, fetchCount } = harness({ read: () => reading('user-1', 'ws-1') }); + + await cache(); + expect(fetchCount()).toBe(1); + + // Same token on the second read: the stored snapshot stands in and the + // expensive lister is never called again. + await expect(cache()).resolves.toEqual([project('p1')]); + await expect(cache()).resolves.toEqual([project('p1')]); + expect(fetchCount()).toBe(1); + }); + + it('re-fetches and writes back when the cloud token moves', async () => { + let token = 'token-a'; + let listed = [project('p1')]; + const { cache, fetchCount } = harness({ + read: () => reading('user-1', 'ws-1', { catalogToken: token }), + list: () => listed, + }); + + await cache(); + expect(fetchCount()).toBe(1); + + // A teammate shared a project: B's token moves (its `count(*)` term means + // even a hard delete moves it), so reuse is off. + token = 'token-b'; + listed = [project('p1'), project('p2')]; + await expect(cache()).resolves.toEqual([project('p1'), project('p2')]); + expect(fetchCount()).toBe(2); + + // The new payload was written back under the new token, so the next read + // reuses it rather than fetching a third time. + await expect(cache()).resolves.toEqual([project('p1'), project('p2')]); + expect(fetchCount()).toBe(2); + }); + + it('never serves one account the snapshot another account cached', async () => { + let accountId = 'user-1'; + let listed = [project('owned-by-user-1')]; + const { cache, fetchCount } = harness({ + // Same workspace id and the SAME digest token for both users — only the + // account differs. If the account were not part of the key, user-2 would + // be handed user-1's catalog here. + read: () => reading(accountId, 'ws-shared'), + list: () => listed, + }); + + await expect(cache()).resolves.toEqual([project('owned-by-user-1')]); + expect(fetchCount()).toBe(1); + + accountId = 'user-2'; + listed = [project('owned-by-user-2')]; + await expect(cache()).resolves.toEqual([project('owned-by-user-2')]); + expect(fetchCount()).toBe(2); + + // And switching back does not resurrect the wrong one either. + accountId = 'user-1'; + listed = [project('should-not-be-reached')]; + await expect(cache()).resolves.toEqual([project('owned-by-user-1')]); + expect(fetchCount()).toBe(2); + }); + + it('keys snapshots per workspace as well as per account', async () => { + let workspaceId = 'ws-1'; + let listed = [project('in-ws-1')]; + const { cache, fetchCount } = harness({ + read: () => reading('user-1', workspaceId), + list: () => listed, + }); + + await cache(); + workspaceId = 'ws-2'; + listed = [project('in-ws-2')]; + + await expect(cache()).resolves.toEqual([project('in-ws-2')]); + expect(fetchCount()).toBe(2); + }); + + it('falls back to a real fetch when the digest is unavailable', async () => { + let available = true; + const { cache, fetchCount } = harness({ + read: () => (available ? reading('user-1', 'ws-1') : null), + }); + + await cache(); + expect(fetchCount()).toBe(1); + + // Offline / signed out / non-vela source: no token means nothing to compare + // against, which is a miss — never an optimistic reuse. + available = false; + await expect(cache()).resolves.toEqual([project('p1')]); + expect(fetchCount()).toBe(2); + }); + + it('treats an empty face token as no token at all', async () => { + const { cache, fetchCount } = harness({ + read: () => reading('user-1', 'ws-1', { catalogToken: '' }), + }); + + await cache(); + await cache(); + + // Nothing was stored (there was no token to pair a snapshot with), so both + // reads went to the real lister. + expect(fetchCount()).toBe(2); + expect(store.read({ face: 'catalog', accountId: 'user-1', workspaceId: 'ws-1' })).toBeNull(); + }); + + it('degrades to a real fetch when the stored snapshot is corrupt', async () => { + const key = { face: 'catalog' as const, accountId: 'user-1', workspaceId: 'ws-1' }; + store.write(key, { token: digest().catalogToken, snapshotJson: '{not json' }); + + const { cache, fetchCount } = harness({ read: () => reading('user-1', 'ws-1') }); + + await expect(cache()).resolves.toEqual([project('p1')]); + expect(fetchCount()).toBe(1); + + // The unusable row was retired and replaced by the fresh fetch, so the next + // read is a clean hit instead of repeating the corruption dance. + await cache(); + expect(fetchCount()).toBe(1); + }); + + it('degrades to a real fetch when the stored snapshot has the wrong shape', async () => { + const key = { face: 'catalog' as const, accountId: 'user-1', workspaceId: 'ws-1' }; + // Valid JSON, wrong contract — e.g. a payload written by an older schema. + store.write(key, { + token: digest().catalogToken, + snapshotJson: JSON.stringify([{ projectId: 'p1' }]), + }); + + const { cache, fetchCount } = harness({ read: () => reading('user-1', 'ws-1') }); + + await expect(cache()).resolves.toEqual([project('p1')]); + expect(fetchCount()).toBe(1); + }); + + it('keeps a usable snapshot when the real fetch fails', async () => { + let token = 'token-a'; + let fail = false; + let fetchCount = 0; + const cache = createPersistentSyncCache({ + face: 'catalog', + fetch: async () => { + fetchCount += 1; + if (fail) throw new Error('catalog unreachable'); + return [project('p1')]; + }, + readDigest: async () => reading('user-1', 'ws-1', { catalogToken: token }), + store, + parseSnapshot: parseTeamProjectSnapshot, + }); + + await cache(); + expect(fetchCount).toBe(1); + + // Token moved AND the network is down: the failure propagates unchanged + // (the caller's existing error handling owns it) and must not take the + // stored snapshot down with it. + token = 'token-b'; + fail = true; + await expect(cache()).rejects.toThrow('catalog unreachable'); + expect( + store.read({ face: 'catalog', accountId: 'user-1', workspaceId: 'ws-1' }), + ).toMatchObject({ token: 'token-a' }); + + // Once the token comes back to what the snapshot was taken at, it is served + // again without a fetch — the outage cost nothing permanent. + token = 'token-a'; + fail = false; + await expect(cache()).resolves.toEqual([project('p1')]); + expect(fetchCount).toBe(2); + }); + + it('bypasses itself entirely until the workspace context is authoritative', async () => { + let ready = false; + let listed: TeamProject[] = []; + const { cache, fetchCount } = harness({ + read: () => reading('user-1', 'ws-1'), + list: () => listed, + shouldCache: () => ready, + }); + + // Startup: the lister answers `[]` because there is no team identity yet. + // That empty must not be snapshotted, or it would pin an empty catalog for + // as long as the upstream token stayed put. + await expect(cache()).resolves.toEqual([]); + expect(store.read({ face: 'catalog', accountId: 'user-1', workspaceId: 'ws-1' })).toBeNull(); + + ready = true; + listed = [project('p1')]; + await expect(cache()).resolves.toEqual([project('p1')]); + expect(fetchCount()).toBe(2); + }); + + it('invalidate() drops the persisted row so a restart cannot resurrect it', async () => { + const { cache, fetchCount } = harness({ read: () => reading('user-1', 'ws-1') }); + + await cache(); + expect(store.read({ face: 'catalog', accountId: 'user-1', workspaceId: 'ws-1' })).not.toBeNull(); + + cache.invalidate(); + expect(store.read({ face: 'catalog', accountId: 'user-1', workspaceId: 'ws-1' })).toBeNull(); + + await cache(); + expect(fetchCount()).toBe(2); + }); + + it('does not reuse a snapshot invalidated while the digest was in flight', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let held = false; + let fetchCount = 0; + const cache = createPersistentSyncCache({ + face: 'catalog', + fetch: async () => { + fetchCount += 1; + return [project('p1')]; + }, + readDigest: async () => { + if (held) await gate; + return reading('user-1', 'ws-1'); + }, + store, + parseSnapshot: parseTeamProjectSnapshot, + }); + + await cache(); + expect(fetchCount).toBe(1); + + held = true; + const pending = cache(); + // A share/unshare lands while the digest round-trip is still open. The + // token it returns is the pre-change one, so equality alone would wrongly + // green-light the stale snapshot. + cache.invalidate(); + release(); + await pending; + + expect(fetchCount).toBe(2); + }); + + it('does not persist a snapshot invalidated while the real fetch was in flight', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let held = false; + let fetchCount = 0; + const cache = createPersistentSyncCache({ + face: 'catalog', + fetch: async () => { + fetchCount += 1; + if (held) await gate; + return [project('stale-before-the-change')]; + }, + readDigest: async () => reading('user-1', 'ws-1'), + store, + parseSnapshot: parseTeamProjectSnapshot, + }); + + held = true; + const pending = cache(); + // The share/unshare lands after the fetch was issued, so the value in + // flight predates the change. Writing it back under the pre-change token + // reopens exactly the window invalidate() exists to close: the cloud digest + // has not recomputed yet, so the next read would find a matching token + // sitting on top of a snapshot that is already wrong. + cache.invalidate(); + release(); + await pending; + + expect(store.read({ face: 'catalog', accountId: 'user-1', workspaceId: 'ws-1' })).toBeNull(); + + await cache(); + expect(fetchCount).toBe(2); + }); + + it('isolates the two faces from each other', async () => { + const membersCache = createPersistentSyncCache({ + face: 'members', + fetch: async () => [project('members-face')], + readDigest: async () => reading('user-1', 'ws-1'), + store, + parseSnapshot: parseTeamProjectSnapshot, + }); + const { cache: catalogCache } = harness({ read: () => reading('user-1', 'ws-1') }); + + await catalogCache(); + await membersCache(); + + expect(store.read({ face: 'catalog', accountId: 'user-1', workspaceId: 'ws-1' })).toMatchObject({ + snapshotJson: JSON.stringify([project('p1')]), + }); + expect(store.read({ face: 'members', accountId: 'user-1', workspaceId: 'ws-1' })).toMatchObject({ + snapshotJson: JSON.stringify([project('members-face')]), + }); + }); +}); diff --git a/apps/daemon/tests/collab/proactive-content-pull.test.ts b/apps/daemon/tests/collab/proactive-content-pull.test.ts new file mode 100644 index 00000000000..108f234b307 --- /dev/null +++ b/apps/daemon/tests/collab/proactive-content-pull.test.ts @@ -0,0 +1,3362 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + activeTeamWorkspaceIdentity, + createProactiveContentPull, + isAuthorizedProactivePullInvocation, + isFreshProactivePullAuthorizationWitness, + type ProactiveContentPull, + type ProactiveContentPullDeps, + type ProactiveContentPullTarget, +} from '../../src/collab/proactive-content-pull.js'; + +// Hub push-channel consumer for 'project-content-changed' (recvqmKQRiIlYf): +// when a teammate publishes a new version of a shared project, the member's +// daemon pulls the content proactively — no open tab required — instead of +// leaving freshness to the member web's ~5s status polling. These tests pin +// the guard boundary: the pull must NEVER touch a project this daemon owns +// (the owner's local copy is the single writer), may bootstrap a teammate's +// newly-shared project from a workspace-scoped event before a local binding +// exists, must dedupe repeated/racing events, and must degrade silently on +// failure so the web polling fallback stays authoritative. + +type Deps = ProactiveContentPullDeps; + +function makeDeps(overrides: Partial = {}): Deps & { + pullCalls: string[]; +} { + const pullCalls: string[] = []; + const deps: Deps & { pullCalls: string[] } = { + pullCalls, + getLocalBinding: () => ({ workspaceId: 'ws-1', visibility: 'team' }), + getWorkspaceIdentity: async () => ({ + workspaceId: 'ws-1', + resourceTeamId: 'team-1', + workspaceMemberId: 'wm-member', + }), + resolveSharedProjectOwner: async () => 'wm-owner', + pullSharedProject: async (target) => { + pullCalls.push(target.projectId); + return { status: 'pulled', version: 3 }; + }, + ...overrides, + }; + return deps; +} + +const baseEvent = { projectId: 'proj-1', workspaceId: 'ws-1', version: 3 }; + +function makeRetryScheduler() { + type Task = { + callback: () => void | Promise; + delayMs: number; + handle: { id: number; unref: ReturnType }; + }; + let nextId = 1; + const tasks = new Map(); + const delays: number[] = []; + const cleared: number[] = []; + const scheduler = { + setTimeout(callback: () => void | Promise, delayMs: number) { + const handle = { id: nextId, unref: vi.fn() }; + nextId += 1; + tasks.set(handle.id, { callback, delayMs, handle }); + delays.push(delayMs); + return handle; + }, + clearTimeout(handle: unknown) { + const id = (handle as { id: number }).id; + cleared.push(id); + tasks.delete(id); + }, + }; + return { + scheduler, + delays, + cleared, + tasks, + async runNext() { + const task = [...tasks.values()].sort((a, b) => a.handle.id - b.handle.id)[0]; + if (!task) throw new Error('expected a scheduled retry'); + tasks.delete(task.handle.id); + await task.callback(); + }, + async runDelay(delayMs: number) { + const task = [...tasks.values()] + .sort((a, b) => a.handle.id - b.handle.id) + .find((candidate) => candidate.delayMs === delayMs); + if (!task) throw new Error(`expected a scheduled ${delayMs}ms retry`); + tasks.delete(task.handle.id); + await task.callback(); + }, + }; +} + +describe('proactive content pull (hub project-content-changed consumer)', () => { + it.each([ + ['billing_past_due'], + ['locked'], + ['deleting'], + ['deleted'], + ])('rejects %s workspace lifecycle before pull authorization', (lifecycleState) => { + expect(activeTeamWorkspaceIdentity({ + workspaceId: 'ws-1', + teamId: 'team-1', + workspaceMemberId: 'wm-member', + workspaceType: 'team', + memberStatus: 'active', + lifecycleState, + })).toBeNull(); + }); + + it('rejects missing resource-team or member identities before authorization', () => { + expect(activeTeamWorkspaceIdentity({ + workspaceId: '', + teamId: '', + workspaceMemberId: '', + workspaceType: 'team', + memberStatus: 'active', + lifecycleState: 'active', + })).toBeNull(); + }); + + it('pulls a locally-bound team project owned by a teammate', async () => { + const deps = makeDeps(); + const pull = createProactiveContentPull(deps); + await pull.handleContentChanged(baseEvent); + expect(deps.pullCalls).toEqual(['proj-1']); + }); + + it('resolves identity from the event Workspace instead of mutable global selection', async () => { + const globallySelectedWorkspaceId = 'ws-other'; + const getWorkspaceIdentity = vi.fn(async (workspaceId: string) => + workspaceId === 'ws-1' + ? { + workspaceId, + resourceTeamId: 'team-1', + workspaceMemberId: 'wm-member', + } + : null, + ); + const deps = makeDeps({ getWorkspaceIdentity }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged({ + ...baseEvent, + workspaceId: 'ws-1', + }); + + expect(globallySelectedWorkspaceId).toBe('ws-other'); + expect(getWorkspaceIdentity).toHaveBeenCalledWith('ws-1'); + expect(deps.pullCalls).toEqual(['proj-1']); + }); + + it('issues the pull target a fresh witness bound to the guarded event version', async () => { + const receivedTargets: ProactiveContentPullTarget[] = []; + const deps = makeDeps({ + pullSharedProject: async (target) => { + receivedTargets.push(target); + return { status: 'pulled', version: 3 }; + }, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged(baseEvent); + + const receivedTarget = receivedTargets[0]; + expect(receivedTarget).toBeDefined(); + expect(receivedTarget!.authorizationWitness).toMatchObject({ + kind: 'proactive-content-pull', + projectId: 'proj-1', + workspaceId: 'ws-1', + resourceTeamId: 'team-1', + viewerMemberId: 'wm-member', + ownerMemberId: 'wm-owner', + version: 3, + }); + expect(isFreshProactivePullAuthorizationWitness( + receivedTarget!.authorizationWitness, + receivedTarget!, + 3, + )).toBe(true); + }); + + it('does not re-sign a catalog owner hint as a fresh authorization witness', async () => { + const receivedTargets: ProactiveContentPullTarget[] = []; + const deps = makeDeps({ + getLocalBinding: () => null, + listSharedProjects: async () => [ + { projectId: 'proj-1', ownerMemberId: 'wm-owner' }, + ], + hasMaterializedProject: () => false, + publishedHead: async () => 3, + pullSharedProject: async (target) => { + receivedTargets.push(target); + return { status: 'pulled', version: 3 }; + }, + }); + const pull = createProactiveContentPull(deps); + + await pull.materializeMissingProjects('ws-1', 'proj-1'); + + expect(receivedTargets[0]).toBeDefined(); + expect(receivedTargets[0]!.authorizationWitness).toBeUndefined(); + }); + + it('reports queue, invoke, and completion timing for a profiled hub event', async () => { + const onTiming = vi.fn(); + const deps = makeDeps({ onTiming }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged({ + ...baseEvent, + profileReceivedAtMs: 100, + }); + + expect(onTiming.mock.calls.map(([event]) => event.phase)).toEqual([ + 'queued', + 'guard-started', + 'guard-completed', + 'invoke', + 'completed', + ]); + expect(onTiming).toHaveBeenCalledWith(expect.objectContaining({ + phase: 'guard-completed', + projectId: 'proj-1', + status: 'target', + })); + expect(onTiming).toHaveBeenCalledWith(expect.objectContaining({ + phase: 'invoke', + projectId: 'proj-1', + version: 3, + receivedAtMs: 100, + })); + }); + + it('starts the independent identity and owner guards concurrently', async () => { + let releaseIdentity!: () => void; + let identityStarted!: () => void; + const identityGate = new Promise((resolve) => { + releaseIdentity = resolve; + }); + const identityStart = new Promise((resolve) => { + identityStarted = resolve; + }); + const resolveSharedProjectOwner = vi.fn(async () => 'wm-owner'); + const deps = makeDeps({ + getWorkspaceIdentity: async () => { + identityStarted(); + await identityGate; + return { + workspaceId: 'ws-1', + resourceTeamId: 'team-1', + workspaceMemberId: 'wm-member', + }; + }, + resolveSharedProjectOwner, + }); + const pull = createProactiveContentPull(deps); + + const pending = pull.handleContentChanged(baseEvent); + await identityStart; + await Promise.resolve(); + const ownerStartedBeforeIdentityFinished = + resolveSharedProjectOwner.mock.calls.length; + releaseIdentity(); + await pending; + + expect(ownerStartedBeforeIdentityFinished).toBe(1); + expect(deps.pullCalls).toEqual(['proj-1']); + }); + + it('skips an event without a projectId', async () => { + const deps = makeDeps(); + const pull = createProactiveContentPull(deps); + await pull.handleContentChanged({ workspaceId: 'ws-1', version: 3 }); + expect(deps.pullCalls).toEqual([]); + }); + + it('pulls a newly-shared teammate project before this daemon has a local binding', async () => { + const onPulled = vi.fn(); + const deps = makeDeps({ getLocalBinding: () => null, onPulled }); + const pull = createProactiveContentPull(deps); + await pull.handleContentChanged(baseEvent); + expect(deps.pullCalls).toEqual(['proj-1']); + expect(onPulled).toHaveBeenCalledWith(expect.objectContaining({ + projectId: 'proj-1', + workspaceId: 'ws-1', + resourceTeamId: 'team-1', + viewerMemberId: 'wm-member', + ownerMemberId: 'wm-owner', + }), 3); + }); + + it('skips an unbound project when the event carries no workspace scope', async () => { + const deps = makeDeps({ getLocalBinding: () => null }); + const pull = createProactiveContentPull(deps); + await pull.handleContentChanged({ projectId: 'proj-1', version: 3 }); + expect(deps.pullCalls).toEqual([]); + }); + + it('skips an unbound project whose event workspace is not the active team', async () => { + const deps = makeDeps({ getLocalBinding: () => null }); + const pull = createProactiveContentPull(deps); + await pull.handleContentChanged({ ...baseEvent, workspaceId: 'ws-other' }); + expect(deps.pullCalls).toEqual([]); + }); + + it('skips a project whose local binding is personal, not team', async () => { + const deps = makeDeps({ + getLocalBinding: () => ({ workspaceId: 'ws-1', visibility: 'personal' }), + }); + const pull = createProactiveContentPull(deps); + await pull.handleContentChanged(baseEvent); + expect(deps.pullCalls).toEqual([]); + }); + + it('skips an event whose workspace does not match the local binding', async () => { + const deps = makeDeps(); + const pull = createProactiveContentPull(deps); + await pull.handleContentChanged({ ...baseEvent, workspaceId: 'ws-other' }); + expect(deps.pullCalls).toEqual([]); + }); + + it('skips when the active identity is in a different workspace than the binding', async () => { + const deps = makeDeps({ + getWorkspaceIdentity: async () => ({ + workspaceId: 'ws-other', + resourceTeamId: 'team-other', + workspaceMemberId: 'wm-member', + }), + }); + const pull = createProactiveContentPull(deps); + // Event carries no workspaceId: the binding/identity cross-check alone + // must still refuse to pull under a foreign-workspace principal. + await pull.handleContentChanged({ projectId: 'proj-1', version: 3 }); + expect(deps.pullCalls).toEqual([]); + }); + + it('skips when there is no team workspace identity (signed out / personal)', async () => { + const deps = makeDeps({ getWorkspaceIdentity: async () => null }); + const pull = createProactiveContentPull(deps); + await pull.handleContentChanged(baseEvent); + expect(deps.pullCalls).toEqual([]); + }); + + it('fails closed when the owner cannot be resolved', async () => { + const deps = makeDeps({ resolveSharedProjectOwner: async () => null }); + const pull = createProactiveContentPull(deps); + await pull.handleContentChanged(baseEvent); + expect(deps.pullCalls).toEqual([]); + }); + + it('fails closed when the owner lookup throws', async () => { + const deps = makeDeps({ + resolveSharedProjectOwner: async () => { + throw new Error('hub unavailable'); + }, + }); + const pull = createProactiveContentPull(deps); + await pull.handleContentChanged(baseEvent); + expect(deps.pullCalls).toEqual([]); + }); + + it('never pulls a project this daemon member owns (single-writer protection)', async () => { + const deps = makeDeps({ resolveSharedProjectOwner: async () => 'wm-member' }); + const pull = createProactiveContentPull(deps); + await pull.handleContentChanged(baseEvent); + expect(deps.pullCalls).toEqual([]); + }); + + it('dedupes a repeated event for an already-pulled version, and pulls again for a newer one', async () => { + const deps = makeDeps(); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged({ ...baseEvent, version: 3 }); + expect(deps.pullCalls).toEqual(['proj-1']); + + // Duplicate (and older) events are no-ops once version 3 materialized. + await pull.handleContentChanged({ ...baseEvent, version: 3 }); + await pull.handleContentChanged({ ...baseEvent, version: 2 }); + expect(deps.pullCalls).toEqual(['proj-1']); + + // A genuinely newer head pulls again. + deps.pullSharedProject = async (target) => { + deps.pullCalls.push(target.projectId); + return { status: 'pulled', version: 4 }; + }; + await pull.handleContentChanged({ ...baseEvent, version: 4 }); + expect(deps.pullCalls).toEqual(['proj-1', 'proj-1']); + }); + + it('keeps the version cursor independent per project', async () => { + const deps = makeDeps(); + const pull = createProactiveContentPull(deps); + await pull.handleContentChanged({ ...baseEvent, version: 3 }); + await pull.handleContentChanged({ ...baseEvent, projectId: 'proj-2', version: 3 }); + expect(deps.pullCalls).toEqual(['proj-1', 'proj-2']); + }); + + it('settles only the exact covered intent when another lane durably materializes', async () => { + const retry = makeRetryScheduler(); + const onPulled = vi.fn(); + const deps = makeDeps({ + scheduler: retry.scheduler, + onPulled, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + return { status: 'register_failed' }; + }, + }); + const pull = createProactiveContentPull(deps); + const target: ProactiveContentPullTarget = { + projectId: 'proj-1', + workspaceId: 'ws-1', + resourceTeamId: 'team-1', + viewerMemberId: 'wm-member', + ownerMemberId: 'wm-owner', + }; + + await pull.handleContentChanged({ ...baseEvent, version: 5 }); + expect(retry.tasks.size).toBe(1); + + await pull.observeMaterialized( + { ...target, workspaceId: 'ws-other' }, + 5, + ); + await pull.observeMaterialized(target, 4); + expect(retry.tasks.size).toBe(1); + + await pull.observeMaterialized(target, 5); + expect(retry.tasks.size).toBe(0); + + deps.pullSharedProject = async (nextTarget) => { + deps.pullCalls.push(nextTarget.projectId); + return { status: 'pulled', version: 6 }; + }; + await pull.handleContentChanged({ ...baseEvent, version: 6 }); + + expect(deps.pullCalls).toEqual(['proj-1', 'proj-1']); + expect(onPulled).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 'proj-1', + workspaceId: 'ws-1', + }), + 5, + ); + }); + + it('coalesces events that race an in-flight pull for the same head', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const deps = makeDeps({ + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + await gate; + return { status: 'pulled', version: 3 }; + }, + }); + const pull = createProactiveContentPull(deps); + + const first = pull.handleContentChanged(baseEvent); + const second = pull.handleContentChanged(baseEvent); + release(); + await Promise.all([first, second]); + + expect(deps.pullCalls).toEqual(['proj-1']); + }); + + it('serializes v2 behind an in-flight v1 pull, then materializes v2', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let call = 0; + let active = 0; + let maxActive = 0; + const deps = makeDeps({ + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + call += 1; + active += 1; + maxActive = Math.max(maxActive, active); + try { + if (call === 1) { + await gate; + return { status: 'pulled', version: 1 }; + } + return { status: 'pulled', version: 2 }; + } finally { + active -= 1; + } + }, + }); + const pull = createProactiveContentPull(deps); + + const first = pull.handleContentChanged({ ...baseEvent, version: 1 }); + await vi.waitFor(() => expect(deps.pullCalls).toEqual(['proj-1'])); + const second = pull.handleContentChanged({ ...baseEvent, version: 2 }); + expect(maxActive).toBe(1); + release(); + await Promise.all([first, second]); + + // The v2 event waited out the v1 pull, saw the cursor still behind, and + // pulled once more — exactly one trailing pull, not a loop. + expect(deps.pullCalls).toEqual(['proj-1', 'proj-1']); + expect(maxActive).toBe(1); + }); + + it('degrades silently on pull failure and leaves the cursor behind so a retry is allowed', async () => { + const retry = makeRetryScheduler(); + const onError = vi.fn(); + let fail = true; + const deps = makeDeps({ + onError, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + if (fail) throw new Error('vela transport down'); + return { status: 'pulled', version: 3 }; + }, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await expect(pull.handleContentChanged(baseEvent)).resolves.toBeUndefined(); + expect(onError).toHaveBeenCalledTimes(1); + + // Cursor did not advance on failure: the scheduled same-version retry is + // still allowed, without a duplicate event resetting its backoff. + fail = false; + await retry.runNext(); + expect(deps.pullCalls).toEqual(['proj-1', 'proj-1']); + }); + + it('settles lifecycle observation only when no retry remains', async () => { + const retry = makeRetryScheduler(); + const onEventSettled = vi.fn(); + let fail = true; + const deps = makeDeps({ + onEventSettled, + scheduler: retry.scheduler, + random: () => 1, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + if (fail) throw new Error('temporary transport failure'); + return { status: 'pulled', version: 3 }; + }, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged(baseEvent); + expect(onEventSettled).not.toHaveBeenCalled(); + + fail = false; + await retry.runNext(); + // Retry completion is observed through `onPulled`; the original-event + // terminal callback is intentionally only for its synchronous decision. + expect(onEventSettled).not.toHaveBeenCalled(); + + await pull.handleContentChanged(baseEvent); + expect(onEventSettled).toHaveBeenCalledWith(baseEvent); + }); + + it('lets a v2 catch-up retry after a failed v1 pull without advancing the cursor', async () => { + const onError = vi.fn(); + let attempt = 0; + const deps = makeDeps({ + onError, + getLocalBinding: () => null, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + attempt += 1; + if (attempt === 1) throw new Error('v1 transport failed'); + return { status: 'pulled', version: 2 }; + }, + listSharedProjects: async () => [ + { projectId: 'proj-1', ownerMemberId: 'wm-owner' }, + ], + publishedHead: async () => 2, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged({ ...baseEvent, version: 1 }); + await pull.catchUpPublishedHeads('ws-1'); + await pull.handleContentChanged({ ...baseEvent, version: 2 }); + + expect(onError).toHaveBeenCalledTimes(1); + expect(deps.pullCalls).toEqual(['proj-1', 'proj-1']); + }); + + it('does not advance the cursor on a revoked outcome', async () => { + const onPulled = vi.fn(); + let revoked = true; + const deps = makeDeps({ + onPulled, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + return revoked ? { status: 'revoked' } : { status: 'pulled', version: 3 }; + }, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged(baseEvent); + // A later re-share of the same head must be able to pull again. + revoked = false; + await pull.handleContentChanged(baseEvent); + expect(deps.pullCalls).toEqual(['proj-1', 'proj-1']); + expect(onPulled).toHaveBeenCalledTimes(1); + }); + + it('does not emit ready or advance the cursor when durable registration fails', async () => { + const onPulled = vi.fn(); + const retry = makeRetryScheduler(); + let registerFailed = true; + const deps = makeDeps({ + onPulled, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + return registerFailed + ? { status: 'register_failed' } + : { status: 'pulled', version: 3 }; + }, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged(baseEvent); + expect(onPulled).not.toHaveBeenCalled(); + expect(retry.tasks.size).toBe(1); + + registerFailed = false; + await retry.runNext(); + expect(deps.pullCalls).toEqual(['proj-1', 'proj-1']); + expect(onPulled).toHaveBeenCalledTimes(1); + expect(retry.tasks.size).toBe(0); + }); + + it('never rejects, even when an identity read throws', async () => { + const onError = vi.fn(); + const deps = makeDeps({ + onError, + getWorkspaceIdentity: async () => { + throw new Error('context provider crashed'); + }, + }); + const pull = createProactiveContentPull(deps); + await expect(pull.handleContentChanged(baseEvent)).resolves.toBeUndefined(); + expect(deps.pullCalls).toEqual([]); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('catches up a published head that already existed before the first hub connection', async () => { + const deps = makeDeps({ getLocalBinding: () => null }); + const listSharedProjects = vi.fn(async () => [ + { projectId: 'proj-1', ownerMemberId: 'wm-owner' }, + ]); + const publishedHead = vi.fn(async () => 3); + Object.assign(deps, { listSharedProjects, publishedHead }); + const pull = createProactiveContentPull(deps); + + await pull.catchUpPublishedHeads('ws-1'); + + expect(listSharedProjects).toHaveBeenCalledTimes(1); + expect(publishedHead).toHaveBeenCalledTimes(1); + expect(deps.pullCalls).toEqual(['proj-1']); + }); + + it('dedupes an unchanged reconnect sweep, then pulls once when the missed head advanced', async () => { + let head = 3; + const deps = makeDeps(); + const listSharedProjects = vi.fn(async () => [ + { projectId: 'proj-1', ownerMemberId: 'wm-owner' }, + ]); + const publishedHead = vi.fn(async () => head); + Object.assign(deps, { listSharedProjects, publishedHead }); + const pull = createProactiveContentPull(deps); + + await pull.catchUpPublishedHeads('ws-1'); + await pull.catchUpPublishedHeads('ws-1'); + expect(deps.pullCalls).toEqual(['proj-1']); + + // The v4 signal was missed while disconnected. The reconnect sweep sees + // the authoritative head and routes it through the same version cursor. + head = 4; + deps.pullSharedProject = async (target) => { + deps.pullCalls.push(target.projectId); + return { status: 'pulled', version: 4 }; + }; + await pull.catchUpPublishedHeads('ws-1'); + + expect(deps.pullCalls).toEqual(['proj-1', 'proj-1']); + expect(listSharedProjects).toHaveBeenCalledTimes(3); + }); + + it('materializes a placeholder row whose project content is still missing when the healthy-stream floor observes it', async () => { + let materialized = false; + const deps = makeDeps({ + getLocalBinding: () => null, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + materialized = true; + return { status: 'pulled', version: 3 }; + }, + }); + const listSharedProjects = vi.fn(async () => [ + { projectId: 'proj-1', ownerMemberId: 'wm-owner' }, + ]); + const publishedHead = vi.fn(async () => 3); + Object.assign(deps, { + listSharedProjects, + publishedHead, + // getLocalBinding above proves the placeholder DB row/team binding + // exists; the materialization probe must look through that shell. + hasMaterializedProject: () => materialized, + }); + const pull = createProactiveContentPull(deps); + + await pull.materializeMissingProjects('ws-1'); + await pull.materializeMissingProjects('ws-1'); + + expect(listSharedProjects).toHaveBeenCalledTimes(2); + expect(publishedHead).toHaveBeenCalledTimes(1); + expect(deps.pullCalls).toEqual(['proj-1']); + }); + + it('does not read a head for a project owned by this daemon member', async () => { + const deps = makeDeps(); + const publishedHead = vi.fn(async () => 3); + Object.assign(deps, { + listSharedProjects: async () => [ + { projectId: 'mine', ownerMemberId: 'wm-member' }, + ], + publishedHead, + }); + const pull = createProactiveContentPull(deps); + + await pull.catchUpPublishedHeads('ws-1'); + + expect(publishedHead).not.toHaveBeenCalled(); + expect(deps.pullCalls).toEqual([]); + }); + + it('retries a catch-up after the active team identity was temporarily unavailable', async () => { + let identityAvailable = false; + const deps = makeDeps({ + getWorkspaceIdentity: async () => identityAvailable + ? { + workspaceId: 'ws-1', + resourceTeamId: 'team-1', + workspaceMemberId: 'wm-member', + } + : null, + }); + const listSharedProjects = vi.fn(async () => [ + { projectId: 'proj-1', ownerMemberId: 'wm-owner' }, + ]); + Object.assign(deps, { + listSharedProjects, + publishedHead: async () => 3, + }); + const pull = createProactiveContentPull(deps); + + await pull.catchUpPublishedHeads('ws-1'); + identityAvailable = true; + await pull.catchUpPublishedHeads('ws-1'); + + expect(listSharedProjects).toHaveBeenCalledTimes(1); + expect(deps.pullCalls).toEqual(['proj-1']); + }); + + it('isolates one published-head failure and continues the sequential sweep', async () => { + const onError = vi.fn(); + let active = 0; + let maxActive = 0; + const deps = makeDeps({ onError }); + Object.assign(deps, { + listSharedProjects: async () => [ + { projectId: 'broken', ownerMemberId: 'wm-owner' }, + { projectId: 'healthy', ownerMemberId: 'wm-owner' }, + { projectId: 'healthy-2', ownerMemberId: 'wm-owner' }, + ], + publishedHead: async (target: { projectId: string }) => { + active += 1; + maxActive = Math.max(maxActive, active); + await Promise.resolve(); + active -= 1; + if (target.projectId === 'broken') throw new Error('head unavailable'); + return 3; + }, + }); + const pull = createProactiveContentPull(deps); + + await pull.catchUpPublishedHeads('ws-1'); + + expect(onError).toHaveBeenCalledTimes(1); + expect(maxActive).toBe(1); + expect(deps.pullCalls).toEqual(['healthy', 'healthy-2']); + }); + + it('coalesces a live event with the same catch-up head onto one pull', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const deps = makeDeps({ + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + await gate; + return { status: 'pulled', version: 3 }; + }, + }); + Object.assign(deps, { + listSharedProjects: async () => [ + { projectId: 'proj-1', ownerMemberId: 'wm-owner' }, + ], + publishedHead: async () => 3, + }); + const pull = createProactiveContentPull(deps); + + const catchUp = pull.catchUpPublishedHeads('ws-1'); + await vi.waitFor(() => expect(deps.pullCalls).toEqual(['proj-1'])); + const liveEvent = pull.handleContentChanged(baseEvent); + release(); + await Promise.all([catchUp, liveEvent]); + + expect(deps.pullCalls).toEqual(['proj-1']); + }); + + it('lets a missing-project sweep overtake a full sweep blocked on historical work', async () => { + let releaseHistoricalHead!: () => void; + const historicalHeadGate = new Promise((resolve) => { + releaseHistoricalHead = resolve; + }); + let catalogReads = 0; + let signalNewProjectPulled!: () => void; + const newProjectPulled = new Promise((resolve) => { + signalNewProjectPulled = resolve; + }); + const publishedHead = vi.fn(async (target: { projectId: string }) => { + if (target.projectId === 'historical') await historicalHeadGate; + return 3; + }); + const deps = makeDeps({ + getLocalBinding: () => null, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + if (target.projectId === 'new-project') signalNewProjectPulled(); + return { status: 'pulled', version: 3 }; + }, + listSharedProjects: async () => { + catalogReads += 1; + return catalogReads === 1 + ? [{ projectId: 'historical', ownerMemberId: 'wm-owner' }] + : [{ projectId: 'new-project', ownerMemberId: 'wm-owner' }]; + }, + hasMaterializedProject: (projectId) => projectId !== 'new-project', + publishedHead, + }); + const pull = createProactiveContentPull(deps); + + const full = pull.catchUpPublishedHeads('ws-1'); + await vi.waitFor(() => { + expect(publishedHead).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'historical' }), + ); + }); + const missing = pull.materializeMissingProjects('ws-1'); + + try { + await newProjectPulled; + expect(deps.pullCalls).toContain('new-project'); + expect(deps.pullCalls).not.toContain('historical'); + } finally { + releaseHistoricalHead(); + await Promise.all([full, missing]); + } + }); + + it('reuses a full-sweep pull when missing-only races the same project', async () => { + let releasePull!: () => void; + const pullGate = new Promise((resolve) => { + releasePull = resolve; + }); + let materialized = false; + let materializationProbes = 0; + const deps = makeDeps({ + getLocalBinding: () => null, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + await pullGate; + materialized = true; + return { status: 'pulled', version: 3 }; + }, + listSharedProjects: async () => [ + { projectId: 'proj-1', ownerMemberId: 'wm-owner' }, + ], + hasMaterializedProject: () => { + materializationProbes += 1; + return materialized; + }, + publishedHead: async () => 3, + }); + const pull = createProactiveContentPull(deps); + + const full = pull.catchUpPublishedHeads('ws-1'); + await vi.waitFor(() => expect(deps.pullCalls).toEqual(['proj-1'])); + const missing = pull.materializeMissingProjects('ws-1'); + await vi.waitFor(() => expect(materializationProbes).toBeGreaterThan(0)); + releasePull(); + await Promise.all([full, missing]); + + expect(deps.pullCalls).toEqual(['proj-1']); + }); + + it('does not duplicate a full-sweep pull when the missing probe returns a stale false', async () => { + let releaseFullPull!: () => void; + const fullPullGate = new Promise((resolve) => { + releaseFullPull = resolve; + }); + let releaseStaleProbe!: () => void; + const staleProbeGate = new Promise((resolve) => { + releaseStaleProbe = resolve; + }); + let signalStaleProbeStarted!: () => void; + const staleProbeStarted = new Promise((resolve) => { + signalStaleProbeStarted = resolve; + }); + let probeCalls = 0; + const deps = makeDeps({ + getLocalBinding: () => null, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + if (deps.pullCalls.length === 1) await fullPullGate; + return { status: 'pulled', version: 3 }; + }, + listSharedProjects: async () => [ + { projectId: 'proj-1', ownerMemberId: 'wm-owner' }, + ], + hasMaterializedProject: async () => { + probeCalls += 1; + if (probeCalls === 1) return false; + const staleResult = false; + signalStaleProbeStarted(); + await staleProbeGate; + return staleResult; + }, + publishedHead: async () => 3, + }); + const pull = createProactiveContentPull(deps); + + const full = pull.catchUpPublishedHeads('ws-1'); + await vi.waitFor(() => expect(deps.pullCalls).toEqual(['proj-1'])); + const missing = pull.materializeMissingProjects('ws-1'); + await staleProbeStarted; + + releaseFullPull(); + await full; + releaseStaleProbe(); + await missing; + + expect(deps.pullCalls).toEqual(['proj-1']); + }); + + it('coalesces repeated missing-only triggers into one trailing sweep', async () => { + let releaseFirst!: () => void; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + let catalogReads = 0; + const deps = makeDeps({ + listSharedProjects: async () => { + catalogReads += 1; + if (catalogReads === 1) await firstGate; + return []; + }, + hasMaterializedProject: () => false, + publishedHead: async () => null, + }); + const pull = createProactiveContentPull(deps); + + const first = pull.materializeMissingProjects('ws-1'); + await vi.waitFor(() => expect(catalogReads).toBe(1)); + const second = pull.materializeMissingProjects('ws-1'); + const third = pull.materializeMissingProjects('ws-1'); + releaseFirst(); + await Promise.all([first, second, third]); + + expect(catalogReads).toBe(2); + }); + + it('retries a missing-only sweep when the first catalog read is temporarily unavailable', async () => { + const retry = makeRetryScheduler(); + let catalogReads = 0; + const deps = makeDeps({ + getLocalBinding: () => null, + listSharedProjects: async () => { + catalogReads += 1; + if (catalogReads === 1) { + throw new Error('catalog has not propagated yet'); + } + return [{ projectId: 'proj-1', ownerMemberId: 'wm-owner' }]; + }, + hasMaterializedProject: () => false, + publishedHead: async () => 3, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.materializeMissingProjects('ws-1'); + + expect(catalogReads).toBe(1); + expect(deps.pullCalls).toEqual([]); + expect(retry.delays).toEqual([1_000]); + expect(retry.tasks.size).toBe(1); + + await retry.runNext(); + + expect(catalogReads).toBe(2); + expect(deps.pullCalls).toEqual(['proj-1']); + expect(retry.tasks.size).toBe(0); + }); + + it('retries a missing-only sweep until its newly-shared project reaches the catalog', async () => { + const retry = makeRetryScheduler(); + let catalogReads = 0; + const deps = makeDeps({ + getLocalBinding: () => null, + listSharedProjects: async () => { + catalogReads += 1; + return catalogReads === 1 + ? [] + : [{ projectId: 'proj-1', ownerMemberId: 'wm-owner' }]; + }, + hasMaterializedProject: () => false, + publishedHead: async () => 3, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.materializeMissingProjects('ws-1', 'proj-1'); + + expect(deps.pullCalls).toEqual([]); + expect(retry.delays).toEqual([1_000]); + + await retry.runNext(); + + expect(catalogReads).toBe(2); + expect(deps.pullCalls).toEqual(['proj-1']); + expect(retry.tasks.size).toBe(0); + }); + + it('does not scan unrelated projects while a targeted first share is absent', async () => { + const retry = makeRetryScheduler(); + const deps = makeDeps({ + getLocalBinding: () => null, + listSharedProjects: async () => [ + { projectId: 'visible-project', ownerMemberId: 'wm-owner' }, + ], + hasMaterializedProject: () => false, + publishedHead: async () => 3, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.materializeMissingProjects('ws-1', 'still-propagating'); + + expect(deps.pullCalls).toEqual([]); + expect(retry.delays).toEqual([1_000]); + expect(retry.tasks.size).toBe(1); + }); + + it('limits targeted recovery to the requested first share', async () => { + const deps = makeDeps({ + getLocalBinding: () => null, + listSharedProjects: async () => [ + { projectId: 'unrelated-history', ownerMemberId: 'wm-owner' }, + { projectId: 'fresh-share', ownerMemberId: 'wm-owner' }, + ], + hasMaterializedProject: () => false, + publishedHead: async () => 3, + }); + const pull = createProactiveContentPull(deps); + + await pull.materializeMissingProjects('ws-1', 'fresh-share'); + + expect(deps.pullCalls).toEqual(['fresh-share']); + }); + + it('starts a first-share event recovery while broad missing-only is blocked', async () => { + let releaseHistory!: () => void; + const historyGate = new Promise((resolve) => { + releaseHistory = resolve; + }); + const deps = makeDeps({ + getLocalBinding: () => null, + listSharedProjects: async () => [ + { projectId: 'unrelated-history', ownerMemberId: 'wm-owner' }, + { projectId: 'fresh-share', ownerMemberId: 'wm-owner' }, + ], + hasMaterializedProject: () => false, + publishedHead: async () => 3, + resolveSharedProjectOwner: async () => null, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + if (target.projectId === 'unrelated-history') await historyGate; + return { status: 'pulled', version: 3 }; + }, + }); + const pull = createProactiveContentPull(deps); + + const broad = pull.materializeMissingProjects('ws-1'); + await vi.waitFor(() => + expect(deps.pullCalls).toEqual(['unrelated-history']), + ); + const targeted = pull.handleContentChanged({ + projectId: 'fresh-share', + workspaceId: 'ws-1', + version: 3, + }); + + await vi.waitFor(() => + expect(deps.pullCalls).toContain('fresh-share'), + ); + releaseHistory(); + await Promise.all([broad, targeted]); + }); + + it('runs a normal owner-resolved event ahead of a pending full rerun', async () => { + let releaseHistory!: () => void; + const historyGate = new Promise((resolve) => { + releaseHistory = resolve; + }); + const deps = makeDeps({ + getLocalBinding: () => ({ + workspaceId: 'ws-1', + visibility: 'team', + }), + listSharedProjects: async () => [ + { projectId: 'unrelated-history', ownerMemberId: 'wm-owner' }, + ], + publishedHead: async () => 3, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + if (target.projectId === 'unrelated-history') await historyGate; + return { status: 'pulled', version: 3 }; + }, + }); + const pull = createProactiveContentPull(deps); + + const full = pull.catchUpPublishedHeads('ws-1'); + await vi.waitFor(() => + expect(deps.pullCalls).toEqual(['unrelated-history']), + ); + const pendingFull = pull.catchUpPublishedHeads('ws-1'); + const event = pull.handleContentChanged({ + projectId: 'fresh-event', + workspaceId: 'ws-1', + version: 3, + }); + + await vi.waitFor(() => + expect(deps.pullCalls).toContain('fresh-event'), + ); + releaseHistory(); + await Promise.all([full, pendingFull, event]); + }); + + it('stops a broad sweep before its next project when a different live event arrives, then resumes after a quiet delay', async () => { + const retry = makeRetryScheduler(); + let releaseHistory!: () => void; + const historyGate = new Promise((resolve) => { + releaseHistory = resolve; + }); + const deps = makeDeps({ + scheduler: retry.scheduler, + listSharedProjects: async () => [ + { projectId: 'history-a', ownerMemberId: 'wm-owner' }, + { projectId: 'history-b', ownerMemberId: 'wm-owner' }, + ], + publishedHead: async () => 3, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + if (target.projectId === 'history-a') await historyGate; + return { status: 'pulled', version: 3 }; + }, + }); + const pull = createProactiveContentPull(deps); + + const broad = pull.catchUpPublishedHeads('ws-1'); + await vi.waitFor(() => expect(deps.pullCalls).toEqual(['history-a'])); + const event = pull.handleContentChanged({ + projectId: 'live-project', + workspaceId: 'ws-1', + version: 3, + }); + await vi.waitFor(() => + expect(deps.pullCalls).toContain('live-project'), + ); + + releaseHistory(); + await Promise.all([broad, event]); + + expect(deps.pullCalls).toEqual(['history-a', 'live-project']); + expect(retry.tasks.size).toBe(1); + + await retry.runNext(); + + expect(deps.pullCalls).toEqual([ + 'history-a', + 'live-project', + 'history-b', + ]); + }); + + it('budgets broad remote heads and rotates fairly without scheduling retries', async () => { + const retry = makeRetryScheduler(); + const projects = Array.from({ length: 80 }, (_, index) => ({ + projectId: `history-${String(index).padStart(2, '0')}`, + ownerMemberId: 'wm-owner', + })); + const headCalls: string[] = []; + const onCatchUp = vi.fn(); + const deps = makeDeps({ + scheduler: retry.scheduler, + onCatchUp, + listSharedProjects: async () => projects, + hasMaterializedProject: () => false, + publishedHead: async (target) => { + headCalls.push(target.projectId); + return null; + }, + }); + const pull = createProactiveContentPull(deps); + + await pull.materializeMissingProjects('ws-1'); + expect(headCalls).toEqual([ + 'history-00', + 'history-01', + 'history-02', + 'history-03', + ]); + expect(onCatchUp).toHaveBeenLastCalledWith(expect.objectContaining({ + phase: 'completed', + lane: 'broad', + headChecks: 4, + heads: 0, + complete: false, + })); + expect(retry.tasks.size).toBe(0); + + await pull.materializeMissingProjects('ws-1'); + expect(headCalls.slice(4)).toEqual([ + 'history-04', + 'history-05', + 'history-06', + 'history-07', + ]); + expect(new Set(headCalls).size).toBe(8); + expect(retry.tasks.size).toBe(0); + + // Project-specific first-share recovery bypasses the broad round budget. + await pull.materializeMissingProjects('ws-1', 'history-79'); + expect(headCalls.at(-1)).toBe('history-79'); + + // The stable safety floor eventually visits every broad candidate instead + // of repeatedly spending its budget on the first catalog page. + for (let round = 2; round < 20; round += 1) { + await pull.materializeMissingProjects('ws-1'); + } + expect(new Set(headCalls).size).toBe(80); + expect(retry.tasks.size).toBe(0); + }); + + it('isolates broad rotation cursors by workspace and recovery mode', async () => { + const projects = Array.from({ length: 8 }, (_, index) => ({ + projectId: `history-${index}`, + ownerMemberId: 'wm-owner', + })); + let activeWorkspaceId = 'ws-1'; + const headCalls: string[] = []; + const deps = makeDeps({ + getWorkspaceIdentity: async () => ({ + workspaceId: activeWorkspaceId, + resourceTeamId: `team-${activeWorkspaceId}`, + workspaceMemberId: 'wm-member', + }), + getLocalBinding: () => ({ + workspaceId: activeWorkspaceId, + visibility: 'team', + }), + listSharedProjects: async () => projects, + hasMaterializedProject: () => false, + publishedHead: async (target) => { + headCalls.push(`${target.workspaceId}:${target.projectId}`); + return null; + }, + }); + const pull = createProactiveContentPull(deps); + + await pull.materializeMissingProjects('ws-1'); + expect(headCalls).toEqual([ + 'ws-1:history-0', + 'ws-1:history-1', + 'ws-1:history-2', + 'ws-1:history-3', + ]); + + // Full reconnect owns an independent position from the missing-only floor. + await pull.catchUpPublishedHeads('ws-1'); + expect(headCalls.slice(4)).toEqual([ + 'ws-1:history-0', + 'ws-1:history-1', + 'ws-1:history-2', + 'ws-1:history-3', + ]); + + activeWorkspaceId = 'ws-2'; + await pull.materializeMissingProjects('ws-2'); + expect(headCalls.slice(8)).toEqual([ + 'ws-2:history-0', + 'ws-2:history-1', + 'ws-2:history-2', + 'ws-2:history-3', + ]); + + activeWorkspaceId = 'ws-1'; + await pull.materializeMissingProjects('ws-1'); + expect(headCalls.slice(12)).toEqual([ + 'ws-1:history-4', + 'ws-1:history-5', + 'ws-1:history-6', + 'ws-1:history-7', + ]); + }); + + it('continues broad rotation when the catalog changes and a candidate materializes', async () => { + let projects = Array.from({ length: 9 }, (_, index) => ({ + projectId: `history-${index}`, + ownerMemberId: 'wm-owner', + })); + const materialized = new Set(); + const headCalls: string[] = []; + const deps = makeDeps({ + listSharedProjects: async () => projects, + hasMaterializedProject: (projectId) => materialized.has(projectId), + publishedHead: async (target) => { + headCalls.push(target.projectId); + return null; + }, + }); + const pull = createProactiveContentPull(deps); + + await pull.materializeMissingProjects('ws-1'); + expect(headCalls).toEqual([ + 'history-0', + 'history-1', + 'history-2', + 'history-3', + ]); + + projects = projects.filter((candidate) => candidate.projectId !== 'history-3'); + materialized.add('history-4'); + await pull.materializeMissingProjects('ws-1'); + + expect(headCalls.slice(4)).toEqual([ + 'history-5', + 'history-6', + 'history-7', + 'history-8', + ]); + }); + + it('yields broad recovery after the current remote head when foreground work arrives', async () => { + const retry = makeRetryScheduler(); + let releaseHead!: () => void; + let headStarted!: () => void; + const headGate = new Promise((resolve) => { + releaseHead = resolve; + }); + const headStart = new Promise((resolve) => { + headStarted = resolve; + }); + const headCalls: string[] = []; + const deps = makeDeps({ + scheduler: retry.scheduler, + listSharedProjects: async () => [ + { projectId: 'history-a', ownerMemberId: 'wm-owner' }, + { projectId: 'history-b', ownerMemberId: 'wm-owner' }, + ], + publishedHead: async (target) => { + headCalls.push(target.projectId); + if (target.projectId === 'history-a') { + headStarted(); + await headGate; + } + return 3; + }, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + return { status: 'pulled', version: 3 }; + }, + }); + const pull = createProactiveContentPull(deps); + + const broad = pull.catchUpPublishedHeads('ws-1'); + await headStart; + const event = pull.handleContentChanged({ + projectId: 'live-project', + workspaceId: 'ws-1', + version: 3, + }); + await vi.waitFor(() => + expect(deps.pullCalls).toContain('live-project'), + ); + releaseHead(); + await Promise.all([broad, event]); + + expect(headCalls).toEqual(['history-a']); + expect(deps.pullCalls).toEqual(['live-project']); + pull.dispose(); + }); + + it('does not turn broad missing-project failures into permanent per-project retry timers', async () => { + const retry = makeRetryScheduler(); + const projects = Array.from({ length: 8 }, (_, index) => ({ + projectId: `history-${index}`, + ownerMemberId: 'wm-owner', + })); + const publishedVersions = new Map( + projects.map((project) => [project.projectId, 3]), + ); + const recoverable = new Set(); + const materialized = new Set(); + const headCalls: string[] = []; + const onCatchUp = vi.fn(); + let clock = 0; + const deps = makeDeps({ + scheduler: retry.scheduler, + now: () => clock, + onCatchUp, + listSharedProjects: async () => projects, + hasMaterializedProject: (projectId) => materialized.has(projectId), + publishedHead: async (target) => { + headCalls.push(target.projectId); + return publishedVersions.get(target.projectId) ?? null; + }, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + if (!recoverable.has(target.projectId)) { + return { status: 'register_failed' }; + } + materialized.add(target.projectId); + return { + status: 'pulled', + version: publishedVersions.get(target.projectId) ?? null, + }; + }, + }); + const pull = createProactiveContentPull(deps); + + await pull.materializeMissingProjects('ws-1'); + + expect(deps.pullCalls).toHaveLength(4); + expect(headCalls).toHaveLength(4); + expect(retry.tasks.size).toBe(0); + + // The next heartbeat rotates to the second bounded batch. + await pull.materializeMissingProjects('ws-1'); + expect(deps.pullCalls).toHaveLength(8); + expect(headCalls).toHaveLength(8); + expect(retry.tasks.size).toBe(0); + + // Once the exact scopes cool down, the low-frequency safety floor neither + // reads their heads nor allocates one retry timer per unavailable project. + await pull.materializeMissingProjects('ws-1'); + expect(deps.pullCalls).toHaveLength(8); + expect(headCalls).toHaveLength(8); + expect(onCatchUp).toHaveBeenLastCalledWith(expect.objectContaining({ + phase: 'completed', + headChecks: 0, + heads: 0, + suppressed: 8, + complete: false, + })); + + // A targeted/live event bypasses the floor cooldown for the same head. + recoverable.add('history-0'); + await pull.handleContentChanged({ + projectId: 'history-0', + workspaceId: 'ws-1', + version: 3, + }); + expect(deps.pullCalls).toHaveLength(9); + expect(deps.pullCalls.at(-1)).toBe('history-0'); + expect(retry.tasks.size).toBe(0); + + // A newer head discovered only by the low-frequency floor may wait for + // the bounded cooldown; unlike targeted/live work it does not spawn an + // extra head CLI during the cooldown window. + recoverable.add('history-1'); + publishedVersions.set('history-1', 4); + await pull.materializeMissingProjects('ws-1'); + expect(deps.pullCalls).toHaveLength(9); + expect(headCalls).toHaveLength(8); + expect(retry.tasks.size).toBe(0); + + // The next low-frequency floor retries the remaining transient failures + // after cooldown, still without allocating one timer per project. + clock = 15_001; + await pull.materializeMissingProjects('ws-1'); + expect(deps.pullCalls).toHaveLength(13); + expect(headCalls).toHaveLength(12); + expect(deps.pullCalls).toContain('history-1'); + expect(retry.tasks.size).toBe(0); + }); + + it('stops a broad sweep before its next project when the live event coalesces with its current project', async () => { + const retry = makeRetryScheduler(); + let releaseHistory!: () => void; + const historyGate = new Promise((resolve) => { + releaseHistory = resolve; + }); + const deps = makeDeps({ + scheduler: retry.scheduler, + listSharedProjects: async () => [ + { projectId: 'proj-1', ownerMemberId: 'wm-owner' }, + { projectId: 'history-b', ownerMemberId: 'wm-owner' }, + ], + publishedHead: async () => 3, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + if (target.projectId === 'proj-1') await historyGate; + return { status: 'pulled', version: 3 }; + }, + }); + const pull = createProactiveContentPull(deps); + + const broad = pull.catchUpPublishedHeads('ws-1'); + await vi.waitFor(() => expect(deps.pullCalls).toEqual(['proj-1'])); + const event = pull.handleContentChanged(baseEvent); + releaseHistory(); + await Promise.all([broad, event]); + + expect(deps.pullCalls).toEqual(['proj-1']); + expect(retry.tasks.size).toBe(1); + + await retry.runNext(); + + expect(deps.pullCalls).toEqual(['proj-1', 'history-b']); + }); + + it('keeps a targeted first-share recovery runnable while a live event suppresses broad work', async () => { + const retry = makeRetryScheduler(); + let releaseHistory!: () => void; + const historyGate = new Promise((resolve) => { + releaseHistory = resolve; + }); + let releaseEvent!: () => void; + const eventGate = new Promise((resolve) => { + releaseEvent = resolve; + }); + const deps = makeDeps({ + scheduler: retry.scheduler, + getLocalBinding: (projectId) => + projectId === 'fresh-share' + ? null + : { workspaceId: 'ws-1', visibility: 'team' }, + listSharedProjects: async () => [ + { projectId: 'history-a', ownerMemberId: 'wm-owner' }, + { projectId: 'history-b', ownerMemberId: 'wm-owner' }, + { projectId: 'fresh-share', ownerMemberId: 'wm-owner' }, + ], + hasMaterializedProject: () => false, + publishedHead: async () => 3, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + if (target.projectId === 'history-a') await historyGate; + if (target.projectId === 'live-project') await eventGate; + return { status: 'pulled', version: 3 }; + }, + }); + const pull = createProactiveContentPull(deps); + + const broad = pull.catchUpPublishedHeads('ws-1'); + await vi.waitFor(() => expect(deps.pullCalls).toEqual(['history-a'])); + const event = pull.handleContentChanged({ + projectId: 'live-project', + workspaceId: 'ws-1', + version: 3, + }); + await vi.waitFor(() => + expect(deps.pullCalls).toContain('live-project'), + ); + releaseHistory(); + await broad; + + const targeted = pull.materializeMissingProjects('ws-1', 'fresh-share'); + await vi.waitFor(() => + expect(deps.pullCalls).toContain('fresh-share'), + ); + + releaseEvent(); + await Promise.all([event, targeted]); + }); + + it('extends one broad-resume delay across overlapping live events and clears it on dispose', async () => { + const retry = makeRetryScheduler(); + let releaseHistory!: () => void; + const historyGate = new Promise((resolve) => { + releaseHistory = resolve; + }); + let releaseFirstEvent!: () => void; + const firstEventGate = new Promise((resolve) => { + releaseFirstEvent = resolve; + }); + let releaseSecondEvent!: () => void; + const secondEventGate = new Promise((resolve) => { + releaseSecondEvent = resolve; + }); + const deps = makeDeps({ + scheduler: retry.scheduler, + listSharedProjects: async () => [ + { projectId: 'history-a', ownerMemberId: 'wm-owner' }, + { projectId: 'history-b', ownerMemberId: 'wm-owner' }, + ], + publishedHead: async () => 3, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + if (target.projectId === 'history-a') await historyGate; + if (target.projectId === 'live-a') await firstEventGate; + if (target.projectId === 'live-b') await secondEventGate; + return { status: 'pulled', version: 3 }; + }, + }); + const pull = createProactiveContentPull(deps); + + const broad = pull.catchUpPublishedHeads('ws-1'); + await vi.waitFor(() => expect(deps.pullCalls).toEqual(['history-a'])); + const firstEvent = pull.handleContentChanged({ + projectId: 'live-a', + workspaceId: 'ws-1', + version: 3, + }); + const secondEvent = pull.handleContentChanged({ + projectId: 'live-b', + workspaceId: 'ws-1', + version: 3, + }); + await vi.waitFor(() => + expect(deps.pullCalls).toEqual([ + 'history-a', + 'live-a', + 'live-b', + ]), + ); + + releaseHistory(); + await broad; + expect(retry.tasks.size).toBe(0); + + releaseFirstEvent(); + await firstEvent; + expect(retry.tasks.size).toBe(0); + + releaseSecondEvent(); + await secondEvent; + expect(retry.tasks.size).toBe(1); + + pull.dispose(); + expect(retry.tasks.size).toBe(0); + expect(retry.cleared).toHaveLength(1); + }); + + it('restarts the quiet delay for a later live event without starving deferred broad work', async () => { + const retry = makeRetryScheduler(); + let releaseHistory!: () => void; + const historyGate = new Promise((resolve) => { + releaseHistory = resolve; + }); + let releaseLaterEvent!: () => void; + const laterEventGate = new Promise((resolve) => { + releaseLaterEvent = resolve; + }); + const deps = makeDeps({ + scheduler: retry.scheduler, + listSharedProjects: async () => [ + { projectId: 'history-a', ownerMemberId: 'wm-owner' }, + { projectId: 'history-b', ownerMemberId: 'wm-owner' }, + ], + publishedHead: async () => 3, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + if (target.projectId === 'history-a') await historyGate; + if (target.projectId === 'later-live') await laterEventGate; + return { status: 'pulled', version: 3 }; + }, + }); + const pull = createProactiveContentPull(deps); + + const broad = pull.catchUpPublishedHeads('ws-1'); + await vi.waitFor(() => expect(deps.pullCalls).toEqual(['history-a'])); + await pull.handleContentChanged({ + projectId: 'first-live', + workspaceId: 'ws-1', + version: 3, + }); + releaseHistory(); + await broad; + expect(retry.tasks.size).toBe(1); + + const laterEvent = pull.handleContentChanged({ + projectId: 'later-live', + workspaceId: 'ws-1', + version: 3, + }); + await vi.waitFor(() => + expect(deps.pullCalls).toContain('later-live'), + ); + expect(retry.tasks.size).toBe(0); + expect(retry.cleared).toHaveLength(1); + + releaseLaterEvent(); + await laterEvent; + expect(retry.tasks.size).toBe(1); + + await retry.runNext(); + + expect(deps.pullCalls).toContain('history-b'); + expect(retry.tasks.size).toBe(0); + }); + + it('keeps broad work deferred while a live event is waiting for its transport retry', async () => { + const retry = makeRetryScheduler(); + let releaseHistory!: () => void; + const historyGate = new Promise((resolve) => { + releaseHistory = resolve; + }); + let liveAttempts = 0; + const deps = makeDeps({ + scheduler: retry.scheduler, + random: () => 1, + listSharedProjects: async () => [ + { projectId: 'history-a', ownerMemberId: 'wm-owner' }, + { projectId: 'history-b', ownerMemberId: 'wm-owner' }, + ], + publishedHead: async () => 3, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + if (target.projectId === 'history-a') await historyGate; + if (target.projectId === 'live-project') { + liveAttempts += 1; + if (liveAttempts === 1) throw new Error('transient transport failure'); + } + return { status: 'pulled', version: 3 }; + }, + }); + const pull = createProactiveContentPull(deps); + + const broad = pull.catchUpPublishedHeads('ws-1'); + await vi.waitFor(() => expect(deps.pullCalls).toEqual(['history-a'])); + await pull.handleContentChanged({ + projectId: 'live-project', + workspaceId: 'ws-1', + version: 3, + }); + expect(retry.delays).toEqual([1_000]); + + releaseHistory(); + await broad; + + expect(deps.pullCalls).toEqual(['history-a', 'live-project']); + expect(retry.delays).toEqual([1_000]); + expect(retry.tasks.size).toBe(1); + + await retry.runNext(); + + expect(deps.pullCalls).toEqual([ + 'history-a', + 'live-project', + 'live-project', + ]); + expect(retry.delays).toEqual([1_000, 250]); + expect(retry.tasks.size).toBe(1); + + await retry.runNext(); + + expect(deps.pullCalls).toEqual([ + 'history-a', + 'live-project', + 'live-project', + 'history-b', + ]); + expect(retry.tasks.size).toBe(0); + }); + + it('transfers foreground priority when a provisional retry merges into a scoped pull', async () => { + const retry = makeRetryScheduler(); + let identityAvailable = true; + let releaseScopedPull!: () => void; + const scopedPullGate = new Promise((resolve) => { + releaseScopedPull = resolve; + }); + const deps = makeDeps({ + scheduler: retry.scheduler, + random: () => 1, + getWorkspaceIdentity: async () => + identityAvailable + ? { + workspaceId: 'ws-1', + resourceTeamId: 'team-1', + workspaceMemberId: 'wm-member', + } + : null, + listSharedProjects: async () => [ + { projectId: 'proj-1', ownerMemberId: 'wm-owner' }, + { projectId: 'history-b', ownerMemberId: 'wm-owner' }, + ], + hasMaterializedProject: () => false, + publishedHead: async () => 3, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + if (target.projectId === 'proj-1') await scopedPullGate; + return { status: 'pulled', version: 3 }; + }, + }); + const pull = createProactiveContentPull(deps); + + const targeted = pull.materializeMissingProjects('ws-1', 'proj-1'); + await vi.waitFor(() => expect(deps.pullCalls).toEqual(['proj-1'])); + + identityAvailable = false; + await pull.handleContentChanged(baseEvent); + expect(retry.delays).toEqual([1_000]); + identityAvailable = true; + + const broad = pull.catchUpPublishedHeads('ws-1'); + await broad; + expect(deps.pullCalls).toEqual(['proj-1']); + + const foregroundRetry = retry.runNext(); + await vi.waitFor(() => + expect(deps.pullCalls).toEqual(['proj-1']), + ); + releaseScopedPull(); + await Promise.all([targeted, foregroundRetry]); + + expect(retry.delays).toEqual([1_000, 250]); + expect(retry.tasks.size).toBe(1); + + await retry.runNext(); + + expect(deps.pullCalls).toEqual(['proj-1', 'history-b']); + expect(retry.tasks.size).toBe(0); + }); + + it('keeps first-share recovery ahead of broad work through its first catalog retry and pull', async () => { + const retry = makeRetryScheduler(); + let releaseHistory!: () => void; + const historyGate = new Promise((resolve) => { + releaseHistory = resolve; + }); + let releaseFirstShare!: () => void; + const firstShareGate = new Promise((resolve) => { + releaseFirstShare = resolve; + }); + let catalogReads = 0; + const deps = makeDeps({ + scheduler: retry.scheduler, + random: () => 1, + getLocalBinding: () => null, + resolveSharedProjectOwner: async (projectId) => + projectId === 'first-share' ? null : 'wm-owner', + listSharedProjects: async () => { + catalogReads += 1; + if (catalogReads === 1) { + return [ + { projectId: 'history-a', ownerMemberId: 'wm-owner' }, + { projectId: 'history-b', ownerMemberId: 'wm-owner' }, + ]; + } + if (catalogReads === 2) return []; + return [ + { projectId: 'history-a', ownerMemberId: 'wm-owner' }, + { projectId: 'history-b', ownerMemberId: 'wm-owner' }, + { projectId: 'first-share', ownerMemberId: 'wm-owner' }, + ]; + }, + hasMaterializedProject: () => false, + publishedHead: async () => 3, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + if (target.projectId === 'history-a') await historyGate; + if (target.projectId === 'first-share') await firstShareGate; + return { status: 'pulled', version: 3 }; + }, + }); + const pull = createProactiveContentPull(deps); + + const broad = pull.catchUpPublishedHeads('ws-1'); + await vi.waitFor(() => expect(deps.pullCalls).toEqual(['history-a'])); + await pull.handleContentChanged({ + projectId: 'first-share', + workspaceId: 'ws-1', + version: 3, + }); + expect(retry.delays).toEqual([1_000]); + + releaseHistory(); + await broad; + expect(deps.pullCalls).toEqual(['history-a']); + expect(retry.delays).toEqual([1_000]); + + const firstShareRetry = retry.runDelay(1_000); + await vi.waitFor(() => + expect(deps.pullCalls).toEqual(['history-a', 'first-share']), + ); + expect( + [...retry.tasks.values()].map((task) => task.delayMs), + ).not.toContain(250); + + releaseFirstShare(); + await firstShareRetry; + expect(retry.delays.at(-1)).toBe(250); + + await retry.runDelay(250); + expect(deps.pullCalls).toEqual([ + 'history-a', + 'first-share', + 'history-b', + ]); + }); + + it('lets broad work resume after one priority retry while a failing live intent keeps backing off', async () => { + const retry = makeRetryScheduler(); + let releaseHistory!: () => void; + const historyGate = new Promise((resolve) => { + releaseHistory = resolve; + }); + const deps = makeDeps({ + scheduler: retry.scheduler, + random: () => 1, + listSharedProjects: async () => [ + { projectId: 'history-a', ownerMemberId: 'wm-owner' }, + { projectId: 'history-b', ownerMemberId: 'wm-owner' }, + ], + publishedHead: async () => 3, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + if (target.projectId === 'history-a') await historyGate; + if (target.projectId === 'live-project') { + throw new Error('persistent transport failure'); + } + return { status: 'pulled', version: 3 }; + }, + }); + const pull = createProactiveContentPull(deps); + + const broad = pull.catchUpPublishedHeads('ws-1'); + await vi.waitFor(() => expect(deps.pullCalls).toEqual(['history-a'])); + await pull.handleContentChanged({ + projectId: 'live-project', + workspaceId: 'ws-1', + version: 3, + }); + releaseHistory(); + await broad; + + expect(retry.delays).toEqual([1_000]); + await retry.runDelay(1_000); + + // The second transport retry remains scheduled, but its persistent + // failure may no longer starve unrelated catch-up work. + expect(retry.delays).toEqual([1_000, 2_000, 250]); + await retry.runDelay(250); + expect(deps.pullCalls).toEqual([ + 'history-a', + 'live-project', + 'live-project', + 'history-b', + ]); + expect( + [...retry.tasks.values()].map((task) => task.delayMs), + ).toEqual([2_000]); + }); + + it('refreshes the one-retry priority budget when a new event arrives', async () => { + const retry = makeRetryScheduler(); + let releaseHistoryA!: () => void; + const historyAGate = new Promise((resolve) => { + releaseHistoryA = resolve; + }); + let releaseHistoryB!: () => void; + const historyBGate = new Promise((resolve) => { + releaseHistoryB = resolve; + }); + const deps = makeDeps({ + scheduler: retry.scheduler, + random: () => 1, + listSharedProjects: async () => [ + { projectId: 'history-a', ownerMemberId: 'wm-owner' }, + { projectId: 'history-b', ownerMemberId: 'wm-owner' }, + { projectId: 'history-c', ownerMemberId: 'wm-owner' }, + ], + publishedHead: async () => 3, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + if (target.projectId === 'history-a') await historyAGate; + if (target.projectId === 'history-b') await historyBGate; + if (target.projectId === 'live-project') { + throw new Error('persistent transport failure'); + } + return { status: 'pulled', version: 3 }; + }, + }); + const pull = createProactiveContentPull(deps); + + const broad = pull.catchUpPublishedHeads('ws-1'); + await vi.waitFor(() => expect(deps.pullCalls).toEqual(['history-a'])); + await pull.handleContentChanged({ + projectId: 'live-project', + workspaceId: 'ws-1', + version: 3, + }); + releaseHistoryA(); + await broad; + await retry.runDelay(1_000); + const firstResume = retry.runDelay(250); + await vi.waitFor(() => expect(deps.pullCalls).toContain('history-b')); + + // A repeated event gives the still-pending intent one fresh priority + // retry, even though the content version did not change. + await pull.handleContentChanged({ + projectId: 'live-project', + workspaceId: 'ws-1', + version: 3, + }); + releaseHistoryB(); + await firstResume; + await vi.waitFor(() => + expect(deps.pullCalls).not.toContain('history-c'), + ); + + await retry.runDelay(2_000); + expect(deps.pullCalls).not.toContain('history-c'); + await retry.runDelay(250); + expect(deps.pullCalls).toContain('history-c'); + }); + + it('keeps concurrent first-share catalog retries independent', async () => { + const retry = makeRetryScheduler(); + let releaseCatalog!: () => void; + const catalogGate = new Promise((resolve) => { + releaseCatalog = resolve; + }); + let reads = 0; + const deps = makeDeps({ + getLocalBinding: () => null, + listSharedProjects: async () => { + reads += 1; + if (reads === 1) await catalogGate; + return []; + }, + hasMaterializedProject: () => false, + publishedHead: async () => 3, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + const first = pull.materializeMissingProjects('ws-1', 'project-a'); + await vi.waitFor(() => expect(reads).toBe(1)); + const second = pull.materializeMissingProjects('ws-1', 'project-b'); + releaseCatalog(); + await Promise.all([first, second]); + + expect(reads).toBe(1); + expect(retry.delays).toEqual([1_000, 1_000]); + expect(retry.tasks.size).toBe(2); + }); + + it('cancels targeted catalog retries from the previous workspace', async () => { + const retry = makeRetryScheduler(); + let activeWorkspaceId = 'ws-1'; + const deps = makeDeps({ + getLocalBinding: () => null, + getWorkspaceIdentity: async () => ({ + workspaceId: activeWorkspaceId, + resourceTeamId: `team-${activeWorkspaceId}`, + workspaceMemberId: 'wm-member', + }), + listSharedProjects: async () => [], + hasMaterializedProject: () => false, + publishedHead: async () => 3, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.materializeMissingProjects('ws-1', 'project-a'); + activeWorkspaceId = 'ws-2'; + await pull.materializeMissingProjects('ws-2', 'project-b'); + + expect(retry.cleared).toHaveLength(1); + expect(retry.tasks.size).toBe(1); + }); + + it('does not let an in-flight old-workspace sweep restore a stale retry', async () => { + const retry = makeRetryScheduler(); + let activeWorkspaceId = 'ws-1'; + let releaseOldCatalog!: () => void; + const oldCatalogGate = new Promise((resolve) => { + releaseOldCatalog = resolve; + }); + const deps = makeDeps({ + getLocalBinding: () => null, + getWorkspaceIdentity: async () => ({ + workspaceId: activeWorkspaceId, + resourceTeamId: `team-${activeWorkspaceId}`, + workspaceMemberId: 'wm-member', + }), + listSharedProjects: async (workspaceId) => { + if (workspaceId === 'ws-1') await oldCatalogGate; + return []; + }, + hasMaterializedProject: () => false, + publishedHead: async () => 3, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + const oldSweep = pull.materializeMissingProjects('ws-1', 'project-a'); + await Promise.resolve(); + activeWorkspaceId = 'ws-2'; + const newSweep = pull.materializeMissingProjects('ws-2', 'project-b'); + releaseOldCatalog(); + await Promise.all([oldSweep, newSweep]); + + expect(retry.delays).toEqual([1_000]); + expect(retry.cleared).toHaveLength(0); + expect(retry.tasks.size).toBe(1); + }); + + it('does not drop a third sweep requested while the trailing sweep is running', async () => { + let releaseFirst!: () => void; + let releaseSecond!: () => void; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + const secondGate = new Promise((resolve) => { + releaseSecond = resolve; + }); + let lists = 0; + const deps = makeDeps(); + Object.assign(deps, { + listSharedProjects: async () => { + lists += 1; + if (lists === 1) await firstGate; + if (lists === 2) await secondGate; + return []; + }, + publishedHead: async () => null, + hasMaterializedProject: () => false, + }); + const pull = createProactiveContentPull(deps); + + const first = pull.catchUpPublishedHeads('ws-1'); + const second = pull.materializeMissingProjects('ws-1'); + releaseFirst(); + await vi.waitFor(() => expect(lists).toBe(2)); + const third = pull.catchUpPublishedHeads('ws-1'); + releaseSecond(); + await Promise.all([first, second, third]); + + expect(lists).toBe(3); + }); + + it('seeds the event cursor from the durable materialized version on cold start', async () => { + const deps = makeDeps(); + const publishedHead = vi.fn(async () => 3); + Object.assign(deps, { + listSharedProjects: async () => [ + { projectId: 'proj-1', ownerMemberId: 'wm-owner' }, + ], + publishedHead, + materializedVersion: () => '3', + }); + const pull = createProactiveContentPull(deps); + + await pull.catchUpPublishedHeads('ws-1'); + await pull.handleContentChanged(baseEvent); + + expect(publishedHead).toHaveBeenCalledTimes(1); + expect(deps.pullCalls).toEqual([]); + }); + + it('does not reuse a durable cursor after the shared project owner changes', async () => { + const deps = makeDeps(); + Object.assign(deps, { + listSharedProjects: async () => [ + { projectId: 'proj-1', ownerMemberId: 'wm-new-owner' }, + ], + publishedHead: async () => 1, + // Version 10 belonged to the previous owner's resource scope. + materializedVersion: (target: { ownerMemberId: string }) => + target.ownerMemberId === 'wm-old-owner' ? '10' : null, + }); + const pull = createProactiveContentPull(deps); + + await pull.catchUpPublishedHeads('ws-1'); + + expect(deps.pullCalls).toEqual(['proj-1']); + }); + + it('forces a missing-only pull when the manifest is absent even if the durable cursor equals head', async () => { + const deps = makeDeps({ getLocalBinding: () => null }); + Object.assign(deps, { + listSharedProjects: async () => [ + { projectId: 'proj-1', ownerMemberId: 'wm-owner' }, + ], + hasMaterializedProject: async () => false, + publishedHead: async () => 3, + materializedVersion: () => '3', + }); + const pull = createProactiveContentPull(deps); + + await pull.materializeMissingProjects('ws-1'); + + expect(deps.pullCalls).toEqual(['proj-1']); + }); + + it('does not repeat an authorized remote mirror that has exact receipt and live bytes but no local project manifest', async () => { + let projectRowExists = false; + let liveDirectoryExists = false; + let receipt: + | { + workspaceId: string; + resourceTeamId: string; + viewerMemberId: string; + ownerMemberId: string; + version: number; + } + | null = null; + const materializedVersion = (target: ProactiveContentPullTarget) => + receipt && + receipt.workspaceId === target.workspaceId && + receipt.resourceTeamId === target.resourceTeamId && + receipt.viewerMemberId === target.viewerMemberId && + receipt.ownerMemberId === target.ownerMemberId + ? String(receipt.version) + : null; + const deps = makeDeps({ + getLocalBinding: () => null, + listSharedProjects: async () => [ + { projectId: 'proj-1', ownerMemberId: 'wm-owner' }, + ], + // Authorized mirrors intentionally contain shared files, not the local + // `.open-design/project.json`. Presence must use the guarded target's + // exact receipt plus the promoted live directory instead. + hasMaterializedProject: ( + _projectId, + target: ProactiveContentPullTarget, + ) => + Boolean( + projectRowExists && + liveDirectoryExists && + materializedVersion(target) != null, + ), + materializedVersion, + publishedHead: async () => 3, + pullSharedProject: async (target, expectedVersion) => { + deps.pullCalls.push(target.projectId); + projectRowExists = true; + liveDirectoryExists = true; + receipt = { + workspaceId: target.workspaceId, + resourceTeamId: target.resourceTeamId, + viewerMemberId: target.viewerMemberId, + ownerMemberId: target.ownerMemberId, + version: expectedVersion ?? 3, + }; + return { status: 'pulled', version: expectedVersion ?? 3 }; + }, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged(baseEvent); + await pull.materializeMissingProjects('ws-1', 'proj-1'); + + expect(deps.pullCalls).toEqual(['proj-1']); + }); + + it('repairs a missing live directory even when its exact receipt equals head', async () => { + const deps = makeDeps({ + getLocalBinding: () => null, + listSharedProjects: async () => [ + { projectId: 'proj-1', ownerMemberId: 'wm-owner' }, + ], + hasMaterializedProject: () => false, + materializedVersion: () => '3', + publishedHead: async () => 3, + }); + const pull = createProactiveContentPull(deps); + + await pull.materializeMissingProjects('ws-1', 'proj-1'); + + expect(deps.pullCalls).toEqual(['proj-1']); + }); + + const mismatchedReceiptCases: Array<[ + string, + { + workspaceId: string; + resourceTeamId: string; + viewerMemberId: string; + ownerMemberId: string; + version: number; + } | null, + ]> = [ + ['missing receipt', null], + [ + 'wrong workspace receipt', + { + workspaceId: 'ws-other', + resourceTeamId: 'team-1', + viewerMemberId: 'wm-member', + ownerMemberId: 'wm-owner', + version: 3, + }, + ], + [ + 'wrong owner receipt', + { + workspaceId: 'ws-1', + resourceTeamId: 'team-1', + viewerMemberId: 'wm-member', + ownerMemberId: 'wm-other-owner', + version: 3, + }, + ], + [ + 'wrong resource team receipt', + { + workspaceId: 'ws-1', + resourceTeamId: 'team-other', + viewerMemberId: 'wm-member', + ownerMemberId: 'wm-owner', + version: 3, + }, + ], + [ + 'wrong viewer receipt', + { + workspaceId: 'ws-1', + resourceTeamId: 'team-1', + viewerMemberId: 'wm-other-viewer', + ownerMemberId: 'wm-owner', + version: 3, + }, + ], + ]; + + it.each(mismatchedReceiptCases)( + 'forces targeted recovery for %s', + async (_label, receipt) => { + const materializedVersion = (target: ProactiveContentPullTarget) => + receipt && + receipt.workspaceId === target.workspaceId && + receipt.resourceTeamId === target.resourceTeamId && + receipt.viewerMemberId === target.viewerMemberId && + receipt.ownerMemberId === target.ownerMemberId + ? String(receipt.version) + : null; + const deps = makeDeps({ + getLocalBinding: () => null, + listSharedProjects: async () => [ + { projectId: 'proj-1', ownerMemberId: 'wm-owner' }, + ], + hasMaterializedProject: ( + _projectId, + target: ProactiveContentPullTarget, + ) => Boolean(materializedVersion(target) != null), + materializedVersion, + publishedHead: async () => 3, + }); + const pull = createProactiveContentPull(deps); + + await pull.materializeMissingProjects('ws-1', 'proj-1'); + + expect(deps.pullCalls).toEqual(['proj-1']); + }, + ); + + it('pulls a targeted mirror whose exact materialized version is below head', async () => { + const deps = makeDeps({ + getLocalBinding: () => null, + listSharedProjects: async () => [ + { projectId: 'proj-1', ownerMemberId: 'wm-owner' }, + ], + hasMaterializedProject: () => true, + materializedVersion: () => '2', + publishedHead: async () => 3, + }); + const pull = createProactiveContentPull(deps); + + await pull.materializeMissingProjects('ws-1', 'proj-1'); + + expect(deps.pullCalls).toEqual(['proj-1']); + }); + + it('does not repeat an exact materialized head during a cold full-heal pass', async () => { + const deps = makeDeps({ + getLocalBinding: () => null, + listSharedProjects: async () => [ + { projectId: 'proj-1', ownerMemberId: 'wm-owner' }, + ], + hasMaterializedProject: ( + _projectId, + _target: ProactiveContentPullTarget, + ) => true, + materializedVersion: () => '3', + publishedHead: async () => 3, + }); + const pull = createProactiveContentPull(deps); + + await pull.advanceRecoveryFloor('ws-1'); + + expect(deps.pullCalls).toEqual([]); + }); + + it('still materializes an initial first share with no project row, live directory, or receipt', async () => { + const deps = makeDeps({ + getLocalBinding: () => null, + listSharedProjects: async () => [ + { projectId: 'proj-1', ownerMemberId: 'wm-owner' }, + ], + hasMaterializedProject: () => false, + materializedVersion: () => null, + publishedHead: async () => 3, + }); + const pull = createProactiveContentPull(deps); + + await pull.materializeMissingProjects('ws-1', 'proj-1'); + + expect(deps.pullCalls).toEqual(['proj-1']); + }); +}); + +describe('proactive content pull retry coordinator', () => { + it.each(['missing', 'error'] as const)( + 'transfers an identity-%s retry into bounded catalog recovery when owner propagation becomes the blocker', + async (initialIdentityFailure) => { + const retry = makeRetryScheduler(); + let identityReads = 0; + const onCatchUp = vi.fn(); + const deps = makeDeps({ + getLocalBinding: () => null, + getWorkspaceIdentity: async () => { + identityReads += 1; + if (identityReads === 1) { + if (initialIdentityFailure === 'error') { + throw new Error('identity unavailable'); + } + return null; + } + return { + workspaceId: 'ws-1', + resourceTeamId: 'team-1', + workspaceMemberId: 'wm-member', + }; + }, + resolveSharedProjectOwner: async () => null, + listSharedProjects: async () => [], + hasMaterializedProject: () => false, + publishedHead: async () => 3, + onCatchUp, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged(baseEvent); + expect(retry.delays).toEqual([1_000]); + + // The generic identity retry now observes owner-missing and transfers to + // the dedicated project catalog lane instead of retaining both owners. + await retry.runNext(); + for (let attempt = 0; attempt < 5; attempt += 1) { + await retry.runNext(); + } + + expect(retry.delays).toEqual([ + 1_000, + 1_000, + 2_000, + 4_000, + 8_000, + 16_000, + ]); + expect(retry.tasks.size).toBe(0); + expect(onCatchUp).toHaveBeenCalledWith(expect.objectContaining({ + phase: 'retry-exhausted', + mode: 'missing-only', + lane: 'targeted', + projectId: 'proj-1', + attempt: 5, + })); + }, + ); + + it('clears a same-version provisional timer when an external event starts catalog recovery', async () => { + const retry = makeRetryScheduler(); + let identityReads = 0; + const deps = makeDeps({ + getLocalBinding: () => null, + getWorkspaceIdentity: async () => { + identityReads += 1; + return identityReads === 1 + ? null + : { + workspaceId: 'ws-1', + resourceTeamId: 'team-1', + workspaceMemberId: 'wm-member', + }; + }, + resolveSharedProjectOwner: async () => null, + listSharedProjects: async () => [], + hasMaterializedProject: () => false, + publishedHead: async () => 3, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged(baseEvent); + await pull.handleContentChanged(baseEvent); + + expect(retry.cleared).toEqual([1]); + expect(retry.delays).toEqual([1_000, 1_000]); + expect(retry.tasks.size).toBe(1); + }); + + it('retries a first-share content event while the owner catalog row is still propagating', async () => { + const retry = makeRetryScheduler(); + let ownerReads = 0; + let catalogReads = 0; + const deps = makeDeps({ + getLocalBinding: () => null, + resolveSharedProjectOwner: async () => { + ownerReads += 1; + return null; + }, + listSharedProjects: async () => { + catalogReads += 1; + return catalogReads === 1 + ? [] + : [{ projectId: 'proj-1', ownerMemberId: 'wm-owner' }]; + }, + hasMaterializedProject: () => false, + publishedHead: async () => 3, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged(baseEvent); + + expect(deps.pullCalls).toEqual([]); + expect(retry.delays).toEqual([1_000]); + + await retry.runNext(); + + expect(ownerReads).toBe(1); + expect(catalogReads).toBe(2); + expect(deps.pullCalls).toEqual(['proj-1']); + expect(retry.tasks.size).toBe(0); + }); + + it('bounds retries for a first-share event whose catalog owner never appears', async () => { + const retry = makeRetryScheduler(); + const onCatchUp = vi.fn(); + const deps = makeDeps({ + getLocalBinding: () => null, + resolveSharedProjectOwner: async () => null, + listSharedProjects: async () => [], + hasMaterializedProject: () => false, + publishedHead: async () => 3, + onCatchUp, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged(baseEvent); + for (let attempt = 0; attempt < 5; attempt += 1) { + await retry.runNext(); + } + + expect(retry.delays).toEqual([1_000, 2_000, 4_000, 8_000, 16_000]); + expect(retry.tasks.size).toBe(0); + expect(deps.pullCalls).toEqual([]); + expect(onCatchUp).toHaveBeenCalledWith(expect.objectContaining({ + phase: 'retry-exhausted', + mode: 'missing-only', + lane: 'targeted', + workspaceId: 'ws-1', + projectId: 'proj-1', + attempt: 5, + })); + + await pull.handleContentChanged(baseEvent); + + expect(retry.delays).toEqual([ + 1_000, + 2_000, + 4_000, + 8_000, + 16_000, + 1_000, + ]); + expect(retry.tasks.size).toBe(1); + expect(onCatchUp).toHaveBeenLastCalledWith(expect.objectContaining({ + phase: 'retry-scheduled', + mode: 'missing-only', + lane: 'targeted', + workspaceId: 'ws-1', + projectId: 'proj-1', + attempt: 1, + delayMs: 1_000, + })); + }); + + it('does not retry authoritative owner absence for an already-bound project', async () => { + const retry = makeRetryScheduler(); + const deps = makeDeps({ + resolveSharedProjectOwner: async () => null, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged(baseEvent); + + expect(retry.delays).toEqual([]); + expect(retry.tasks.size).toBe(0); + expect(deps.pullCalls).toEqual([]); + }); + + it.each([ + ['missing identity', 'null'], + ['throwing identity lookup', 'throw'], + ] as const)( + 'does not let an in-flight provisional guard with %s erase a freshly guarded same-version event', + async (_label, staleResult) => { + const retry = makeRetryScheduler(); + let identityCalls = 0; + let signalStaleGuardStarted!: () => void; + const staleGuardStarted = new Promise((resolve) => { + signalStaleGuardStarted = resolve; + }); + let resolveStaleGuard!: () => void; + let rejectStaleGuard!: (error: Error) => void; + const staleGuard = new Promise((resolve, reject) => { + resolveStaleGuard = resolve; + rejectStaleGuard = reject; + }); + const deps = makeDeps({ + getWorkspaceIdentity: async () => { + identityCalls += 1; + if (identityCalls === 1) return null; + if (identityCalls === 2) { + signalStaleGuardStarted(); + await staleGuard; + if (staleResult === 'throw') { + throw new Error('stale context read failed'); + } + return null; + } + return { + workspaceId: 'ws-1', + resourceTeamId: 'team-1', + workspaceMemberId: 'wm-member', + }; + }, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged(baseEvent); + expect(retry.tasks.size).toBe(1); + + const staleRetry = retry.runNext(); + await staleGuardStarted; + const freshEvent = pull.handleContentChanged(baseEvent); + await vi.waitFor(() => expect(identityCalls).toBe(3)); + + if (staleResult === 'throw') { + rejectStaleGuard(new Error('release rejected stale guard')); + } else { + resolveStaleGuard(); + } + await Promise.all([staleRetry, freshEvent]); + + expect(deps.pullCalls).toEqual(['proj-1']); + expect(retry.tasks.size).toBe(0); + }, + ); + + it('wakes immediately when a same-version event resolves a provisional guard retry', async () => { + const retry = makeRetryScheduler(); + let identityAvailable = false; + const deps = makeDeps({ + getWorkspaceIdentity: async () => identityAvailable + ? { + workspaceId: 'ws-1', + resourceTeamId: 'team-1', + workspaceMemberId: 'wm-member', + } + : null, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged(baseEvent); + expect(deps.pullCalls).toEqual([]); + expect(retry.delays).toEqual([1_000]); + expect(retry.tasks.size).toBe(1); + + identityAvailable = true; + await pull.handleContentChanged(baseEvent); + + expect(retry.cleared).toHaveLength(1); + expect(deps.pullCalls).toEqual(['proj-1']); + expect(retry.tasks.size).toBe(0); + }); + + it('merges live and full-sweep failures after both guard to the same final scope', async () => { + const retry = makeRetryScheduler(); + const deps = makeDeps({ + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + throw new Error('transport down'); + }, + listSharedProjects: async () => [ + { projectId: 'proj-1', ownerMemberId: 'wm-owner' }, + ], + publishedHead: async () => 3, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged(baseEvent); + await pull.catchUpPublishedHeads('ws-1'); + + expect(deps.pullCalls).toEqual(['proj-1']); + expect(retry.delays).toEqual([1_000]); + expect(retry.tasks.size).toBe(1); + }); + + it('treats an inner manifest hit as removing force, not as satisfying a newer head', async () => { + let probes = 0; + let pullAttempt = 0; + const deps = makeDeps({ + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + pullAttempt += 1; + return { status: 'pulled', version: pullAttempt }; + }, + listSharedProjects: async () => [ + { projectId: 'proj-1', ownerMemberId: 'wm-owner' }, + ], + hasMaterializedProject: () => { + probes += 1; + return probes === 2; + }, + publishedHead: async () => 2, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged({ ...baseEvent, version: 1 }); + await pull.materializeMissingProjects('ws-1'); + + expect(probes).toBe(2); + expect(deps.pullCalls).toEqual(['proj-1', 'proj-1']); + }); + + it('clears an established retry when the active team identity disappears', async () => { + const retry = makeRetryScheduler(); + let identityAvailable = true; + const deps = makeDeps({ + getWorkspaceIdentity: async () => identityAvailable + ? { + workspaceId: 'ws-1', + resourceTeamId: 'team-1', + workspaceMemberId: 'wm-member', + } + : null, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + throw new Error('transport down'); + }, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged(baseEvent); + identityAvailable = false; + await retry.runNext(); + + expect(deps.pullCalls).toEqual(['proj-1']); + expect(retry.tasks.size).toBe(0); + }); + + it('clears an established retry when owner resolution confirms the project is gone', async () => { + const retry = makeRetryScheduler(); + let owner: string | null = 'wm-owner'; + const deps = makeDeps({ + resolveSharedProjectOwner: async () => owner, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + throw new Error('transport down'); + }, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged(baseEvent); + owner = null; + await retry.runNext(); + + expect(deps.pullCalls).toEqual(['proj-1']); + expect(retry.tasks.size).toBe(0); + }); + + it('keeps an established retry when owner resolution throws transiently', async () => { + const retry = makeRetryScheduler(); + let ownerLookupThrows = false; + const deps = makeDeps({ + resolveSharedProjectOwner: async () => { + if (ownerLookupThrows) throw new Error('hub unavailable'); + return 'wm-owner'; + }, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + throw new Error('transport down'); + }, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged(baseEvent); + ownerLookupThrows = true; + await retry.runNext(); + + expect(deps.pullCalls).toEqual(['proj-1']); + expect(retry.delays).toEqual([1_000, 2_000]); + expect(retry.tasks.size).toBe(1); + }); + + it('retries an unknown-version event when pull reports an unknown version', async () => { + const retry = makeRetryScheduler(); + const deps = makeDeps({ + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + return { status: 'pulled', version: null }; + }, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged({ + projectId: 'proj-1', + workspaceId: 'ws-1', + }); + + expect(deps.pullCalls).toEqual(['proj-1']); + expect(retry.delays).toEqual([1_000]); + expect(retry.tasks.size).toBe(1); + }); + + it.each([ + ['thrown transport failure', 'throw'], + ['null transport result', 'null'], + ['register failure', 'register_failed'], + ['unknown materialized version', 'version_null'], + ['materialized version below the desired head', 'version_low'], + ] as const)('retries after %s and eventually covers the desired version', async (_label, firstResult) => { + const retry = makeRetryScheduler(); + let attempt = 0; + const deps = makeDeps({ + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + attempt += 1; + if (attempt > 1) return { status: 'pulled', version: 3 }; + if (firstResult === 'throw') throw new Error('transport down'); + if (firstResult === 'null') return null as never; + if (firstResult === 'register_failed') return { status: 'register_failed' }; + if (firstResult === 'version_null') return { status: 'pulled', version: null }; + return { status: 'pulled', version: 2 }; + }, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged(baseEvent); + + expect(deps.pullCalls).toEqual(['proj-1']); + expect(retry.delays).toEqual([1_000]); + expect([...retry.tasks.values()][0]?.handle.unref).toHaveBeenCalledTimes(1); + + await retry.runNext(); + await vi.waitFor(() => expect(deps.pullCalls).toEqual(['proj-1', 'proj-1'])); + expect(retry.tasks.size).toBe(0); + }); + + it('keeps backoff for same/older events but wakes immediately for a higher version', async () => { + const retry = makeRetryScheduler(); + let succeedAtVersion = 4; + const deps = makeDeps({ + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + if (deps.pullCalls.length < succeedAtVersion) throw new Error('still down'); + return { status: 'pulled', version: 2 }; + }, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged({ ...baseEvent, version: 1 }); + await retry.runNext(); + expect(retry.delays).toEqual([1_000, 2_000]); + expect(deps.pullCalls).toHaveLength(2); + + await pull.handleContentChanged({ ...baseEvent, version: 1 }); + await pull.handleContentChanged({ ...baseEvent, version: 0 }); + expect(deps.pullCalls).toHaveLength(2); + expect(retry.delays).toEqual([1_000, 2_000]); + + succeedAtVersion = 3; + await pull.handleContentChanged({ ...baseEvent, version: 2 }); + expect(deps.pullCalls).toHaveLength(3); + expect(retry.cleared).toHaveLength(1); + expect(retry.tasks.size).toBe(0); + }); + + it('caps equal-jitter retry backoff at 30 seconds', async () => { + const retry = makeRetryScheduler(); + const deps = makeDeps({ + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + throw new Error('still down'); + }, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged(baseEvent); + for (let index = 0; index < 7; index += 1) { + await retry.runNext(); + } + + expect(retry.delays).toEqual([ + 1_000, + 2_000, + 4_000, + 8_000, + 16_000, + 30_000, + 30_000, + 30_000, + ]); + }); + + it('runs different projects independently while one transport is blocked', async () => { + let releaseFirst!: () => void; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + const deps = makeDeps({ + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + if (target.projectId === 'proj-1') await firstGate; + return { status: 'pulled', version: 3 }; + }, + }); + const pull = createProactiveContentPull(deps); + + const first = pull.handleContentChanged(baseEvent); + await vi.waitFor(() => expect(deps.pullCalls).toEqual(['proj-1'])); + await pull.handleContentChanged({ ...baseEvent, projectId: 'proj-2' }); + + expect(deps.pullCalls).toEqual(['proj-1', 'proj-2']); + releaseFirst(); + await first; + }); + + it.each([ + ['workspace scope changes', 'scope'], + ['binding becomes personal', 'personal'], + ['viewer becomes the owner', 'self-owner'], + ['resource owner changes', 'owner-drift'], + ] as const)('stops retries when %s', async (_label, change) => { + const retry = makeRetryScheduler(); + let workspaceId = 'ws-1'; + let visibility: 'personal' | 'team' = 'team'; + let owner = 'wm-owner'; + const deps = makeDeps({ + getLocalBinding: () => ({ workspaceId: 'ws-1', visibility }), + getWorkspaceIdentity: async () => ({ + workspaceId, + resourceTeamId: `team-${workspaceId}`, + workspaceMemberId: 'wm-member', + }), + resolveSharedProjectOwner: async () => owner, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + throw new Error('retry me'); + }, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged(baseEvent); + if (change === 'scope') workspaceId = 'ws-other'; + if (change === 'personal') visibility = 'personal'; + if (change === 'self-owner') owner = 'wm-member'; + if (change === 'owner-drift') owner = 'wm-new-owner'; + await retry.runNext(); + + expect(deps.pullCalls).toEqual(['proj-1']); + expect(retry.tasks.size).toBe(0); + }); + + it('stops retrying after the resource is revoked', async () => { + const retry = makeRetryScheduler(); + let attempt = 0; + const deps = makeDeps({ + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + attempt += 1; + if (attempt === 1) throw new Error('retry me'); + return { status: 'revoked' }; + }, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged(baseEvent); + await retry.runNext(); + + expect(deps.pullCalls).toEqual(['proj-1', 'proj-1']); + expect(retry.tasks.size).toBe(0); + }); + + it('dispose cancels pending retry timers', async () => { + const retry = makeRetryScheduler(); + const deps = makeDeps({ + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + throw new Error('retry me'); + }, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.handleContentChanged(baseEvent); + (pull as ProactiveContentPull & { dispose(): void }).dispose(); + + expect(retry.cleared).toHaveLength(1); + expect(retry.tasks.size).toBe(0); + expect(deps.pullCalls).toEqual(['proj-1']); + }); + + it('dispose cancels a pending catalog-sweep retry timer', async () => { + const retry = makeRetryScheduler(); + const deps = makeDeps({ + listSharedProjects: async () => { + throw new Error('catalog unavailable'); + }, + hasMaterializedProject: () => false, + publishedHead: async () => 3, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.materializeMissingProjects('ws-1'); + pull.dispose(); + + expect(retry.cleared).toHaveLength(1); + expect(retry.tasks.size).toBe(0); + }); + + it('does not schedule a catalog retry after dispose wins an in-flight sweep race', async () => { + const retry = makeRetryScheduler(); + let catalogStarted!: () => void; + let releaseCatalog!: () => void; + const started = new Promise((resolve) => { + catalogStarted = resolve; + }); + const gate = new Promise((resolve) => { + releaseCatalog = resolve; + }); + const deps = makeDeps({ + getLocalBinding: () => null, + listSharedProjects: async () => { + catalogStarted(); + await gate; + return []; + }, + hasMaterializedProject: () => false, + publishedHead: async () => 3, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + const sweep = pull.materializeMissingProjects('ws-1', 'project-a'); + await started; + pull.dispose(); + releaseCatalog(); + await sweep; + + expect(retry.delays).toEqual([]); + expect(retry.tasks.size).toBe(0); + }); + + it('does not treat a manifest appearing before retry as proof of the desired version', async () => { + const retry = makeRetryScheduler(); + let materialized = false; + let attempt = 0; + const deps = makeDeps({ + getLocalBinding: () => null, + pullSharedProject: async (target) => { + deps.pullCalls.push(target.projectId); + attempt += 1; + if (attempt === 1) throw new Error('retry me'); + return { status: 'pulled', version: 3 }; + }, + listSharedProjects: async () => [ + { projectId: 'proj-1', ownerMemberId: 'wm-owner' }, + ], + hasMaterializedProject: () => materialized, + publishedHead: async () => 3, + }); + Object.assign(deps, { + scheduler: retry.scheduler, + random: () => 1, + }); + const pull = createProactiveContentPull(deps); + + await pull.materializeMissingProjects('ws-1', 'proj-1'); + materialized = true; + await retry.runNext(); + + expect(deps.pullCalls).toEqual(['proj-1', 'proj-1']); + expect(retry.tasks.size).toBe(0); + }); + + it('serializes different scopes for one project and re-guards after waiting', async () => { + let releaseFirst!: () => void; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + let activeWorkspaceId = 'ws-1'; + let identityReads = 0; + let active = 0; + let maxActive = 0; + const scopes: string[] = []; + const deps = makeDeps({ + getLocalBinding: () => null, + getWorkspaceIdentity: async () => { + identityReads += 1; + return { + workspaceId: activeWorkspaceId, + resourceTeamId: `team-${activeWorkspaceId}`, + workspaceMemberId: 'wm-member', + }; + }, + pullSharedProject: async (target) => { + scopes.push(target.workspaceId); + active += 1; + maxActive = Math.max(maxActive, active); + try { + if (target.workspaceId === 'ws-1') await firstGate; + return { status: 'pulled', version: 1 }; + } finally { + active -= 1; + } + }, + }); + const pull = createProactiveContentPull(deps); + + const first = pull.handleContentChanged({ + projectId: 'proj-1', + workspaceId: 'ws-1', + version: 1, + }); + await vi.waitFor(() => expect(scopes).toEqual(['ws-1'])); + activeWorkspaceId = 'ws-2'; + const second = pull.handleContentChanged({ + projectId: 'proj-1', + workspaceId: 'ws-2', + version: 1, + }); + await vi.waitFor(() => expect(identityReads).toBe(2)); + + expect(scopes).toEqual(['ws-1']); + expect(maxActive).toBe(1); + releaseFirst(); + await Promise.all([first, second]); + + expect(scopes).toEqual(['ws-1', 'ws-2']); + expect(maxActive).toBe(1); + // The second scope guarded once before waiting, then guarded again after + // the foreign-scope completion instead of trusting that outcome. + expect(identityReads).toBe(3); + }); + + it('brands the exact in-flight version and invalidates it when a newer event arrives', async () => { + let releaseFirst!: () => void; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + const targets: ProactiveContentPullTarget[] = []; + const deps = makeDeps({ + pullSharedProject: async (target, expectedVersion) => { + targets.push(target); + if (expectedVersion === 3) await firstGate; + return { status: 'pulled', version: expectedVersion ?? null }; + }, + }); + const pull = createProactiveContentPull(deps); + + const first = pull.handleContentChanged(baseEvent); + await vi.waitFor(() => expect(targets).toHaveLength(1)); + const firstInvocation = targets[0]!.authorizedStageInvocation; + expect(isAuthorizedProactivePullInvocation( + firstInvocation, + targets[0]!, + 3, + )).toBe(true); + + const second = pull.handleContentChanged({ ...baseEvent, version: 4 }); + await vi.waitFor(() => { + expect(firstInvocation?.isStillExpected()).toBe(false); + }); + releaseFirst(); + await Promise.all([first, second]); + + expect(targets.map((target) => target.authorizedStageInvocation?.expectedVersion)) + .toEqual([3, 4]); + expect(isAuthorizedProactivePullInvocation( + { ...targets[1]!.authorizedStageInvocation! }, + targets[1]!, + 4, + )).toBe(false); + }); + + it('aborts an in-flight authorized stage immediately when a newer version arrives', async () => { + const versions: number[] = []; + let firstSignal: AbortSignal | undefined; + const deps = makeDeps({ + pullSharedProject: async (target, expectedVersion) => { + versions.push(expectedVersion!); + if (expectedVersion === 3) { + firstSignal = target.authorizedStageInvocation?.signal; + await new Promise((resolve) => { + firstSignal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }); + return { status: 'register_failed' }; + } + return { status: 'pulled', version: expectedVersion! }; + }, + }); + const pull = createProactiveContentPull(deps); + + const first = pull.handleContentChanged(baseEvent); + await vi.waitFor(() => expect(versions).toEqual([3])); + const second = pull.handleContentChanged({ ...baseEvent, version: 4 }); + + await vi.waitFor(() => expect(firstSignal?.aborted).toBe(true)); + await Promise.all([first, second]); + expect(versions).toEqual([3, 4]); + }); +}); diff --git a/apps/daemon/tests/collab/proactive-pull-authorization.test.ts b/apps/daemon/tests/collab/proactive-pull-authorization.test.ts new file mode 100644 index 00000000000..5b862443c38 --- /dev/null +++ b/apps/daemon/tests/collab/proactive-pull-authorization.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { + createProactiveContentPull, + isFreshProactivePullAuthorizationWitness, + type ProactiveContentPullTarget, + type ProactivePullAuthorizationScope, + type ProactivePullAuthorizationWitness, +} from '../../src/collab/proactive-content-pull.js'; + +const scope: ProactivePullAuthorizationScope = { + projectId: 'project-1', + workspaceId: 'workspace-1', + resourceTeamId: 'team-1', + viewerMemberId: 'viewer-1', + ownerMemberId: 'owner-1', +}; + +async function mintWitness( + version = 7, +): Promise { + const targets: ProactiveContentPullTarget[] = []; + const pull = createProactiveContentPull({ + getLocalBinding: () => ({ + workspaceId: scope.workspaceId, + visibility: 'team', + }), + getWorkspaceIdentity: async () => ({ + workspaceId: scope.workspaceId, + resourceTeamId: scope.resourceTeamId, + workspaceMemberId: scope.viewerMemberId, + }), + resolveSharedProjectOwner: async () => scope.ownerMemberId, + pullSharedProject: async (input) => { + targets.push(input); + return { status: 'pulled', version }; + }, + }); + await pull.handleContentChanged({ + projectId: scope.projectId, + workspaceId: scope.workspaceId, + version, + }); + const target = targets[0]; + if (!target?.authorizationWitness) { + throw new Error('expected proactive guard to issue a witness'); + } + return target.authorizationWitness; +} + +describe('proactive pull authorization witness', () => { + it('accepts only a fresh witness bound to the exact scope and version', async () => { + const witness = await mintWitness(); + + expect( + isFreshProactivePullAuthorizationWitness( + witness, + scope, + 7, + witness.verifiedAtMs + 4_999, + ), + ).toBe(true); + expect( + isFreshProactivePullAuthorizationWitness( + witness, + scope, + 7, + witness.verifiedAtMs + 5_001, + ), + ).toBe(false); + expect( + isFreshProactivePullAuthorizationWitness( + witness, + { ...scope, ownerMemberId: 'other-owner' }, + 7, + witness.verifiedAtMs + 1, + ), + ).toBe(false); + expect( + isFreshProactivePullAuthorizationWitness( + witness, + scope, + 8, + witness.verifiedAtMs + 1, + ), + ).toBe(false); + }); + + it('rejects copied or hand-built objects that did not come from the issuer', async () => { + const witness = await mintWitness(); + const serializedCopy = JSON.parse( + JSON.stringify(witness), + ) as ProactivePullAuthorizationWitness; + const spreadCopy = { ...witness }; + const reflectedCopy = Object.fromEntries( + Reflect.ownKeys(witness).map((key) => [key, Reflect.get(witness, key)]), + ) as unknown as ProactivePullAuthorizationWitness; + + for (const copy of [serializedCopy, spreadCopy, reflectedCopy]) { + expect( + isFreshProactivePullAuthorizationWitness( + copy, + scope, + 7, + witness.verifiedAtMs + 1, + ), + ).toBe(false); + } + }); +}); diff --git a/apps/daemon/tests/collab/project-content-transfer-state.test.ts b/apps/daemon/tests/collab/project-content-transfer-state.test.ts new file mode 100644 index 00000000000..48d3300842b --- /dev/null +++ b/apps/daemon/tests/collab/project-content-transfer-state.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createProjectContentTransferStateStore, + type ProjectContentTransferScope, +} from '../../src/collab/project-content-transfer-state.js'; + +const scope = ( + overrides: Partial = {}, +): ProjectContentTransferScope => ({ + projectId: 'project-1', + workspaceId: 'workspace-1', + resourceTeamId: 'team-1', + viewerMemberId: 'viewer-1', + ownerMemberId: 'owner-1', + ...overrides, +}); + +describe('project content transfer state', () => { + it('publishes downloading immediately and retains an idle reconnect snapshot', () => { + const onChange = vi.fn(); + let clock = 100; + const store = createProjectContentTransferStateStore({ + now: () => clock, + onChange, + }); + + const transferScope = scope(); + const started = store.begin(transferScope, 7); + expect(started.state).toEqual({ + status: 'downloading', + version: 7, + startedAt: 100, + updatedAt: 100, + }); + expect(store.read(transferScope)).toEqual(started.state); + + clock = 200; + const idle = store.finish(transferScope, started.token, 7); + expect(idle).toEqual({ + status: 'idle', + version: 7, + startedAt: 100, + updatedAt: 200, + }); + expect(store.read(transferScope)).toEqual(idle); + expect(onChange).toHaveBeenCalledTimes(2); + }); + + it('does not let an older or versionless completion hide a newer transfer', () => { + let clock = 100; + const store = createProjectContentTransferStateStore({ now: () => clock }); + const transferScope = scope(); + const older = store.begin(transferScope); + clock = 101; + const newer = store.begin(transferScope, 8); + clock = 102; + + expect(store.finish(transferScope, older.token)).toBe(newer.state); + expect(store.finish(transferScope, older.token, 7)).toBe(newer.state); + expect(store.read(transferScope)).toMatchObject({ + status: 'downloading', + version: 8, + }); + }); + + it('isolates identical project ids by workspace, resource team, viewer, and owner', () => { + const store = createProjectContentTransferStateStore(); + const firstScope = scope(); + const otherScope = scope({ + workspaceId: 'workspace-2', + resourceTeamId: 'team-2', + viewerMemberId: 'viewer-2', + ownerMemberId: 'owner-2', + }); + const first = store.begin(firstScope, 7); + const other = store.begin(otherScope, 9); + + expect(store.finish(otherScope, first.token, 7)).toBe(other.state); + expect(store.read(firstScope)).toBe(first.state); + expect(store.read(otherScope)).toBe(other.state); + + expect(store.finish(firstScope, first.token, 7)).toMatchObject({ + status: 'idle', + version: 7, + }); + expect(store.read(otherScope)).toBe(other.state); + }); + + it('uses monotonic timestamps when transitions share a wall-clock tick', () => { + const store = createProjectContentTransferStateStore({ now: () => 100 }); + const transferScope = scope(); + + const started = store.begin(transferScope, 1); + const idle = store.finish(transferScope, started.token, 1); + + expect(idle?.updatedAt).toBeGreaterThan(started.state.updatedAt); + }); +}); diff --git a/apps/daemon/tests/collab/project-request-authority.test.ts b/apps/daemon/tests/collab/project-request-authority.test.ts new file mode 100644 index 00000000000..a4e6e5c4fe6 --- /dev/null +++ b/apps/daemon/tests/collab/project-request-authority.test.ts @@ -0,0 +1,458 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createAuthorizeProjectRequest } from '../../src/collab/project-request-authority.js'; +import { verifyWorkspaceRequestContext } from '../../src/collab/request-workspace-context.js'; +import { createCachedWorkspaceDirectoryFetcher } from '../../src/collab/vela-workspace-context.js'; + +function response() { + return {} as any; +} + +function request(input: { + workspaceId?: string; + memberId?: string; + query?: Record; +}) { + const headers: Record = { + 'x-od-workspace-id': input.workspaceId, + 'x-od-workspace-member-id': input.memberId, + }; + return { + query: input.query ?? {}, + get(name: string) { + return headers[name.toLowerCase()]; + }, + }; +} + +function context(overrides: Record = {}) { + return { + workspaceId: 'workspace-a', + workspaceName: 'A', + workspaceType: 'team' as const, + workspaceMemberId: 'member-a', + role: 'member' as const, + memberStatus: 'active' as const, + lifecycleState: 'active' as const, + billingState: 'active' as const, + planId: 'team_plus', + providerMode: 'platform_credits' as const, + seatSummary: { + seatLimit: 5, + usedSeats: 2, + availableSeats: 3, + isSeatFull: false, + }, + permissions: { + canManageMembers: false, + canManageBilling: false, + canInviteMembers: false, + canManageAutoRecharge: false, + canShareProjects: true, + canWriteSyncedFiles: true, + canViewWorkspaceSettings: true, + canManageSharedResources: false, + }, + ...overrides, + }; +} + +describe('createAuthorizeProjectRequest', () => { + it('uses the bounded read verifier without weakening fresh mutation authority', async () => { + const row = { + workspaceId: 'workspace-a', + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: 'member-a', + }; + const directoryResult = { + ok: true as const, + items: [{ + workspaceId: 'workspace-a', + workspaceName: 'A', + workspaceType: 'team' as const, + workspaceMemberId: 'member-a', + role: 'member' as const, + memberStatus: 'active' as const, + lifecycleState: 'active' as const, + }], + }; + const fetchReadDirectory = vi.fn(async () => directoryResult); + const cachedReadDirectory = createCachedWorkspaceDirectoryFetcher({ + fetchDirectory: fetchReadDirectory, + identityKey: () => 'account-a:config-a', + ttlMs: 5_000, + }); + const fetchFreshMutationDirectory = vi.fn(async () => directoryResult); + const readVerifier = vi.fn((req: unknown) => + verifyWorkspaceRequestContext({ + req, + fetchWorkspaceDirectory: cachedReadDirectory, + })); + const mutationVerifier = vi.fn((req: unknown) => + verifyWorkspaceRequestContext({ + req, + fetchWorkspaceDirectory: fetchFreshMutationDirectory, + })); + const authorize = createAuthorizeProjectRequest({ + db: {}, + getWorkspaceProject: () => row, + getWorkspaceProjectByProjectId: () => row, + verifyWorkspaceReadAuthority: readVerifier, + verifyWorkspaceRequestAuthority: mutationVerifier, + sendApiError: vi.fn(), + }); + const req = request({ + workspaceId: 'workspace-a', + memberId: 'member-a', + }); + + for (let index = 0; index < 6; index += 1) { + await expect(authorize(req, response(), 'project-a', { mode: 'read' })) + .resolves.toBe(true); + } + expect(readVerifier).toHaveBeenCalledTimes(6); + expect(fetchReadDirectory).toHaveBeenCalledTimes(1); + expect(mutationVerifier).not.toHaveBeenCalled(); + + for (let index = 0; index < 2; index += 1) { + await expect(authorize( + req, + response(), + 'project-a', + { mode: 'write', capability: 'writeFiles' }, + )).resolves.toBe(true); + } + expect(readVerifier).toHaveBeenCalledTimes(6); + expect(fetchReadDirectory).toHaveBeenCalledTimes(1); + expect(mutationVerifier).toHaveBeenCalledTimes(2); + expect(fetchFreshMutationDirectory).toHaveBeenCalledTimes(2); + }); + + it('preserves unbound legacy reads and writes without consulting authority', async () => { + const verify = vi.fn(); + const sendApiError = vi.fn(); + const authorize = createAuthorizeProjectRequest({ + db: {}, + getWorkspaceProject: () => null, + getWorkspaceProjectByProjectId: () => null, + verifyWorkspaceRequestAuthority: verify, + sendApiError, + }); + + await expect(authorize(request({}), response(), 'legacy', { mode: 'read' })) + .resolves.toBe(true); + await expect(authorize( + request({}), + response(), + 'legacy', + { mode: 'write', capability: 'writeFiles' }, + )).resolves.toBe(true); + expect(verify).not.toHaveBeenCalled(); + expect(sendApiError).not.toHaveBeenCalled(); + }); + + it('does not disclose a bound project to a headerless bootstrap request', async () => { + const row = { + workspaceId: 'workspace-a', + visibility: 'personal', + resourceState: 'active', + }; + const verify = vi.fn(async () => ({ + ok: false as const, + status: 400 as const, + code: 'WORKSPACE_CONTEXT_REQUIRED', + message: 'an explicit workspace context is required', + })); + const sendApiError = vi.fn(); + const authorize = createAuthorizeProjectRequest({ + db: {}, + getWorkspaceProject: () => row, + getWorkspaceProjectByProjectId: () => row, + verifyWorkspaceRequestAuthority: verify, + sendApiError, + }); + + await expect(authorize( + request({}), + response(), + 'project-a', + { mode: 'read' }, + )).resolves.toBe(false); + expect(verify).toHaveBeenCalledTimes(1); + expect(sendApiError).toHaveBeenCalledWith( + expect.anything(), + 400, + 'WORKSPACE_CONTEXT_REQUIRED', + expect.any(String), + expect.any(Object), + ); + }); + + it('keeps locked/frozen Team projects readable but rejects writes', async () => { + const row = { + visibility: 'team', + resourceState: 'frozen', + createdByWorkspaceMemberId: 'member-a', + }; + const sendApiError = vi.fn(); + const authorize = createAuthorizeProjectRequest({ + db: {}, + getWorkspaceProject: (_db, workspaceId) => + workspaceId === 'workspace-a' ? row : null, + getWorkspaceProjectByProjectId: () => row, + verifyWorkspaceRequestAuthority: async () => ({ + ok: true, + context: context({ lifecycleState: 'locked' }), + }), + sendApiError, + }); + const req = request({ workspaceId: 'workspace-a', memberId: 'member-a' }); + + await expect(authorize(req, response(), 'project-a', { mode: 'read' })) + .resolves.toBe(true); + await expect(authorize( + req, + response(), + 'project-a', + { mode: 'write', capability: 'writeFiles' }, + )).resolves.toBe(false); + expect(sendApiError).toHaveBeenLastCalledWith( + expect.anything(), + 403, + 'WORKSPACE_LOCKED', + expect.any(String), + ); + }); + + it.each(['rename', 'delete', 'duplicate', 'writeFiles'] as const)( + 'keeps shared-project %s single-writer even for a Team workspace owner', + async (capability) => { + const row = { + workspaceId: 'workspace-a', + visibility: 'team', + resourceState: 'active', + createdByWorkspaceMemberId: 'project-owner', + }; + const sendApiError = vi.fn(); + const authorize = createAuthorizeProjectRequest({ + db: {}, + getWorkspaceProject: () => row, + getWorkspaceProjectByProjectId: () => row, + verifyWorkspaceRequestAuthority: async () => ({ + ok: true, + context: context({ + workspaceMemberId: 'workspace-owner', + role: 'owner', + }), + }), + sendApiError, + }); + + await expect(authorize( + request({ workspaceId: 'workspace-a', memberId: 'workspace-owner' }), + response(), + 'project-a', + { mode: 'write', capability }, + )).resolves.toBe(false); + expect(sendApiError).toHaveBeenLastCalledWith( + expect.anything(), + 403, + 'WORKSPACE_PROJECT_PERMISSION_DENIED', + expect.any(String), + ); + }, + ); + + it('preserves shared-project comments for active Team viewers', async () => { + const row = { + workspaceId: 'workspace-a', + visibility: 'team', + resourceState: 'active', + createdByWorkspaceMemberId: 'project-owner', + }; + const authorize = createAuthorizeProjectRequest({ + db: {}, + getWorkspaceProject: () => row, + getWorkspaceProjectByProjectId: () => row, + verifyWorkspaceRequestAuthority: async () => ({ + ok: true, + context: context({ + workspaceMemberId: 'workspace-owner', + role: 'owner', + }), + }), + sendApiError: vi.fn(), + }); + + await expect(authorize( + request({ workspaceId: 'workspace-a', memberId: 'workspace-owner' }), + response(), + 'project-a', + { mode: 'write', capability: 'comment' }, + )).resolves.toBe(true); + }); + + it.each([ + [ + 'the shared-project owner', + { + visibility: 'team', + resourceState: 'active', + createdByWorkspaceMemberId: 'member-a', + }, + context({ role: 'member' }), + ], + [ + 'a Workspace owner on a personal project', + { + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: 'another-member', + }, + context({ role: 'owner' }), + ], + ])('preserves write access for %s', async (_label, row, verifiedContext) => { + const authorize = createAuthorizeProjectRequest({ + db: {}, + getWorkspaceProject: () => row, + getWorkspaceProjectByProjectId: () => row, + verifyWorkspaceRequestAuthority: async () => ({ + ok: true, + context: verifiedContext, + }), + sendApiError: vi.fn(), + }); + + await expect(authorize( + request({ workspaceId: 'workspace-a', memberId: 'member-a' }), + response(), + 'project-a', + { mode: 'write', capability: 'writeFiles' }, + )).resolves.toBe(true); + }); + + it.each([ + [ + 'removed member', + { ok: false as const, status: 403 as const, code: 'WORKSPACE_MEMBER_REMOVED', message: 'removed' }, + ], + [ + 'authority outage', + { + ok: false as const, + status: 503 as const, + code: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + message: 'unavailable', + retryable: true as const, + }, + ], + ])('fails a bound read closed for %s', async (_label, result) => { + const row = { visibility: 'team', resourceState: 'active' }; + const sendApiError = vi.fn(); + const authorize = createAuthorizeProjectRequest({ + db: {}, + getWorkspaceProject: () => row, + getWorkspaceProjectByProjectId: () => row, + verifyWorkspaceRequestAuthority: async () => result, + sendApiError, + }); + + await expect(authorize( + request({ workspaceId: 'workspace-a', memberId: 'member-a' }), + response(), + 'project-a', + { mode: 'read' }, + )).resolves.toBe(false); + expect(sendApiError).toHaveBeenCalledWith( + expect.anything(), + result.status, + result.code, + result.message, + expect.any(Object), + ); + }); + + it('denies a freshly verified cross-Workspace identity', async () => { + const row = { visibility: 'team', resourceState: 'active' }; + const sendApiError = vi.fn(); + const authorize = createAuthorizeProjectRequest({ + db: {}, + getWorkspaceProject: (_db, workspaceId) => + workspaceId === 'workspace-a' ? row : null, + getWorkspaceProjectByProjectId: () => row, + verifyWorkspaceRequestAuthority: async () => ({ + ok: true, + context: context({ + workspaceId: 'workspace-b', + workspaceMemberId: 'member-b', + }), + }), + sendApiError, + }); + + await expect(authorize( + request({ workspaceId: 'workspace-b', memberId: 'member-b' }), + response(), + 'project-a', + { mode: 'read' }, + )).resolves.toBe(false); + expect(sendApiError).toHaveBeenCalledWith( + expect.anything(), + 403, + 'WORKSPACE_PROJECT_PERMISSION_DENIED', + expect.any(String), + ); + }); + + it('accepts an exact navigation query pair and rejects header/query conflict', async () => { + const row = { visibility: 'team', resourceState: 'active' }; + const verify = vi.fn(async (req: any) => ({ + ok: true as const, + context: context({ + workspaceId: req.get('x-od-workspace-id'), + workspaceMemberId: req.get('x-od-workspace-member-id'), + }), + })); + const sendApiError = vi.fn(); + const authorize = createAuthorizeProjectRequest({ + db: {}, + getWorkspaceProject: (_db, workspaceId) => + workspaceId === 'workspace-a' ? row : null, + getWorkspaceProjectByProjectId: () => row, + verifyWorkspaceRequestAuthority: verify, + sendApiError, + }); + + await expect(authorize( + request({ + query: { + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }, + }), + response(), + 'project-a', + { mode: 'read', allowNavigationQuery: true }, + )).resolves.toBe(true); + + await expect(authorize( + request({ + workspaceId: 'workspace-b', + memberId: 'member-b', + query: { + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }, + }), + response(), + 'project-a', + { mode: 'read', allowNavigationQuery: true }, + )).resolves.toBe(false); + expect(sendApiError).toHaveBeenLastCalledWith( + expect.anything(), + 400, + 'WORKSPACE_CONTEXT_CONFLICT', + expect.any(String), + ); + }); +}); diff --git a/apps/daemon/tests/collab/project-workspace-scope.test.ts b/apps/daemon/tests/collab/project-workspace-scope.test.ts new file mode 100644 index 00000000000..1f8e08110e8 --- /dev/null +++ b/apps/daemon/tests/collab/project-workspace-scope.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from 'vitest'; + +import { + resolveProjectWorkspaceScope, + resolveProjectWorkspaceScopeBootstrap, +} from '../../src/collab/project-workspace-scope.js'; + +const directoryItems = [ + { + workspaceId: 'workspace-b', + workspaceName: 'Workspace B', + workspaceType: 'team' as const, + workspaceMemberId: 'member-b', + role: 'member' as const, + memberStatus: 'active' as const, + lifecycleState: 'active' as const, + }, + { + workspaceId: 'workspace-a', + workspaceName: 'Workspace A', + workspaceType: 'team' as const, + workspaceMemberId: 'member-a', + role: 'owner' as const, + memberStatus: 'active' as const, + lifecycleState: 'active' as const, + }, +]; + +describe('resolveProjectWorkspaceScope', () => { + it('resolves the project binding rather than the first or active directory workspace', () => { + const scope = resolveProjectWorkspaceScope({ + projectId: 'project-a', + binding: { + workspaceId: 'workspace-a', + visibility: 'personal', + }, + directory: { ok: true, items: directoryItems }, + }); + + expect(scope).toMatchObject({ + kind: 'team', + projectId: 'project-a', + workspaceId: 'workspace-a', + visibility: 'personal', + context: { + workspaceId: 'workspace-a', + workspaceType: 'team', + workspaceMemberId: 'member-a', + }, + }); + }); + + it('tags a personal binding as account billing even though it has a workspace id', () => { + const scope = resolveProjectWorkspaceScope({ + projectId: 'project-personal', + binding: { + workspaceId: 'workspace-personal', + visibility: 'personal', + }, + directory: { + ok: true, + items: [{ + workspaceId: 'workspace-personal', + workspaceName: 'Personal', + workspaceType: 'personal', + workspaceMemberId: 'member-personal', + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + }], + }, + }); + + expect(scope).toMatchObject({ + kind: 'personal', + workspaceId: 'workspace-personal', + context: { + workspaceType: 'personal', + workspaceMemberId: 'member-personal', + }, + }); + }); + + it('does not fall back to another workspace when directory membership is unavailable', () => { + const scope = resolveProjectWorkspaceScope({ + projectId: 'project-a', + binding: { + workspaceId: 'workspace-a', + visibility: 'team', + }, + directory: { ok: false, items: directoryItems }, + }); + + expect(scope).toEqual({ + kind: 'unavailable', + projectId: 'project-a', + workspaceId: 'workspace-a', + visibility: 'team', + context: null, + }); + }); + + it('keeps locked/frozen workspaces readable while write permissions stay disabled', () => { + const scope = resolveProjectWorkspaceScope({ + projectId: 'project-a', + binding: { + workspaceId: 'workspace-a', + visibility: 'personal', + }, + directory: { + ok: true, + items: [{ + ...directoryItems[1]!, + lifecycleState: 'locked', + }], + }, + }); + + expect(scope).toMatchObject({ + kind: 'team', + projectId: 'project-a', + workspaceId: 'workspace-a', + visibility: 'personal', + context: { + lifecycleState: 'locked', + permissions: { + canShareProjects: false, + canWriteSyncedFiles: false, + }, + }, + }); + }); + + it('reports a truly unbound legacy project without borrowing ambient scope', () => { + const scope = resolveProjectWorkspaceScope({ + projectId: 'project-legacy', + binding: null, + directory: { ok: true, items: directoryItems }, + }); + + expect(scope).toEqual({ + kind: 'unbound', + projectId: 'project-legacy', + workspaceId: null, + context: null, + }); + }); +}); + +describe('resolveProjectWorkspaceScopeBootstrap', () => { + it('returns project A from its exact membership even when ambient-order B is first', () => { + expect(resolveProjectWorkspaceScopeBootstrap({ + projectId: 'project-a', + binding: { + workspaceId: 'workspace-a', + visibility: 'team', + resourceState: 'active', + }, + directory: { ok: true, items: directoryItems }, + })).toMatchObject({ + ok: true, + scope: { + kind: 'team', + projectId: 'project-a', + workspaceId: 'workspace-a', + context: { + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }, + }, + }); + }); + + it('fails closed when the current account is not a member of the persisted workspace', () => { + expect(resolveProjectWorkspaceScopeBootstrap({ + projectId: 'project-a', + binding: { + workspaceId: 'workspace-a', + visibility: 'team', + resourceState: 'active', + }, + directory: { ok: true, items: [directoryItems[0]!] }, + })).toEqual({ + ok: false, + status: 403, + code: 'WORKSPACE_PROJECT_PERMISSION_DENIED', + message: 'workspace project read is not allowed', + }); + }); + + it('distinguishes a directory outage from revoked membership', () => { + expect(resolveProjectWorkspaceScopeBootstrap({ + projectId: 'project-a', + binding: { + workspaceId: 'workspace-a', + visibility: 'team', + resourceState: 'active', + }, + directory: { ok: false, items: [] }, + })).toEqual({ + ok: false, + status: 503, + code: 'WORKSPACE_DIRECTORY_UNAVAILABLE', + message: 'workspace membership directory is unavailable', + }); + }); + + it.each([ + { memberStatus: 'removed' as const, lifecycleState: 'active' as const }, + { memberStatus: 'active' as const, lifecycleState: 'deleted' as const }, + ])('rejects a revoked or deleted membership: %o', (membership) => { + expect(resolveProjectWorkspaceScopeBootstrap({ + projectId: 'project-a', + binding: { + workspaceId: 'workspace-a', + visibility: 'team', + resourceState: 'active', + }, + directory: { + ok: true, + items: [{ ...directoryItems[1]!, ...membership }], + }, + })).toMatchObject({ + ok: false, + status: 403, + code: 'WORKSPACE_PROJECT_PERMISSION_DENIED', + }); + }); + + it('rejects a deleted persisted resource before returning its binding', () => { + expect(resolveProjectWorkspaceScopeBootstrap({ + projectId: 'project-a', + binding: { + workspaceId: 'workspace-a', + visibility: 'team', + resourceState: 'deleted', + }, + directory: { ok: true, items: directoryItems }, + })).toMatchObject({ + ok: false, + status: 403, + code: 'WORKSPACE_PROJECT_PERMISSION_DENIED', + }); + }); + + it('preserves a genuinely unbound local project without requiring login', () => { + expect(resolveProjectWorkspaceScopeBootstrap({ + projectId: 'legacy-local', + binding: null, + directory: { ok: false, items: [] }, + })).toEqual({ + ok: true, + scope: { + kind: 'unbound', + projectId: 'legacy-local', + workspaceId: null, + context: null, + }, + }); + }); +}); diff --git a/apps/daemon/tests/collab/pull-profile.test.ts b/apps/daemon/tests/collab/pull-profile.test.ts new file mode 100644 index 00000000000..3c79b672945 --- /dev/null +++ b/apps/daemon/tests/collab/pull-profile.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + emitSharedProjectPullTiming, + emitVelaResourcePullProfile, + sharedProjectPullProfileEnabled, +} from '../../src/collab/pull-profile.js'; + +describe('shared project pull profiling', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('is strictly opt-in and emits no default log', () => { + const info = vi.spyOn(console, 'info').mockImplementation(() => {}); + + expect(sharedProjectPullProfileEnabled({})).toBe(false); + emitSharedProjectPullTiming( + { + phase: 'event-received', + projectId: 'project-1', + version: 3, + atMs: 100, + }, + {}, + ); + + expect(info).not.toHaveBeenCalled(); + }); + + it('logs only the allowlisted Vela profile fields from JSONL stderr', () => { + const info = vi.spyOn(console, 'info').mockImplementation(() => {}); + const stderr = [ + 'ordinary vela warning with https://secret.example.test', + JSON.stringify({ + event: 'resource_pull_profile', + schemaVersion: 1, + startedAt: '2026-07-26T00:00:00.000Z', + finishedAt: '2026-07-26T00:00:02.500Z', + success: true, + kind: 'project', + resourceId: 'project-content-project-1', + ref: 'published', + totalMs: 2500, + phases: [ + { + name: 'resolve_ref', + count: 1, + totalMs: 800, + maxMs: 800, + url: 'https://secret.example.test', + }, + { + name: 'object_store_download', + count: 2, + totalMs: 1200, + maxMs: 700, + }, + { + name: 'resolve_ref', + count: 99, + totalMs: 99, + maxMs: 99, + }, + { + name: 'unexpected_future_phase', + count: 1, + totalMs: 1, + maxMs: 1, + }, + ], + destination: '/private/member/project-1', + token: 'secret', + }), + ].join('\n'); + + emitVelaResourcePullProfile(stderr, { + OD_COLLAB_PULL_PROFILE: '1', + }); + + expect(info).toHaveBeenCalledTimes(1); + const line = String(info.mock.calls[0]?.[0]); + expect(line).toContain('"phase":"vela-child-done"'); + expect(line).toContain('"name":"resolve_ref"'); + expect(line).toContain('"name":"object_store_download"'); + expect(line.match(/"name":"resolve_ref"/gu)).toHaveLength(1); + expect(line).not.toContain('unexpected_future_phase'); + expect(line).not.toContain('secret.example.test'); + expect(line).not.toContain('/private/member'); + expect(line).not.toContain('"token"'); + }); +}); diff --git a/apps/daemon/tests/collab/swr-cache.test.ts b/apps/daemon/tests/collab/swr-cache.test.ts new file mode 100644 index 00000000000..87a1a881d05 --- /dev/null +++ b/apps/daemon/tests/collab/swr-cache.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; +import { createSwrCache } from '../../src/collab/swr-cache.js'; + +// Generic stale-while-revalidate primitive shared by every SWR-cached read in +// the daemon (team-project catalog, team member roster, and — the bug this +// file exists to pin down — the team design-system/plugin/skill listing). The +// `invalidate()` tests are the load-bearing ones: without it, a local mutation +// (share/unshare) had no way to drop the entry it just made stale, so the +// caller who just mutated the data read their own pre-mutation write back for +// up to `freshMs`. + +describe('createSwrCache', () => { + it('reuses the cached value for repeat reads inside freshMs', async () => { + let calls = 0; + const cache = createSwrCache(async () => { calls += 1; return calls; }, () => 'k', 3000); + + await expect(cache()).resolves.toBe(1); + await expect(cache()).resolves.toBe(1); + await expect(cache()).resolves.toBe(1); + + expect(calls).toBe(1); + }); + + it('coalesces concurrent callers onto the same in-flight fetch', async () => { + let calls = 0; + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const cache = createSwrCache(async () => { + calls += 1; + await gate; + return 'settled'; + }, () => 'k', 3000); + + const a = cache(); + const b = cache(); + release(); + + await expect(a).resolves.toBe('settled'); + await expect(b).resolves.toBe('settled'); + expect(calls).toBe(1); + }); + + it('treats a key change as an automatic miss', async () => { + let key = 'workspace-a'; + let calls = 0; + const cache = createSwrCache(async () => { calls += 1; return key; }, () => key, 3000); + + await expect(cache()).resolves.toBe('workspace-a'); + expect(calls).toBe(1); + + key = 'workspace-b'; + await expect(cache()).resolves.toBe('workspace-b'); + expect(calls).toBe(2); + }); + + it('serves the stale value immediately and refreshes in the background once past freshMs', async () => { + let calls = 0; + let value = 'v1'; + const cache = createSwrCache(async () => { calls += 1; return value; }, () => 'k', 0); + + await expect(cache()).resolves.toBe('v1'); + expect(calls).toBe(1); + + // freshMs=0: the very next read is already "stale", so it is served the + // last known value synchronously AND kicks a background refresh. + value = 'v2'; + await expect(cache()).resolves.toBe('v1'); + expect(calls).toBe(2); + + // Let the background refresh's microtask land. + await Promise.resolve(); + await Promise.resolve(); + await expect(cache()).resolves.toBe('v2'); + }); + + it('does not poison the cache with a failed fetch — the next read retries', async () => { + let calls = 0; + const cache = createSwrCache(async () => { + calls += 1; + if (calls === 1) throw new Error('transient hub outage'); + return 'recovered'; + }, () => 'k', 3000); + + await expect(cache()).rejects.toThrow('transient hub outage'); + await expect(cache()).resolves.toBe('recovered'); + expect(calls).toBe(2); + }); + + // The actual bug: `cachedTeamResourceList` in server.ts wraps this primitive + // and, before this fix, had no way to tell it a local share/unshare just + // changed the underlying data. The route's response is what makes the client + // refetch — without invalidate() that refetch read the pre-change list + // straight out of here for up to freshMs (3s in production), so a shared + // design system/plugin/skill did not show up in the team list until a later + // poll tick. + it('invalidate() forces a real fetch on the very next read, even inside freshMs', async () => { + let calls = 0; + let value = 'before-share'; + const cache = createSwrCache(async () => { calls += 1; return value; }, () => 'k', 3000); + + await expect(cache()).resolves.toBe('before-share'); + expect(calls).toBe(1); + + // The share lands: the underlying data changed, but freshMs (3000ms) has + // not elapsed. Without invalidate() this proves the cache really would + // still serve the stale value. + value = 'after-share'; + await expect(cache()).resolves.toBe('before-share'); + expect(calls).toBe(1); + + cache.invalidate(); + await expect(cache()).resolves.toBe('after-share'); + expect(calls).toBe(2); + }); + + it('invalidate() discards a background refresh that was already in flight, instead of letting it repopulate the cache', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + let calls = 0; + // freshMs=0 so the second read below immediately kicks a background + // refresh, which we hold open on `gate` to simulate it still being in + // flight when invalidate() runs. + const cache = createSwrCache(async () => { + calls += 1; + const call = calls; + if (call === 2) await gate; + return `value-${call}`; + }, () => 'k', 0); + + await expect(cache()).resolves.toBe('value-1'); + expect(calls).toBe(1); + + // Kicks the held background refresh (call #2) but still returns the + // synchronously-cached value. + await expect(cache()).resolves.toBe('value-1'); + expect(calls).toBe(2); + + // A share/unshare invalidates while call #2 is still in flight. + cache.invalidate(); + release(); + // Let the held promise's continuation run. + await new Promise((resolve) => setTimeout(resolve, 0)); + + // The discarded call #2 must not have repopulated the cache: this read is + // a brand new fetch (#3), not the stale-but-in-flight #2 value. + await expect(cache()).resolves.toBe('value-3'); + expect(calls).toBe(3); + }); + + it('invalidate() is safe to call before any read has happened', () => { + const cache = createSwrCache(async () => 'v', () => 'k', 3000); + expect(() => cache.invalidate()).not.toThrow(); + }); + + it('leaves an unrelated cache instance unaffected by another instance calling invalidate()', async () => { + let aCalls = 0; + let bCalls = 0; + const cacheA = createSwrCache(async () => { aCalls += 1; return 'a'; }, () => 'k', 3000); + const cacheB = createSwrCache(async () => { bCalls += 1; return 'b'; }, () => 'k', 3000); + + await cacheA(); + await cacheB(); + cacheA.invalidate(); + await cacheB(); + + expect(aCalls).toBe(1); + expect(bCalls).toBe(1); + }); +}); diff --git a/apps/daemon/tests/collab/sync-digest.test.ts b/apps/daemon/tests/collab/sync-digest.test.ts new file mode 100644 index 00000000000..0a562e04cc6 --- /dev/null +++ b/apps/daemon/tests/collab/sync-digest.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from 'vitest'; + +import { + createSyncDigestReader, + parseSyncDigest, + tokenForFace, +} from '../../src/collab/sync-digest.js'; + +// B's `GET /api/v1/collab/sync-digest` hands out four OPAQUE tokens. The only +// legal operation is `===` against a token we stored earlier — they are built +// from `max(updated_at) || ':' || count(*)`, so there is no ordering relation +// between two values and no timestamp to parse out. + +const VALID = { + catalogToken: '2026-07-20T00:00:00Z:4', + membersToken: '2026-07-20T00:00:00Z:2', + contextToken: '2026-07-20T00:00:00Z', + billingToken: '', +}; + +function session(overrides: Record = {}) { + return { + profile: 'prod', + apiUrl: 'https://amr-api.example.test', + controlKey: 'ck-1', + user: { id: 'user-1', email: 'a@example.test' }, + configMtimeMs: null, + ...overrides, + } as never; +} + +describe('parseSyncDigest', () => { + it('accepts the four-token payload, including an empty billing token', () => { + // An empty `billingToken` is the documented no-subscription value, not a + // malformed response. + expect(parseSyncDigest(VALID)).toEqual(VALID); + }); + + it('accepts the empty-table token shapes verbatim', () => { + // `'0:0'` / `'0'` are values, not sentinels — nothing may special-case them. + const empty = { catalogToken: '0:0', membersToken: '0:0', contextToken: '0', billingToken: '' }; + expect(parseSyncDigest(empty)).toEqual(empty); + }); + + it('rejects a payload with a missing or non-string token', () => { + expect(parseSyncDigest({ ...VALID, membersToken: undefined })).toBeNull(); + expect(parseSyncDigest({ ...VALID, catalogToken: 12 })).toBeNull(); + expect(parseSyncDigest(null)).toBeNull(); + expect(parseSyncDigest([VALID])).toBeNull(); + }); +}); + +describe('tokenForFace', () => { + it('maps each cached face to its own token', () => { + expect(tokenForFace(VALID, 'catalog')).toBe(VALID.catalogToken); + expect(tokenForFace(VALID, 'members')).toBe(VALID.membersToken); + }); +}); + +describe('createSyncDigestReader', () => { + it('reads the digest with the same auth the SSE channel uses', async () => { + const seen: Array<{ url: string; headers: Record }> = []; + const read = createSyncDigestReader({ + env: { OD_WORKSPACE_CONTEXT_SOURCE: 'vela' }, + getWorkspaceId: () => 'ws-1', + readSession: () => session(), + fetchImpl: (async (url: string, init: RequestInit) => { + seen.push({ url, headers: init.headers as Record }); + return new Response(JSON.stringify(VALID), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as unknown as typeof fetch, + }); + + await expect(read()).resolves.toEqual({ + accountId: 'user-1', + workspaceId: 'ws-1', + digest: VALID, + }); + expect(seen[0]?.url).toBe('https://amr-api.example.test/api/v1/collab/sync-digest'); + expect(seen[0]?.headers.authorization).toBe('Bearer ck-1'); + expect(seen[0]?.headers['x-vela-workspace-id']).toBe('ws-1'); + }); + + it('stays off the wire unless the workspace source is vela', async () => { + let called = 0; + const read = createSyncDigestReader({ + // A dev daemon on any other source has no hub to ask and must not dial + // production — the same gate the hub events subscriber uses. + env: { OD_WORKSPACE_CONTEXT_SOURCE: 'stub' }, + getWorkspaceId: () => 'ws-1', + readSession: () => session(), + fetchImpl: (async () => { + called += 1; + return new Response('{}', { status: 200 }); + }) as unknown as typeof fetch, + }); + + await expect(read()).resolves.toBeNull(); + expect(called).toBe(0); + }); + + it('reports null rather than inventing a key when the account id is missing', async () => { + let called = 0; + const read = createSyncDigestReader({ + env: { OD_WORKSPACE_CONTEXT_SOURCE: 'vela' }, + getWorkspaceId: () => 'ws-1', + // A config-only session read can land before the user record does. An + // empty account id must never become a cache key. + readSession: () => session({ user: null }), + fetchImpl: (async () => { + called += 1; + return new Response(JSON.stringify(VALID), { status: 200 }); + }) as unknown as typeof fetch, + }); + + await expect(read()).resolves.toBeNull(); + expect(called).toBe(0); + }); + + it('reports null when no workspace is selected', async () => { + const read = createSyncDigestReader({ + env: { OD_WORKSPACE_CONTEXT_SOURCE: 'vela' }, + getWorkspaceId: () => ' ', + readSession: () => session(), + fetchImpl: (async () => new Response('{}', { status: 200 })) as unknown as typeof fetch, + }); + + await expect(read()).resolves.toBeNull(); + }); + + it('reports null on a transport failure instead of throwing', async () => { + const errors: unknown[] = []; + const read = createSyncDigestReader({ + env: { OD_WORKSPACE_CONTEXT_SOURCE: 'vela' }, + getWorkspaceId: () => 'ws-1', + readSession: () => session(), + fetchImpl: (async () => { + throw new Error('offline'); + }) as unknown as typeof fetch, + onError: (error) => errors.push(error), + }); + + // Callers treat null as "cannot prove the snapshot is current", which is a + // real fetch — a digest outage degrades, it never fails a page load. + await expect(read()).resolves.toBeNull(); + expect(errors).toHaveLength(1); + }); + + it('reports null on a non-2xx digest response', async () => { + const read = createSyncDigestReader({ + env: { OD_WORKSPACE_CONTEXT_SOURCE: 'vela' }, + getWorkspaceId: () => 'ws-1', + readSession: () => session(), + fetchImpl: (async () => new Response('nope', { status: 503 })) as unknown as typeof fetch, + }); + + await expect(read()).resolves.toBeNull(); + }); + + it('stops asking for a cooldown after a failure, then resumes', async () => { + let clock = 1_000; + let calls = 0; + let broken = true; + const read = createSyncDigestReader({ + env: { OD_WORKSPACE_CONTEXT_SOURCE: 'vela' }, + getWorkspaceId: () => 'ws-1', + readSession: () => session(), + failureCooldownMs: 60_000, + now: () => clock, + fetchImpl: (async () => { + calls += 1; + // A B deployment that predates the endpoint answers 404 forever. + if (broken) return new Response('no route', { status: 404 }); + return new Response(JSON.stringify(VALID), { status: 200 }); + }) as unknown as typeof fetch, + }); + + await expect(read()).resolves.toBeNull(); + expect(calls).toBe(1); + + // The digest exists to REPLACE a slow read. Retrying it on every catalog and + // member load would add a round-trip to each one for no chance of a hit. + clock += 1_000; + await expect(read()).resolves.toBeNull(); + expect(calls).toBe(1); + + clock += 60_000; + broken = false; + await expect(read()).resolves.toMatchObject({ accountId: 'user-1' }); + expect(calls).toBe(2); + + // A success clears the cooldown rather than leaving it armed. + await read(); + expect(calls).toBe(3); + }); + + it('coalesces concurrent reads onto one request but does not cache the result', async () => { + let calls = 0; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const read = createSyncDigestReader({ + env: { OD_WORKSPACE_CONTEXT_SOURCE: 'vela' }, + getWorkspaceId: () => 'ws-1', + readSession: () => session(), + fetchImpl: (async () => { + calls += 1; + await gate; + return new Response(JSON.stringify(VALID), { status: 200 }); + }) as unknown as typeof fetch, + }); + + // Catalog and members refresh milliseconds apart on one navigation; they + // must not cost two identical round-trips. + const both = Promise.all([read(), read()]); + release(); + await both; + expect(calls).toBe(1); + + // But a SETTLED token is never reused: a token is only meaningful when it + // was read fresh, or it could green-light a snapshot the cloud moved past. + await read(); + expect(calls).toBe(2); + }); +}); diff --git a/apps/daemon/tests/collab/team-mirror-materializer.test.ts b/apps/daemon/tests/collab/team-mirror-materializer.test.ts new file mode 100644 index 00000000000..adb61ddaec7 --- /dev/null +++ b/apps/daemon/tests/collab/team-mirror-materializer.test.ts @@ -0,0 +1,273 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + closeDatabase, + getProject, + listConversations, + listMessages, + listWorkspaceProjects, + openDatabase, +} from '../../src/db.js'; +import { + getTeamProjectMaterialization, + latestTeamProjectMaterializationVersion, + materializePulledTeamMirror, + teamProjectMaterializationSupersedes, +} from '../../src/collab/team-mirror-materializer.js'; +import type { AuthorizedTeamProjectPullReceipt } from '../../src/collab/authorized-team-project-pull.js'; +import { projectResourceIdFor } from '../../src/integrations/vela-team-projects.js'; + +const roots: string[] = []; + +const scope = { + workspaceId: 'workspace-1', + resourceTeamId: 'workspace-1', + viewerMemberId: 'viewer-1', + ownerMemberId: 'owner-1', +}; +const resourceId = projectResourceIdFor('project-1', { + teamId: scope.resourceTeamId, + memberId: scope.ownerMemberId, + role: 'member', + lifecycleState: 'active', + workspaceType: 'team', +}); + +function receipt(): AuthorizedTeamProjectPullReceipt { + return { + schemaVersion: 1, + ...scope, + projectId: 'project-1', + resourceId, + ref: 'published', + version: 7, + versionId: 'version-7', + manifestDigest: `sha256:${'a'.repeat(64)}`, + lifecycleState: 'active', + authorizedAt: '2026-07-26T10:00:00.000Z', + expiresAt: '2026-07-26T10:00:02.000Z', + }; +} + +const input = { + id: 'project-1', + name: 'Pulled project', + skillId: null, + designSystemId: null, + createdAt: 1, + updatedAt: 2, +}; + +afterEach(async () => { + closeDatabase(); + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +async function database() { + const root = await mkdtemp(path.join(os.tmpdir(), 'od-team-materialize-')); + roots.push(root); + return openDatabase(root, { dataDir: root }); +} + +describe('authorized team mirror SQLite materialization', () => { + it('reads a newer legacy cursor after an authorized materialization', () => { + expect( + latestTeamProjectMaterializationVersion( + { ...receipt(), version: 5 }, + '6', + input.id, + scope, + ), + ).toBe(6); + }); + + it('keeps a newer authorized cursor ahead of the legacy store', () => { + expect( + latestTeamProjectMaterializationVersion( + receipt(), + '6', + input.id, + scope, + ), + ).toBe(7); + }); + + it('ignores an authorized cursor with a mismatched scope or resource binding', () => { + expect( + latestTeamProjectMaterializationVersion( + { ...receipt(), viewerMemberId: 'other-viewer', version: 9 }, + '6', + input.id, + scope, + ), + ).toBe(6); + expect( + latestTeamProjectMaterializationVersion( + { ...receipt(), resourceId: 'non-canonical', version: 9 }, + null, + input.id, + scope, + ), + ).toBeNull(); + }); + + it('rejects malformed, negative, fractional, and unsafe legacy cursors', () => { + for (const legacy of ['', '-1', '1.5', '01', '9007199254740992']) { + expect( + latestTeamProjectMaterializationVersion(null, legacy, input.id, scope), + ).toBeNull(); + } + }); + + it('classifies only a newer receipt with the same canonical binding as superseding', () => { + const previous = { ...receipt(), version: 5, versionId: 'version-5' }; + + expect(teamProjectMaterializationSupersedes(receipt(), previous)).toBe(true); + expect( + teamProjectMaterializationSupersedes( + { + ...previous, + authorizedAt: '2026-07-26T10:00:01.000Z', + expiresAt: '2026-07-26T10:00:03.000Z', + }, + previous, + ), + ).toBe(true); + expect( + teamProjectMaterializationSupersedes( + { ...receipt(), viewerMemberId: 'other-viewer' }, + previous, + ), + ).toBe(false); + expect( + teamProjectMaterializationSupersedes( + { ...receipt(), resourceId: 'non-canonical' }, + previous, + ), + ).toBe(false); + expect( + teamProjectMaterializationSupersedes( + { ...receipt(), version: 5, versionId: 'other-version-5' }, + previous, + ), + ).toBe(false); + }); + + it('commits metadata, binding, and the full authorization receipt together', async () => { + const db = await database(); + + materializePulledTeamMirror(db, input, scope, receipt()); + + expect(getProject(db, input.id)?.name).toBe('Pulled project'); + expect(getTeamProjectMaterialization(db, scope.workspaceId, input.id)) + .toEqual(receipt()); + }); + + it('creates one stable local-only comment anchor without copying owner chat', async () => { + const db = await database(); + + materializePulledTeamMirror(db, input, scope, receipt()); + const first = listConversations(db, input.id); + + expect(first).toHaveLength(1); + expect(first[0]?.messageCount).toBe(0); + expect(listMessages(db, first[0]!.id)).toEqual([]); + + materializePulledTeamMirror(db, input, scope, { + ...receipt(), + version: 8, + versionId: 'version-8', + }); + const second = listConversations(db, input.id); + + expect(second.map((conversation) => conversation.id)) + .toEqual(first.map((conversation) => conversation.id)); + expect(listMessages(db, second[0]!.id)).toEqual([]); + }); + + it('rolls back metadata and binding when the exact receipt cursor cannot commit', async () => { + const db = await database(); + db.exec(` + CREATE TRIGGER reject_team_materialization + BEFORE INSERT ON team_project_materializations + BEGIN + SELECT RAISE(ABORT, 'cursor unavailable'); + END; + `); + + expect(() => + materializePulledTeamMirror(db, input, scope, receipt()), + ).toThrow('cursor unavailable'); + + expect(getProject(db, input.id)).toBeNull(); + expect(getTeamProjectMaterialization(db, scope.workspaceId, input.id)) + .toBeNull(); + }); + + it('rejects a non-canonical but non-empty receipt resource id before mutation', async () => { + const db = await database(); + + expect(() => + materializePulledTeamMirror(db, input, scope, { + ...receipt(), + resourceId: 'resource-1', + }), + ).toThrow('receipt resource conflict'); + + expect(getProject(db, input.id)).toBeNull(); + }); +}); + +/** + * The project card's time. `RecentProjectsStrip` renders one relative time per + * card and `GET /api/workspaces/:id/projects` answers it as + * `MAX(p.updated_at, wp.updated_at)` (see `normalizeWorkspaceProjectRow`'s + * `lastActivityAt` in routes/project/index.ts, and `listWorkspaceProjects`' + * own `ORDER BY` in db.ts). So BOTH halves have to answer "when did a person + * last change this project's content" — a pull that stamps either one with + * `Date.now()` is indistinguishable from a real edit. + * + * Reported by the owner: a member who opens the client hours later and pulls a + * shared project sees 「刚刚更新」 on a project nobody touched. Materialization + * already carries the origin's `updatedAt` into `projects`; the + * `workspace_projects` binding written in the same transaction did not, and + * `MAX` then surfaced the pull's own clock. + */ +describe('a team-mirror pull reports the origin content time, not the pull clock', () => { + /** What the client renders for this project's card. */ + function displayedUpdatedAt(db: Awaited>) { + const row = listWorkspaceProjects(db, scope.workspaceId).find( + (candidate) => candidate.id === input.id, + ) as { updatedAt: number; workspaceUpdatedAt: number | null } | undefined; + if (!row) throw new Error('project is not listed in the workspace'); + return Math.max(row.updatedAt, row.workspaceUpdatedAt ?? 0); + } + + it('does not advance the card time on a first pull', async () => { + const db = await database(); + + materializePulledTeamMirror(db, input, scope, receipt()); + + expect(getProject(db, input.id)?.updatedAt).toBe(input.updatedAt); + expect(displayedUpdatedAt(db)).toBe(input.updatedAt); + }); + + it('does not advance the card time on a re-pull of an already-bound mirror', async () => { + const db = await database(); + + materializePulledTeamMirror(db, input, scope, receipt()); + materializePulledTeamMirror(db, input, scope, { + ...receipt(), + version: 8, + versionId: 'version-8', + }); + + expect(getProject(db, input.id)?.updatedAt).toBe(input.updatedAt); + expect(displayedUpdatedAt(db)).toBe(input.updatedAt); + }); +}); diff --git a/apps/daemon/tests/collab/team-mirror-promotion.test.ts b/apps/daemon/tests/collab/team-mirror-promotion.test.ts new file mode 100644 index 00000000000..c4ba6fafc3a --- /dev/null +++ b/apps/daemon/tests/collab/team-mirror-promotion.test.ts @@ -0,0 +1,899 @@ +import { renameSync } from 'node:fs'; +import { lstat, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { WorkspaceCollabContext } from '@open-design/contracts'; + +import { + promoteAuthorizedTeamProjectStage, + recoverAuthorizedTeamProjectPromotions, + type TeamMirrorPromotionJournalRecord, +} from '../../src/collab/team-mirror-promotion.js'; +import { resolveAuthorizedActiveTeamWorkspaceSnapshot } from '../../src/collab/active-workspace-selection.js'; +import { withLastKnownWorkspaceContext } from '../../src/collab/workspace-context.js'; +import { teamProjectMaterializationSupersedes } from '../../src/collab/team-mirror-materializer.js'; +import type { AuthorizedTeamProjectPullReceipt } from '../../src/collab/authorized-team-project-pull.js'; +import { projectResourceIdFor } from '../../src/integrations/vela-team-projects.js'; + +const roots: string[] = []; +const resourceId = projectResourceIdFor('project-1', { + teamId: 'workspace-1', + memberId: 'owner-1', + role: 'member', + lifecycleState: 'active', + workspaceType: 'team', +}); +const activeIdentity = { + workspaceId: 'workspace-1', + teamId: 'workspace-1', + workspaceMemberId: 'viewer-1', + workspaceType: 'team', + memberStatus: 'active', + lifecycleState: 'active', + role: 'member', + billingState: 'active', + planId: null, + providerMode: 'platform_credits', + seatSummary: { seatLimit: 5, usedSeats: 2, availableSeats: 3 }, + permissions: { + canManageMembers: false, + canManageBilling: false, + canShareProjects: true, + canWriteSyncedFiles: true, + }, +} as WorkspaceCollabContext; + +async function fixture(live = true) { + const root = await mkdtemp(path.join(os.tmpdir(), 'od-team-promote-')); + roots.push(root); + const liveDir = path.join(root, 'project-1'); + const stageDir = await mkdtemp(path.join(root, '.project-1.od-pull-stage-')); + const journalDir = path.join(root, '.journals'); + if (live) { + await mkdir(liveDir); + await writeFile(path.join(liveDir, 'index.html'), 'old'); + } + await writeFile(path.join(stageDir, 'index.html'), 'new'); + const stageStat = await lstat(stageDir); + return { + root, + liveDir, + stageDir, + journalDir, + stageIdentity: { dev: String(stageStat.dev), ino: String(stageStat.ino) }, + }; +} + +function receipt( + overrides: Partial = {}, +): AuthorizedTeamProjectPullReceipt { + return { + schemaVersion: 1, + workspaceId: 'workspace-1', + resourceTeamId: 'workspace-1', + viewerMemberId: 'viewer-1', + ownerMemberId: 'owner-1', + projectId: 'project-1', + resourceId, + ref: 'published', + version: 7, + versionId: 'version-7', + manifestDigest: `sha256:${'a'.repeat(64)}`, + lifecycleState: 'active', + authorizedAt: '2026-07-26T10:00:00.000Z', + expiresAt: '2026-07-26T10:00:02.000Z', + ...overrides, + }; +} + +afterEach(async () => { + const { rm } = await import('node:fs/promises'); + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe('authorized team mirror promotion', () => { + it('promotes after a transient A to null to A observation without generation drift', async () => { + const fx = await fixture(); + let current: WorkspaceCollabContext | null = activeIdentity; + const provider = withLastKnownWorkspaceContext({ + current: async () => current, + }); + await provider.current({}); + const captured = resolveAuthorizedActiveTeamWorkspaceSnapshot( + { workspaceId: 'workspace-1', generation: 0 }, + provider.lastKnownSnapshot!(), + ); + + current = null; + await provider.current({}); + current = activeIdentity; + await provider.current({}); + + await expect(promoteAuthorizedTeamProjectStage({ + receipt: receipt(), + liveDir: fx.liveDir, + stageDir: fx.stageDir, + expectedStageIdentity: fx.stageIdentity, + journalDir: fx.journalDir, + activeWorkspaceGeneration: captured.generation, + getActiveWorkspaceSnapshot: () => + resolveAuthorizedActiveTeamWorkspaceSnapshot( + { workspaceId: 'workspace-1', generation: 0 }, + provider.lastKnownSnapshot!(), + ), + expectedWorkspaceId: 'workspace-1', + isExpectedVersion: () => true, + validateReceipt: () => undefined, + commit: () => ({ localRecordChanged: true }), + })).resolves.toEqual({ localRecordChanged: true }); + + expect(await readFile(path.join(fx.liveDir, 'index.html'), 'utf8')).toBe('new'); + }); + + it('rejects promotion while the authoritative context remains unavailable', async () => { + const fx = await fixture(); + let current: WorkspaceCollabContext | null = activeIdentity; + const provider = withLastKnownWorkspaceContext({ + current: async () => current, + }); + await provider.current({}); + const captured = resolveAuthorizedActiveTeamWorkspaceSnapshot( + { workspaceId: 'workspace-1', generation: 0 }, + provider.lastKnownSnapshot!(), + ); + current = null; + await provider.current({}); + + await expect(promoteAuthorizedTeamProjectStage({ + receipt: receipt(), + liveDir: fx.liveDir, + stageDir: fx.stageDir, + expectedStageIdentity: fx.stageIdentity, + journalDir: fx.journalDir, + activeWorkspaceGeneration: captured.generation, + getActiveWorkspaceSnapshot: () => + resolveAuthorizedActiveTeamWorkspaceSnapshot( + { workspaceId: 'workspace-1', generation: 0 }, + provider.lastKnownSnapshot!(), + ), + expectedWorkspaceId: 'workspace-1', + isExpectedVersion: () => true, + validateReceipt: () => undefined, + commit: () => ({ localRecordChanged: true }), + })).rejects.toThrow('active workspace changed'); + + expect(await readFile(path.join(fx.liveDir, 'index.html'), 'utf8')).toBe('old'); + }); + + it('atomically promotes the stage before committing SQLite metadata+cursor', async () => { + const fx = await fixture(); + const commit = vi.fn(() => { + expect(() => readFile(path.join(fx.liveDir, 'index.html'), 'utf8')).not.toThrow(); + return { localRecordChanged: true }; + }); + + const result = await promoteAuthorizedTeamProjectStage({ + receipt: receipt(), + liveDir: fx.liveDir, + stageDir: fx.stageDir, + expectedStageIdentity: fx.stageIdentity, + journalDir: fx.journalDir, + activeWorkspaceGeneration: 4, + getActiveWorkspaceSnapshot: () => ({ workspaceId: 'workspace-1', generation: 4 }), + expectedWorkspaceId: 'workspace-1', + isExpectedVersion: () => true, + validateReceipt: () => undefined, + commit, + }); + + expect(result).toEqual({ localRecordChanged: true }); + expect(commit).toHaveBeenCalledTimes(1); + expect(await readFile(path.join(fx.liveDir, 'index.html'), 'utf8')).toBe('new'); + expect(await readdir(fx.journalDir)).toEqual([]); + expect((await readdir(fx.root)).some((name) => name.includes('.od-pull-recovery-'))).toBe(false); + }); + + it('keeps one complete live tree addressable at every async durability boundary', async () => { + const fx = await fixture(); + await writeFile(path.join(fx.liveDir, 'old-only.txt'), 'old'); + await writeFile(path.join(fx.stageDir, 'new-only.txt'), 'new'); + const observedVersions: string[] = []; + const assertCompleteLiveTree = async (): Promise => { + const [index, entries] = await Promise.all([ + readFile(path.join(fx.liveDir, 'index.html'), 'utf8'), + readdir(fx.liveDir), + ]); + if (index === 'old') { + expect(entries.sort()).toEqual(['index.html', 'old-only.txt']); + } else { + expect(index).toBe('new'); + expect(entries.sort()).toEqual(['index.html', 'new-only.txt']); + } + observedVersions.push(index); + }; + + await expect(promoteAuthorizedTeamProjectStage({ + receipt: receipt(), + liveDir: fx.liveDir, + stageDir: fx.stageDir, + expectedStageIdentity: fx.stageIdentity, + journalDir: fx.journalDir, + activeWorkspaceGeneration: 4, + getActiveWorkspaceSnapshot: () => ({ + workspaceId: 'workspace-1', + generation: 4, + }), + expectedWorkspaceId: 'workspace-1', + isExpectedVersion: () => true, + validateReceipt: () => undefined, + commit: () => ({ localRecordChanged: true }), + durability: { + syncDirectory: async () => { + await assertCompleteLiveTree(); + }, + }, + })).resolves.toEqual({ localRecordChanged: true }); + + expect(observedVersions.length).toBeGreaterThan(0); + expect(observedVersions).toContain('old'); + expect(observedVersions.at(-1)).toBe('new'); + await assertCompleteLiveTree(); + }); + + it('restores the old live tree synchronously when the stage rename fails', async () => { + const fx = await fixture(); + const stageRenameFailure = new Error('injected stage rename failure'); + let renameCalls = 0; + + await expect(promoteAuthorizedTeamProjectStage({ + receipt: receipt(), + liveDir: fx.liveDir, + stageDir: fx.stageDir, + expectedStageIdentity: fx.stageIdentity, + journalDir: fx.journalDir, + activeWorkspaceGeneration: 4, + getActiveWorkspaceSnapshot: () => ({ + workspaceId: 'workspace-1', + generation: 4, + }), + expectedWorkspaceId: 'workspace-1', + isExpectedVersion: () => true, + validateReceipt: () => undefined, + commit: () => ({ localRecordChanged: true }), + durability: { + renameDirectorySync: (from, to) => { + renameCalls += 1; + if (renameCalls === 2) throw stageRenameFailure; + renameSync(from, to); + }, + }, + })).rejects.toBe(stageRenameFailure); + + expect(renameCalls).toBe(3); + expect(await readFile(path.join(fx.liveDir, 'index.html'), 'utf8')).toBe('old'); + await expect(lstat(fx.stageDir)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await readdir(fx.journalDir)).toEqual([]); + expect((await readdir(fx.root)).some( + (name) => name.includes('.od-pull-recovery-'), + )).toBe(false); + }); + + it('uses journal rollback when the immediate live restore also fails', async () => { + const fx = await fixture(); + const stageRenameFailure = new Error('injected stage rename failure'); + const immediateRestoreFailure = new Error('injected immediate restore failure'); + let renameCalls = 0; + let thrown: unknown; + + try { + await promoteAuthorizedTeamProjectStage({ + receipt: receipt(), + liveDir: fx.liveDir, + stageDir: fx.stageDir, + expectedStageIdentity: fx.stageIdentity, + journalDir: fx.journalDir, + activeWorkspaceGeneration: 4, + getActiveWorkspaceSnapshot: () => ({ + workspaceId: 'workspace-1', + generation: 4, + }), + expectedWorkspaceId: 'workspace-1', + isExpectedVersion: () => true, + validateReceipt: () => undefined, + commit: () => ({ localRecordChanged: true }), + durability: { + renameDirectorySync: (from, to) => { + renameCalls += 1; + if (renameCalls === 2) throw stageRenameFailure; + if (renameCalls === 3) throw immediateRestoreFailure; + renameSync(from, to); + }, + }, + }); + } catch (error) { + thrown = error; + } + + expect(renameCalls).toBe(3); + expect(thrown).toBeInstanceOf(AggregateError); + expect((thrown as AggregateError).errors).toEqual([ + stageRenameFailure, + immediateRestoreFailure, + ]); + expect(await readFile(path.join(fx.liveDir, 'index.html'), 'utf8')).toBe('old'); + await expect(lstat(fx.stageDir)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await readdir(fx.journalDir)).toEqual([]); + expect((await readdir(fx.root)).some( + (name) => name.includes('.od-pull-recovery-'), + )).toBe(false); + }); + + it('restores the old tree when the SQLite transaction fails', async () => { + const fx = await fixture(); + + await expect(promoteAuthorizedTeamProjectStage({ + receipt: receipt(), + liveDir: fx.liveDir, + stageDir: fx.stageDir, + expectedStageIdentity: fx.stageIdentity, + journalDir: fx.journalDir, + activeWorkspaceGeneration: 4, + getActiveWorkspaceSnapshot: () => ({ workspaceId: 'workspace-1', generation: 4 }), + expectedWorkspaceId: 'workspace-1', + isExpectedVersion: () => true, + validateReceipt: () => undefined, + commit: () => { + throw new Error('sqlite unavailable'); + }, + })).rejects.toThrow('sqlite unavailable'); + + expect(await readFile(path.join(fx.liveDir, 'index.html'), 'utf8')).toBe('old'); + expect((await readdir(fx.root)).some((name) => name.includes('.od-pull-stage-'))).toBe(false); + expect(await readdir(fx.journalDir)).toEqual([]); + }); + + it('reports post-commit cleanup failure without rolling back the committed tree', async () => { + const fx = await fixture(); + let committed = false; + const onPostCommitCleanupError = vi.fn(); + const syncDirectory = vi.fn(async () => { + if (committed) throw new Error('post-commit fsync failed'); + }); + + await expect(promoteAuthorizedTeamProjectStage({ + receipt: receipt(), + liveDir: fx.liveDir, + stageDir: fx.stageDir, + expectedStageIdentity: fx.stageIdentity, + journalDir: fx.journalDir, + activeWorkspaceGeneration: 4, + getActiveWorkspaceSnapshot: () => ({ workspaceId: 'workspace-1', generation: 4 }), + expectedWorkspaceId: 'workspace-1', + isExpectedVersion: () => true, + validateReceipt: () => undefined, + commit: () => { + committed = true; + return { localRecordChanged: true }; + }, + onPostCommitCleanupError, + durability: { syncDirectory }, + })).resolves.toEqual({ localRecordChanged: true }); + + expect(committed).toBe(true); + expect(onPostCommitCleanupError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'post-commit fsync failed' }), + ); + expect(await readFile(path.join(fx.liveDir, 'index.html'), 'utf8')).toBe('new'); + expect(await readdir(fx.journalDir)).toHaveLength(1); + expect((await readdir(fx.root)).some((name) => name.includes('.od-pull-recovery-'))) + .toBe(true); + + await recoverAuthorizedTeamProjectPromotions({ + journalDir: fx.journalDir, + allowedProjectsRoot: fx.root, + isCommitted: () => true, + }); + + expect(await readFile(path.join(fx.liveDir, 'index.html'), 'utf8')).toBe('new'); + expect(await readdir(fx.journalDir)).toEqual([]); + expect((await readdir(fx.root)).some((name) => name.includes('.od-pull-recovery-'))) + .toBe(false); + }); + + it('revalidates receipt expiry in the final synchronous guard before commit', async () => { + const fx = await fixture(); + const commit = vi.fn(); + const validateReceipt = vi.fn(() => { + throw new Error('authorized pull receipt is stale'); + }); + + await expect(promoteAuthorizedTeamProjectStage({ + receipt: receipt(), + liveDir: fx.liveDir, + stageDir: fx.stageDir, + expectedStageIdentity: fx.stageIdentity, + journalDir: fx.journalDir, + activeWorkspaceGeneration: 4, + getActiveWorkspaceSnapshot: () => ({ workspaceId: 'workspace-1', generation: 4 }), + expectedWorkspaceId: 'workspace-1', + isExpectedVersion: () => true, + validateReceipt, + commit, + })).rejects.toThrow('authorized pull receipt is stale'); + + expect(validateReceipt).toHaveBeenCalledTimes(1); + expect(commit).not.toHaveBeenCalled(); + expect(await readFile(path.join(fx.liveDir, 'index.html'), 'utf8')).toBe('old'); + }); + + it.each([ + ['workspace generation', () => ({ workspaceId: 'workspace-1', generation: 5 }), () => true], + ['workspace id', () => ({ workspaceId: 'workspace-2', generation: 4 }), () => true], + ['expected version', () => ({ workspaceId: 'workspace-1', generation: 4 }), () => false], + ])('discards the stage without touching live when %s drifted', async ( + _case, + getActiveWorkspaceSnapshot, + isExpectedVersion, + ) => { + const fx = await fixture(); + const commit = vi.fn(); + + await expect(promoteAuthorizedTeamProjectStage({ + receipt: receipt(), + liveDir: fx.liveDir, + stageDir: fx.stageDir, + expectedStageIdentity: fx.stageIdentity, + journalDir: fx.journalDir, + activeWorkspaceGeneration: 4, + getActiveWorkspaceSnapshot, + expectedWorkspaceId: 'workspace-1', + isExpectedVersion, + validateReceipt: () => undefined, + commit, + })).rejects.toThrow(/stale|workspace/u); + + expect(commit).not.toHaveBeenCalled(); + expect(await readFile(path.join(fx.liveDir, 'index.html'), 'utf8')).toBe('old'); + expect((await readdir(fx.root)).some((name) => name.includes('.od-pull-stage-'))).toBe(false); + }); + + it('refuses to promote a replacement raced into the authorized stage path', async () => { + const fx = await fixture(); + const { rename } = await import('node:fs/promises'); + await rename(fx.stageDir, `${fx.stageDir}.owned`); + await mkdir(fx.stageDir); + await writeFile(path.join(fx.stageDir, 'caller.txt'), 'preserve me'); + const commit = vi.fn(); + + await expect(promoteAuthorizedTeamProjectStage({ + receipt: receipt(), + liveDir: fx.liveDir, + stageDir: fx.stageDir, + expectedStageIdentity: fx.stageIdentity, + journalDir: fx.journalDir, + activeWorkspaceGeneration: 4, + getActiveWorkspaceSnapshot: () => ({ workspaceId: 'workspace-1', generation: 4 }), + expectedWorkspaceId: 'workspace-1', + isExpectedVersion: () => true, + validateReceipt: () => undefined, + commit, + })).rejects.toThrow(/stage identity/u); + + expect(commit).not.toHaveBeenCalled(); + expect(await readFile(path.join(fx.liveDir, 'index.html'), 'utf8')).toBe('old'); + expect(await readFile(path.join(fx.stageDir, 'caller.txt'), 'utf8')).toBe('preserve me'); + }); + + it('rolls back a promoted-but-uncommitted journal on startup', async () => { + const fx = await fixture(); + const recoveryDir = path.join(fx.root, '.project-1.od-pull-recovery-crash'); + const { rename } = await import('node:fs/promises'); + await rename(fx.liveDir, recoveryDir); + await rename(fx.stageDir, fx.liveDir); + await mkdir(fx.journalDir, { recursive: true }); + const record: TeamMirrorPromotionJournalRecord = { + schemaVersion: 1, + id: 'crash', + receipt: receipt(), + liveDir: fx.liveDir, + stageDir: fx.stageDir, + recoveryDir, + liveExisted: true, + phase: 'promoted', + promotedIdentity: fx.stageIdentity, + recoveryIdentity: await lstat(recoveryDir).then((entry) => ({ + dev: String(entry.dev), + ino: String(entry.ino), + })), + }; + await writeFile(path.join(fx.journalDir, 'crash.json'), JSON.stringify(record)); + + await recoverAuthorizedTeamProjectPromotions({ + journalDir: fx.journalDir, + allowedProjectsRoot: fx.root, + isCommitted: () => false, + }); + + expect(await readFile(path.join(fx.liveDir, 'index.html'), 'utf8')).toBe('old'); + expect(await readdir(fx.journalDir)).toEqual([]); + expect((await readdir(fx.root)).some((name) => name.includes('.od-pull-recovery-'))).toBe(false); + }); + + it('restores old live from a prepared journal when the live rename was durable first', async () => { + const fx = await fixture(); + const recoveryDir = path.join(fx.root, '.project-1.od-pull-recovery-crash'); + const { rename } = await import('node:fs/promises'); + const originalLiveIdentity = await lstat(fx.liveDir).then((entry) => ({ + dev: String(entry.dev), + ino: String(entry.ino), + })); + await rename(fx.liveDir, recoveryDir); + await mkdir(fx.journalDir, { recursive: true }); + const record: TeamMirrorPromotionJournalRecord = { + schemaVersion: 1, + id: 'crash', + receipt: receipt(), + liveDir: fx.liveDir, + stageDir: fx.stageDir, + recoveryDir, + liveExisted: true, + phase: 'prepared', + promotedIdentity: fx.stageIdentity, + recoveryIdentity: originalLiveIdentity, + }; + await writeFile(path.join(fx.journalDir, 'crash.json'), JSON.stringify(record)); + + await recoverAuthorizedTeamProjectPromotions({ + journalDir: fx.journalDir, + allowedProjectsRoot: fx.root, + isCommitted: () => false, + }); + + expect(await readFile(path.join(fx.liveDir, 'index.html'), 'utf8')).toBe('old'); + expect(await readdir(fx.journalDir)).toEqual([]); + expect((await readdir(fx.root)).some((name) => name.includes('.od-pull-stage-'))).toBe(false); + expect((await readdir(fx.root)).some((name) => name.includes('.od-pull-recovery-'))).toBe(false); + }); + + it('rejects a prepared live-move journal that omitted the original live identity', async () => { + const fx = await fixture(); + await mkdir(fx.journalDir, { recursive: true }); + const record = { + schemaVersion: 1, + id: 'crash', + receipt: receipt(), + liveDir: fx.liveDir, + stageDir: fx.stageDir, + recoveryDir: path.join(fx.root, '.project-1.od-pull-recovery-crash'), + liveExisted: true, + phase: 'prepared', + promotedIdentity: fx.stageIdentity, + }; + await writeFile(path.join(fx.journalDir, 'crash.json'), JSON.stringify(record)); + const errors: unknown[] = []; + + await recoverAuthorizedTeamProjectPromotions({ + journalDir: fx.journalDir, + allowedProjectsRoot: fx.root, + isCommitted: () => false, + onError: (error) => errors.push(error), + }); + + expect(errors).toHaveLength(1); + expect(await readFile(path.join(fx.liveDir, 'index.html'), 'utf8')).toBe('old'); + expect(await readdir(fx.journalDir)).toEqual(['crash.json']); + }); + + it('removes an uncommitted first materialization when rename beat the prepared journal update', async () => { + const fx = await fixture(false); + const { rename } = await import('node:fs/promises'); + await rename(fx.stageDir, fx.liveDir); + await mkdir(fx.journalDir, { recursive: true }); + const record: TeamMirrorPromotionJournalRecord = { + schemaVersion: 1, + id: 'crash', + receipt: receipt(), + liveDir: fx.liveDir, + stageDir: fx.stageDir, + recoveryDir: path.join(fx.root, '.project-1.od-pull-recovery-crash'), + liveExisted: false, + phase: 'prepared', + promotedIdentity: fx.stageIdentity, + }; + await writeFile(path.join(fx.journalDir, 'crash.json'), JSON.stringify(record)); + + await recoverAuthorizedTeamProjectPromotions({ + journalDir: fx.journalDir, + allowedProjectsRoot: fx.root, + isCommitted: () => false, + }); + + await expect(lstat(fx.liveDir)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await readdir(fx.journalDir)).toEqual([]); + }); + + it('finalizes recovery cleanup when SQLite proves the promoted version committed', async () => { + const fx = await fixture(); + const recoveryDir = path.join(fx.root, '.project-1.od-pull-recovery-crash'); + const { rename } = await import('node:fs/promises'); + await rename(fx.liveDir, recoveryDir); + await rename(fx.stageDir, fx.liveDir); + await mkdir(fx.journalDir, { recursive: true }); + const record: TeamMirrorPromotionJournalRecord = { + schemaVersion: 1, + id: 'crash', + receipt: receipt(), + liveDir: fx.liveDir, + stageDir: fx.stageDir, + recoveryDir, + liveExisted: true, + phase: 'promoted', + promotedIdentity: fx.stageIdentity, + recoveryIdentity: await lstat(recoveryDir).then((entry) => ({ + dev: String(entry.dev), + ino: String(entry.ino), + })), + }; + await writeFile(path.join(fx.journalDir, 'crash.json'), JSON.stringify(record)); + + await recoverAuthorizedTeamProjectPromotions({ + journalDir: fx.journalDir, + allowedProjectsRoot: fx.root, + isCommitted: (entry) => + entry.receipt.projectId === 'project-1' && + entry.receipt.workspaceId === 'workspace-1' && + entry.receipt.ownerMemberId === 'owner-1' && + entry.receipt.resourceId === resourceId && + entry.receipt.versionId === 'version-7' && + entry.receipt.manifestDigest === `sha256:${'a'.repeat(64)}`, + }); + + expect(await readFile(path.join(fx.liveDir, 'index.html'), 'utf8')).toBe('new'); + expect(await readdir(fx.journalDir)).toEqual([]); + expect((await readdir(fx.root)).some((name) => name.includes('.od-pull-recovery-'))).toBe(false); + }); + + it('cleans an older superseded journal without touching the newer live tree', async () => { + const fx = await fixture(); + const recoveryDir = path.join(fx.root, '.project-1.od-pull-recovery-crash'); + const { rename } = await import('node:fs/promises'); + await rename(fx.liveDir, recoveryDir); + await rename(fx.stageDir, fx.liveDir); + await mkdir(fx.journalDir, { recursive: true }); + const record: TeamMirrorPromotionJournalRecord = { + schemaVersion: 1, + id: 'crash', + receipt: receipt({ version: 5, versionId: 'version-5' }), + liveDir: fx.liveDir, + stageDir: fx.stageDir, + recoveryDir, + liveExisted: true, + phase: 'promoted', + promotedIdentity: fx.stageIdentity, + recoveryIdentity: await lstat(recoveryDir).then((entry) => ({ + dev: String(entry.dev), + ino: String(entry.ino), + })), + }; + await writeFile(path.join(fx.journalDir, 'crash.json'), JSON.stringify(record)); + await rm(fx.liveDir, { recursive: true }); + await mkdir(fx.liveDir); + await writeFile(path.join(fx.liveDir, 'index.html'), 'newer-v6'); + const newerLiveIdentity = await lstat(fx.liveDir).then((entry) => ({ + dev: String(entry.dev), + ino: String(entry.ino), + })); + + await recoverAuthorizedTeamProjectPromotions({ + journalDir: fx.journalDir, + allowedProjectsRoot: fx.root, + isCommitted: () => false, + isSuperseded: (entry) => entry.receipt.version < 6, + }); + + expect(await readFile(path.join(fx.liveDir, 'index.html'), 'utf8')).toBe('newer-v6'); + expect(await lstat(fx.liveDir).then((entry) => ({ + dev: String(entry.dev), + ino: String(entry.ino), + }))).toEqual(newerLiveIdentity); + expect(await readdir(fx.journalDir)).toEqual([]); + expect((await readdir(fx.root)).some((name) => name.includes('.od-pull-recovery-'))) + .toBe(false); + }); + + it('cleans a failed-cleanup journal after a same-version retry commits a fresh receipt', async () => { + const fx = await fixture(); + const firstReceipt = receipt({ + version: 5, + versionId: 'version-5', + }); + let firstCommitted = false; + await expect(promoteAuthorizedTeamProjectStage({ + receipt: firstReceipt, + liveDir: fx.liveDir, + stageDir: fx.stageDir, + expectedStageIdentity: fx.stageIdentity, + journalDir: fx.journalDir, + activeWorkspaceGeneration: 4, + getActiveWorkspaceSnapshot: () => ({ workspaceId: 'workspace-1', generation: 4 }), + expectedWorkspaceId: 'workspace-1', + isExpectedVersion: () => true, + validateReceipt: () => undefined, + commit: () => { + firstCommitted = true; + return undefined; + }, + durability: { + syncDirectory: async () => { + if (firstCommitted) throw new Error('defer first cleanup'); + }, + }, + })).resolves.toBeUndefined(); + expect(await readdir(fx.journalDir)).toHaveLength(1); + + const retryStageDir = await mkdtemp( + path.join(fx.root, '.project-1.od-pull-stage-'), + ); + await writeFile(path.join(retryStageDir, 'index.html'), 'same-v5-retry'); + const retryStageIdentity = await lstat(retryStageDir).then((entry) => ({ + dev: String(entry.dev), + ino: String(entry.ino), + })); + const retryReceipt = receipt({ + version: 5, + versionId: 'version-5', + authorizedAt: '2026-07-26T10:00:01.000Z', + expiresAt: '2026-07-26T10:00:03.000Z', + }); + let storedReceipt = firstReceipt; + await promoteAuthorizedTeamProjectStage({ + receipt: retryReceipt, + liveDir: fx.liveDir, + stageDir: retryStageDir, + expectedStageIdentity: retryStageIdentity, + journalDir: fx.journalDir, + activeWorkspaceGeneration: 4, + getActiveWorkspaceSnapshot: () => ({ workspaceId: 'workspace-1', generation: 4 }), + expectedWorkspaceId: 'workspace-1', + isExpectedVersion: () => true, + validateReceipt: () => undefined, + commit: () => { + storedReceipt = retryReceipt; + }, + }); + const retryLiveIdentity = await lstat(fx.liveDir).then((entry) => ({ + dev: String(entry.dev), + ino: String(entry.ino), + })); + expect(await readdir(fx.journalDir)).toHaveLength(1); + + await recoverAuthorizedTeamProjectPromotions({ + journalDir: fx.journalDir, + allowedProjectsRoot: fx.root, + isCommitted: (entry) => + entry.receipt.authorizedAt === storedReceipt.authorizedAt && + entry.receipt.expiresAt === storedReceipt.expiresAt, + isSuperseded: (entry) => + teamProjectMaterializationSupersedes(storedReceipt, entry.receipt), + }); + + expect(await readFile(path.join(fx.liveDir, 'index.html'), 'utf8')) + .toBe('same-v5-retry'); + expect(await lstat(fx.liveDir).then((entry) => ({ + dev: String(entry.dev), + ino: String(entry.ino), + }))).toEqual(retryLiveIdentity); + expect(await readdir(fx.journalDir)).toEqual([]); + expect((await readdir(fx.root)).some((name) => name.includes('.od-pull-recovery-'))) + .toBe(false); + }); + + it('preserves an unexpected live inode during startup rollback', async () => { + const fx = await fixture(); + const recoveryDir = path.join(fx.root, '.project-1.od-pull-recovery-crash'); + const { rename } = await import('node:fs/promises'); + await rename(fx.liveDir, recoveryDir); + await rename(fx.stageDir, fx.liveDir); + await rm(fx.liveDir, { recursive: true }); + await mkdir(fx.liveDir); + await writeFile(path.join(fx.liveDir, 'caller.txt'), 'do not delete'); + await mkdir(fx.journalDir, { recursive: true }); + const recoveryStat = await lstat(recoveryDir); + const record: TeamMirrorPromotionJournalRecord = { + schemaVersion: 1, + id: 'crash', + receipt: receipt(), + liveDir: fx.liveDir, + stageDir: fx.stageDir, + recoveryDir, + liveExisted: true, + phase: 'promoted', + promotedIdentity: fx.stageIdentity, + recoveryIdentity: { + dev: String(recoveryStat.dev), + ino: String(recoveryStat.ino), + }, + }; + await writeFile(path.join(fx.journalDir, 'crash.json'), JSON.stringify(record)); + const errors: unknown[] = []; + + await recoverAuthorizedTeamProjectPromotions({ + journalDir: fx.journalDir, + allowedProjectsRoot: fx.root, + isCommitted: () => false, + onError: (error) => errors.push(error), + }); + + expect(errors).toHaveLength(1); + expect(await readFile(path.join(fx.liveDir, 'caller.txt'), 'utf8')).toBe('do not delete'); + expect(await readFile(path.join(recoveryDir, 'index.html'), 'utf8')).toBe('old'); + expect(await readdir(fx.journalDir)).toEqual(['crash.json']); + }); + + it('rejects a corrupt journal that points outside the allowed projects root', async () => { + const fx = await fixture(); + const outside = await mkdtemp(path.join(os.tmpdir(), 'od-outside-promotion-')); + roots.push(outside); + await writeFile(path.join(outside, 'caller.txt'), 'preserve'); + await mkdir(fx.journalDir, { recursive: true }); + const record: TeamMirrorPromotionJournalRecord = { + schemaVersion: 1, + id: 'crash', + receipt: receipt(), + liveDir: outside, + stageDir: fx.stageDir, + recoveryDir: path.join(fx.root, '.project-1.od-pull-recovery-crash'), + liveExisted: false, + phase: 'promoted', + promotedIdentity: fx.stageIdentity, + }; + await writeFile(path.join(fx.journalDir, 'crash.json'), JSON.stringify(record)); + const errors: unknown[] = []; + + await recoverAuthorizedTeamProjectPromotions({ + journalDir: fx.journalDir, + allowedProjectsRoot: fx.root, + isCommitted: () => false, + onError: (error) => errors.push(error), + }); + + expect(errors).toHaveLength(1); + expect(await readFile(path.join(outside, 'caller.txt'), 'utf8')).toBe('preserve'); + expect(await readdir(fx.journalDir)).toEqual(['crash.json']); + }); + + it('fsyncs the journal and project directories across promotion boundaries', async () => { + const fx = await fixture(); + const syncDirectory = vi.fn(async (_directory: string) => undefined); + + await promoteAuthorizedTeamProjectStage({ + receipt: receipt(), + liveDir: fx.liveDir, + stageDir: fx.stageDir, + expectedStageIdentity: fx.stageIdentity, + journalDir: fx.journalDir, + activeWorkspaceGeneration: 4, + getActiveWorkspaceSnapshot: () => ({ workspaceId: 'workspace-1', generation: 4 }), + expectedWorkspaceId: 'workspace-1', + isExpectedVersion: () => true, + validateReceipt: () => undefined, + commit: () => ({ localRecordChanged: false }), + durability: { syncDirectory }, + }); + + expect(syncDirectory.mock.calls.map(([directory]) => directory)).toEqual( + expect.arrayContaining([ + fx.journalDir, + path.dirname(fx.journalDir), + fx.root, + ]), + ); + expect(syncDirectory.mock.calls.slice(0, 2).map(([directory]) => directory)).toEqual([ + fx.root, + fx.journalDir, + ]); + expect(syncDirectory.mock.calls.filter(([directory]) => directory === fx.root).length) + .toBeGreaterThanOrEqual(3); + }); +}); diff --git a/apps/daemon/tests/collab/team-resource-materialization.test.ts b/apps/daemon/tests/collab/team-resource-materialization.test.ts new file mode 100644 index 00000000000..948ed24317a --- /dev/null +++ b/apps/daemon/tests/collab/team-resource-materialization.test.ts @@ -0,0 +1,202 @@ +import { mkdtemp, readFile, symlink, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + materializeWorkspaceScopedTeamResource, + readWorkspaceScopedTeamResourceFile, + readTeamResourceMaterialization, + teamResourceMaterializationDir, +} from '../../src/collab/team-resource-materialization.js'; + +const roots: string[] = []; + +afterEach(async () => { + const { rm } = await import('node:fs/promises'); + await Promise.all(roots.splice(0).map((root) => + rm(root, { recursive: true, force: true }), + )); +}); + +async function tempRoot(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), 'od-team-resource-scope-')); + roots.push(root); + return root; +} + +describe('workspace-scoped Team resource materialization', () => { + it.each(['design_system', 'plugin', 'skill'] as const)( + 'keeps Workspace A and B copies of the same %s id physically and logically isolated', + async (kind) => { + const root = await tempRoot(); + for (const [workspaceId, content] of [ + ['workspace-a', 'content-from-a'], + ['workspace-b', 'content-from-b'], + ] as const) { + await materializeWorkspaceScopedTeamResource({ + kindRoot: root, + identity: { + kind, + workspaceId, + resourceId: 'same-id', + hubResourceId: `${kind}-${workspaceId}-same-id`, + }, + pullInto: async (dir) => { + await writeFile(path.join(dir, 'content.txt'), content); + }, + verifyWorkspaceScope: async () => true, + verifyStillShared: async () => true, + }); + } + + const aDir = teamResourceMaterializationDir(root, 'workspace-a', 'same-id'); + const bDir = teamResourceMaterializationDir(root, 'workspace-b', 'same-id'); + expect(aDir).not.toBe(bDir); + await expect( + readWorkspaceScopedTeamResourceFile( + root, + 'workspace-a', + 'same-id', + 'content.txt', + ), + ).resolves.toEqual(Buffer.from('content-from-a')); + await expect( + readWorkspaceScopedTeamResourceFile( + root, + 'workspace-b', + 'same-id', + 'content.txt', + ), + ).resolves.toEqual(Buffer.from('content-from-b')); + await expect( + readTeamResourceMaterialization(root, 'workspace-a', 'same-id'), + ).resolves.toMatchObject({ + workspaceId: 'workspace-a', + resourceId: 'same-id', + sourceKey: `team:${kind}:workspace-a:same-id`, + }); + await expect( + readTeamResourceMaterialization(root, 'workspace-b', 'same-id'), + ).resolves.toMatchObject({ + workspaceId: 'workspace-b', + resourceId: 'same-id', + sourceKey: `team:${kind}:workspace-b:same-id`, + }); + }, + ); + + it.each([ + ['membership revoked', false, true], + ['resource unshared', true, false], + ] as const)( + 'does not expose downloaded bytes or update the registry when %s before commit', + async (_label, scopeValid, stillShared) => { + const root = await tempRoot(); + const result = await materializeWorkspaceScopedTeamResource({ + kindRoot: root, + identity: { + kind: 'skill', + workspaceId: 'workspace-a', + resourceId: 'revoked-during-pull', + hubResourceId: 'skill-workspace-a-revoked-during-pull', + }, + pullInto: async (dir) => { + await writeFile(path.join(dir, 'downloaded.txt'), 'must-stay-invisible'); + }, + verifyWorkspaceScope: async () => scopeValid, + verifyStillShared: async () => stillShared, + }); + + expect(result).toEqual({ status: 'revoked' }); + await expect( + readTeamResourceMaterialization(root, 'workspace-a', 'revoked-during-pull'), + ).resolves.toBeNull(); + await expect( + readFile( + path.join( + teamResourceMaterializationDir( + root, + 'workspace-a', + 'revoked-during-pull', + ), + 'downloaded.txt', + ), + 'utf8', + ), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }, + ); + + it('preserves the previously committed copy when a replacement loses authority', async () => { + const root = await tempRoot(); + const identity = { + kind: 'plugin' as const, + workspaceId: 'workspace-a', + resourceId: 'same-plugin', + hubResourceId: 'plugin-workspace-a-same-plugin', + }; + await materializeWorkspaceScopedTeamResource({ + kindRoot: root, + identity, + pullInto: (dir) => writeFile(path.join(dir, 'content.txt'), 'version-one'), + verifyWorkspaceScope: async () => true, + verifyStillShared: async () => true, + }); + const before = await readTeamResourceMaterialization( + root, + 'workspace-a', + 'same-plugin', + ); + + await expect( + materializeWorkspaceScopedTeamResource({ + kindRoot: root, + identity, + pullInto: (dir) => writeFile(path.join(dir, 'content.txt'), 'revoked-version-two'), + verifyWorkspaceScope: async () => true, + verifyStillShared: async () => false, + }), + ).resolves.toEqual({ status: 'revoked' }); + + await expect( + readWorkspaceScopedTeamResourceFile( + root, + 'workspace-a', + 'same-plugin', + 'content.txt', + ), + ).resolves.toEqual(Buffer.from('version-one')); + await expect( + readTeamResourceMaterialization(root, 'workspace-a', 'same-plugin'), + ).resolves.toEqual(before); + }); + + it('refuses a materialized symlink that escapes the scoped Workspace root', async () => { + const root = await tempRoot(); + const outside = path.join(root, 'outside-secret.txt'); + await writeFile(outside, 'must-not-be-readable'); + await materializeWorkspaceScopedTeamResource({ + kindRoot: root, + identity: { + kind: 'skill', + workspaceId: 'workspace-a', + resourceId: 'symlink-skill', + hubResourceId: 'skill-workspace-a-symlink-skill', + }, + pullInto: async (dir) => { + await symlink(outside, path.join(dir, 'escaped.txt')); + }, + verifyWorkspaceScope: async () => true, + verifyStillShared: async () => true, + }); + + await expect( + readWorkspaceScopedTeamResourceFile( + root, + 'workspace-a', + 'symlink-skill', + 'escaped.txt', + ), + ).resolves.toBeNull(); + }); +}); diff --git a/apps/daemon/tests/collab/team-share-scope.test.ts b/apps/daemon/tests/collab/team-share-scope.test.ts new file mode 100644 index 00000000000..d85a4a9750b --- /dev/null +++ b/apps/daemon/tests/collab/team-share-scope.test.ts @@ -0,0 +1,320 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + closeDatabase, + ensureWorkspaceProject, + findTeamWorkspaceIdForProject, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + insertProject, + listTeamWorkspaceProjectShares, + openDatabase, + rebindWorkspaceProject, + updateWorkspaceProject, +} from '../../src/db.js'; +import { + createWorkspaceTypeRegistry, + impossibleTeamShareRows, + projectCollabScope, + refuseTeamShareScope, +} from '../../src/collab/team-share-scope.js'; + +// The reporter's real ids, kept verbatim: `OD Feature Team` (a TEAM workspace) +// and the owner's PERSONAL workspace, which is what their `workspace_projects` +// row for `Simple Deck` was pinned to. +const PERSONAL_WS = 'eh0z7baa1w6jjgyed3v32y0j'; +const TEAM_WS = 'vp44mftzknedrrqgy05oqpv9'; + +let tmp: string; + +beforeEach(async () => { + tmp = await mkdtemp(path.join(os.tmpdir(), 'od-team-share-scope-')); +}); + +afterEach(async () => { + closeDatabase(); + await rm(tmp, { recursive: true, force: true }); +}); + +function seedImpossibleTeamShare(projectId: string) { + const db = openDatabase(tmp, { dataDir: tmp }); + const now = Date.now(); + insertProject(db, { id: projectId, name: 'Simple Deck', createdAt: now, updatedAt: now }); + ensureWorkspaceProject(db, { + projectId, + // The contradiction: a TEAM projection pinned to a PERSONAL workspace. + workspaceId: PERSONAL_WS, + visibility: 'team', + resourceState: 'active', + syncState: 'pending_upload', + resourceHubResourceId: 'resource-keyed-on-the-personal-workspace', + createdByWorkspaceMemberId: 'member-owner', + updatedByWorkspaceMemberId: 'member-owner', + createdAt: now, + updatedAt: now, + }); + return db; +} + +function registryKnowing(...facts: Array<{ workspaceId: string; workspaceType: string }>) { + const registry = createWorkspaceTypeRegistry(); + registry.learn(facts); + return registry; +} + +describe('team share scope invariant', () => { + it('does not pin a team row whose workspace is a personal workspace', () => { + const projectId = 'project-impossible-scope'; + const db = seedImpossibleTeamShare(projectId); + const refused: string[] = []; + + // This is exactly the composition the daemon uses for every project-scoped + // collab call (presence heartbeat / list / leave): the project's pinned + // workspace outranks the local selection. + const scope = projectCollabScope({ + projectId, + projectWorkspaceId: findTeamWorkspaceIdForProject(db, projectId), + localSelection: TEAM_WS, + registry: registryKnowing( + { workspaceId: PERSONAL_WS, workspaceType: 'personal' }, + { workspaceId: TEAM_WS, workspaceType: 'team' }, + ), + onRefused: ({ workspaceId }) => refused.push(workspaceId), + }).workspaceId; + + // The personal workspace has no team plane, so B answers every call scoped + // to it with 403 missing_principal. The local selection — which holds the + // real team workspace — must win instead. + expect(scope).toBe(TEAM_WS); + // …and the refusal is observable, not silent. + expect(refused).toEqual([PERSONAL_WS]); + // The row itself is untouched by a read. + expect(getWorkspaceProject(db, PERSONAL_WS, projectId)).toMatchObject({ + visibility: 'team', + }); + }); + + it('still pins a team row whose workspace is a real team workspace', () => { + const projectId = 'project-valid-scope'; + const db = openDatabase(tmp, { dataDir: tmp }); + const now = Date.now(); + insertProject(db, { id: projectId, name: 'Simple Deck', createdAt: now, updatedAt: now }); + ensureWorkspaceProject(db, { + projectId, + workspaceId: TEAM_WS, + visibility: 'team', + resourceState: 'active', + syncState: 'synced', + createdAt: now, + updatedAt: now, + }); + + // The whole point of pinning is that it survives a workspace switch made on + // another device — the local selection here is deliberately something else. + expect( + projectCollabScope({ + projectWorkspaceId: findTeamWorkspaceIdForProject(db, projectId), + localSelection: 'ws-switched-elsewhere', + registry: registryKnowing({ workspaceId: TEAM_WS, workspaceType: 'team' }), + }), + ).toEqual({ workspaceId: TEAM_WS, source: 'project' }); + }); + + it('pins an unknown workspace rather than guessing it is broken', () => { + // Evidence-gated: a workspace the daemon has not learned about (cold start, + // signed out, directory unreachable) is never refused. + expect( + projectCollabScope({ + projectWorkspaceId: 'ws-never-seen', + localSelection: TEAM_WS, + registry: createWorkspaceTypeRegistry(), + }), + ).toEqual({ workspaceId: 'ws-never-seen', source: 'project' }); + }); + + it('refuses on either witness: the caller assertion or the directory', () => { + const registry = registryKnowing({ workspaceId: PERSONAL_WS, workspaceType: 'personal' }); + expect(refuseTeamShareScope(PERSONAL_WS, { assertedType: 'personal' })).toBe('asserted_personal'); + expect(refuseTeamShareScope(PERSONAL_WS, { registry })).toBe('directory_personal'); + // A caller that lies about the type is still caught by the directory. + expect(refuseTeamShareScope(PERSONAL_WS, { assertedType: 'team', registry })).toBe( + 'directory_personal', + ); + // No witness, no refusal. + expect(refuseTeamShareScope(TEAM_WS, { assertedType: 'team', registry })).toBeNull(); + expect(refuseTeamShareScope('ws-never-seen', { registry })).toBeNull(); + }); + + it('heals only the contradictory rows and leaves personal rows alone', () => { + const brokenId = 'project-broken-share'; + const db = seedImpossibleTeamShare(brokenId); + const now = Date.now(); + + // A legitimately-personal row in the same personal workspace: normal, and + // must never be rewritten. + const personalId = 'project-personal-draft'; + insertProject(db, { id: personalId, name: 'Personal draft', createdAt: now, updatedAt: now }); + ensureWorkspaceProject(db, { + projectId: personalId, + workspaceId: PERSONAL_WS, + visibility: 'personal', + resourceState: 'active', + syncState: 'local_only', + createdAt: now, + updatedAt: now, + }); + + // A healthy team share: must survive untouched. + const healthyId = 'project-healthy-share'; + insertProject(db, { id: healthyId, name: 'Healthy share', createdAt: now, updatedAt: now }); + ensureWorkspaceProject(db, { + projectId: healthyId, + workspaceId: TEAM_WS, + visibility: 'team', + resourceState: 'active', + syncState: 'synced', + createdByWorkspaceMemberId: 'member-owner', + createdAt: now, + updatedAt: now, + }); + + const registry = registryKnowing( + { workspaceId: PERSONAL_WS, workspaceType: 'personal' }, + { workspaceId: TEAM_WS, workspaceType: 'team' }, + ); + const broken = impossibleTeamShareRows(listTeamWorkspaceProjectShares(db), registry); + expect(broken.map((row) => row.projectId)).toEqual([brokenId]); + + for (const row of broken) { + updateWorkspaceProject(db, row.workspaceId, row.projectId, { + visibility: 'personal', + resourceHubResourceId: null, + cloudTombstonedAt: null, + syncState: 'local_only', + }); + } + + expect(getWorkspaceProject(db, PERSONAL_WS, brokenId)).toMatchObject({ + visibility: 'personal', + resourceHubResourceId: null, + syncState: 'local_only', + // NOT tombstoned: a copy that genuinely exists in the team catalog must + // keep showing up instead of being suppressed as "unshared here". + cloudTombstonedAt: null, + }); + expect(getWorkspaceProject(db, PERSONAL_WS, personalId)).toMatchObject({ + visibility: 'personal', + syncState: 'local_only', + }); + expect(getWorkspaceProject(db, TEAM_WS, healthyId)).toMatchObject({ + visibility: 'team', + syncState: 'synced', + }); + + // After healing, the project's collab scope falls back to the real team + // workspace instead of the address that always 403s. + expect( + projectCollabScope({ + projectWorkspaceId: findTeamWorkspaceIdForProject(db, brokenId), + localSelection: TEAM_WS, + registry, + }).workspaceId, + ).toBe(TEAM_WS); + }); + + it('leaves rows in an unknown workspace alone when healing', () => { + const projectId = 'project-unknown-workspace'; + const db = seedImpossibleTeamShare(projectId); + // Empty registry = no evidence; healing must be a no-op rather than + // demoting every team share the daemon cannot currently classify. + expect( + impossibleTeamShareRows(listTeamWorkspaceProjectShares(db), createWorkspaceTypeRegistry()), + ).toEqual([]); + }); + + // The mirror-image bug: not a team row stuck in a personal workspace, but a + // PERSONAL row that predates a real share and now needs to become the + // team's row when the share event arrives. `Simple Deck` reproduced this + // live in the owner/member feature-test dogfood on 2026-07-22: the owner's + // local row for a project Lee shared into `OD Feature Team` stayed pinned + // to the owner's own personal workspace forever, so edits/renames Lee made + // never synced — the viewer just kept re-rendering the stale file. + describe('rebinding a stale personal row onto a real team share', () => { + function seedPersonalDraft(projectId: string) { + const db = openDatabase(tmp, { dataDir: tmp }); + const now = Date.now(); + insertProject(db, { id: projectId, name: 'Simple Deck', createdAt: now, updatedAt: now }); + ensureWorkspaceProject(db, { + projectId, + // Bound to the owner's OWN personal workspace, from before this + // project was ever shared — the exact shape a viewer's daemon has for + // a project it drafted locally, then someone else shared it TO them + // under a different id (or it round-tripped through a personal + // workspace the owner has since switched away from). + workspaceId: PERSONAL_WS, + visibility: 'personal', + resourceState: 'active', + syncState: 'local_only', + createdAt: now, + updatedAt: now, + }); + return db; + } + + it('updateWorkspaceProject alone cannot migrate the row (documents the bug)', () => { + const projectId = 'project-stale-personal-row'; + const db = seedPersonalDraft(projectId); + + // This is exactly what `persistWorkspaceProjectVisibility` used to call: + // an update scoped to the NEW (team) workspace, on a row that is still + // sitting under the OLD (personal) one. + const result = updateWorkspaceProject(db, TEAM_WS, projectId, { + visibility: 'team', + syncState: 'synced', + }); + + expect(result).toBeNull(); + // The row never moved — still personal, still under the stale workspace. + expect(getWorkspaceProjectByProjectId(db, projectId)).toMatchObject({ + workspaceId: PERSONAL_WS, + visibility: 'personal', + syncState: 'local_only', + }); + }); + + it('rebindWorkspaceProject migrates the row to the real team workspace', () => { + const projectId = 'project-stale-personal-row-fixed'; + const db = seedPersonalDraft(projectId); + + const result = rebindWorkspaceProject(db, projectId, { + workspaceId: TEAM_WS, + visibility: 'team', + createdByWorkspaceMemberId: 'member-owner', + updatedByWorkspaceMemberId: 'member-owner', + resourceHubResourceId: 'resource-under-the-real-team-workspace', + cloudTombstonedAt: null, + syncState: 'synced', + }); + + expect(result).toMatchObject({ + workspaceId: TEAM_WS, + visibility: 'team', + syncState: 'synced', + }); + // Reading by project id alone finds the single row, now under the team + // workspace — a subsequent open of this project pulls the sharer's + // updates instead of replaying the stale personal-draft snapshot. + expect(getWorkspaceProjectByProjectId(db, projectId)).toMatchObject({ + workspaceId: TEAM_WS, + visibility: 'team', + syncState: 'synced', + }); + // The two-key form scoped to the OLD workspace no longer finds anything + // — there is exactly one row, and it moved. + expect(getWorkspaceProject(db, PERSONAL_WS, projectId)).toBeUndefined(); + }); + }); +}); diff --git a/apps/daemon/tests/collab/workspace-billing-runtime.test.ts b/apps/daemon/tests/collab/workspace-billing-runtime.test.ts new file mode 100644 index 00000000000..ad9531dc787 --- /dev/null +++ b/apps/daemon/tests/collab/workspace-billing-runtime.test.ts @@ -0,0 +1,1438 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { VelaWorkspaceBillingProjection } from '../../src/integrations/vela-billing.js'; +import { + createWorkspaceBillingRuntimeCoordinator, + shouldEmitWorkspaceBillingRuntimeNudge, +} from '../../src/collab/workspace-billing-runtime.js'; + +const KEY_A = { workspaceId: 'workspace-a', workspaceMemberId: 'member-a' }; +const KEY_B = { workspaceId: 'workspace-b', workspaceMemberId: 'member-b' }; + +afterEach(() => { + vi.useRealTimers(); +}); + +function projection( + workspaceId: string, + workspaceMemberId: string, + balanceUsd: string, + billingRevision = '1', + walletRevision = '1', + planId: string | null = 'team_plus', + revisionClocks?: { + billing: { epoch: string; counter: string }; + wallet: { epoch: string; counter: string }; + }, +): VelaWorkspaceBillingProjection { + return { + snapshot: { + schemaVersion: 1, + workspaceId, + workspaceMemberId, + billingScopeVersion: 2, + billing: { billingState: planId ? 'active' : 'free', planId }, + wallet: { + balanceUsd, + expiresAt: null, + updatedAt: '2026-07-27T00:00:00.000Z', + }, + revisions: { billing: billingRevision, wallet: walletRevision }, + ...(revisionClocks ? { revisionClocks } : {}), + }, + workspaceBalance: { + workspaceId, + workspaceMemberId, + billingScopeVersion: 2, + balanceUsd, + expiresAt: null, + updatedAt: '2026-07-27T00:00:00.000Z', + }, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +describe('WorkspaceBillingRuntimeCoordinator', () => { + it('makes A → B → A generations wait for the newest trailing A read', async () => { + const firstA = deferred(); + let aCalls = 0; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async (key) => { + if (key.workspaceId === 'workspace-b') { + return projection('workspace-b', 'member-b', '2.00'); + } + aCalls += 1; + return aCalls === 1 + ? firstA.promise + : projection('workspace-a', 'member-a', '3.00', '3', '3'); + }, + }); + + const oldA = runtime.read(KEY_A, { + clientId: 'window-1', + clientGeneration: '1', + }); + await vi.waitFor(() => expect(aCalls).toBe(1)); + const b = await runtime.read(KEY_B, { + clientId: 'window-1', + clientGeneration: '2', + }); + expect(b.projection.workspaceBalance?.balanceUsd).toBe('2.00'); + + const latestA = runtime.read(KEY_A, { + clientId: 'window-1', + clientGeneration: '3', + }); + firstA.resolve(projection('workspace-a', 'member-a', '1.00')); + + await expect(latestA).resolves.toMatchObject({ + projection: { workspaceBalance: { balanceUsd: '3.00' } }, + state: { workspaceId: 'workspace-a', status: 'fresh' }, + }); + await expect(oldA).resolves.toMatchObject({ + projection: { workspaceBalance: { balanceUsd: '3.00' } }, + }); + expect(aCalls).toBe(2); + runtime.dispose(); + }); + + it('dedupes duplicate and out-of-order revisions and catches up a gap', async () => { + let calls = 0; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + calls += 1; + return projection( + 'workspace-a', + 'member-a', + String(calls), + calls === 1 ? '1' : '4', + '1', + ); + }, + }); + await runtime.read(KEY_A); + + runtime.invalidate({ + domain: 'subscription', + workspaceId: 'workspace-a', + revision: '1', + }); + runtime.invalidate({ + domain: 'subscription', + workspaceId: 'workspace-a', + revision: '0', + }); + await Promise.resolve(); + expect(calls).toBe(1); + + runtime.invalidate({ + domain: 'subscription', + workspaceId: 'workspace-a', + revision: '4', + }); + const caughtUp = await runtime.read(KEY_A); + expect(calls).toBe(2); + expect(caughtUp.state).toMatchObject({ + status: 'fresh', + reason: 'revision-gap', + sourceGapDetected: true, + }); + runtime.dispose(); + }); + + it('dedupes and detects gaps within one revision-clock epoch', async () => { + let calls = 0; + let counter = '1'; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + calls += 1; + return projection( + 'workspace-a', + 'member-a', + counter, + `billing:v1:${counter}`, + 'wallet:v1:1', + 'team_plus', + { + billing: { epoch: 'billing-epoch-a', counter }, + wallet: { epoch: 'wallet-epoch-a', counter: '1' }, + }, + ); + }, + }); + await runtime.read(KEY_A); + + runtime.invalidate({ + domain: 'subscription', + workspaceId: 'workspace-a', + revision: 'billing:v1:1', + revisionClock: { epoch: 'billing-epoch-a', counter: '1' }, + }); + runtime.invalidate({ + domain: 'subscription', + workspaceId: 'workspace-a', + revision: 'billing:v1:0', + revisionClock: { epoch: 'billing-epoch-a', counter: '0' }, + }); + await Promise.resolve(); + expect(calls).toBe(1); + + counter = '4'; + runtime.invalidate({ + domain: 'subscription', + workspaceId: 'workspace-a', + revision: 'billing:v1:4', + revisionClock: { epoch: 'billing-epoch-a', counter: '4' }, + }); + const caughtUp = await runtime.read(KEY_A); + expect(calls).toBe(2); + expect(caughtUp.state).toMatchObject({ + status: 'fresh', + reason: 'revision-gap', + sourceGapDetected: true, + }); + runtime.dispose(); + }); + + it('accepts a counter reset after a revision-clock epoch change', async () => { + let clock = { epoch: 'billing-epoch-a', counter: '9' }; + let calls = 0; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + calls += 1; + return projection( + 'workspace-a', + 'member-a', + String(calls), + `billing:v1:${clock.counter}`, + 'wallet:v1:1', + 'team_plus', + { + billing: clock, + wallet: { epoch: 'wallet-epoch-a', counter: '1' }, + }, + ); + }, + }); + await runtime.read(KEY_A); + + clock = { epoch: 'billing-epoch-b', counter: '1' }; + runtime.invalidate({ + domain: 'subscription', + workspaceId: 'workspace-a', + revision: 'billing:v1:1', + revisionClock: clock, + }); + const refreshed = await runtime.read(KEY_A); + + expect(calls).toBe(2); + expect(refreshed.state).toMatchObject({ + status: 'fresh', + reason: 'revision-epoch-change', + sourceGapDetected: false, + }); + runtime.dispose(); + }); + + it('rebases to a fenced authoritative snapshot that has advanced beyond the event epoch', async () => { + let clock = { epoch: 'billing-epoch-a', counter: '9' }; + let calls = 0; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + calls += 1; + return projection( + 'workspace-a', + 'member-a', + String(calls), + `billing:v1:${clock.counter}`, + 'wallet:v1:1', + 'team_plus', + { + billing: clock, + wallet: { epoch: 'wallet-epoch-a', counter: '1' }, + }, + ); + }, + }); + await runtime.read(KEY_A); + + // The producer's fenced snapshot has already crossed C by the time the B + // event reaches this consumer. + clock = { epoch: 'billing-epoch-c', counter: '1' }; + runtime.invalidate({ + domain: 'subscription', + workspaceId: 'workspace-a', + revision: 'billing:v1:1', + revisionClock: { epoch: 'billing-epoch-b', counter: '1' }, + }); + const rebased = await runtime.read(KEY_A); + + expect(rebased).toMatchObject({ + projection: { + snapshot: { + revisionClocks: { + billing: { epoch: 'billing-epoch-c', counter: '1' }, + }, + }, + }, + state: { + status: 'fresh', + errorCode: null, + sourceGapDetected: false, + }, + }); + + // The legacy alias for the B write can arrive after the C snapshot has + // already committed. B is now a retired fence and must not regress the + // accepted C baseline or schedule another projection read. + runtime.invalidate({ + domain: 'legacy', + workspaceId: 'workspace-a', + revision: 'billing:v1:1', + revisionClock: { epoch: 'billing-epoch-b', counter: '1' }, + }); + await Promise.resolve(); + expect(runtime.peek(KEY_A)?.state).toMatchObject({ + status: 'fresh', + errorCode: null, + }); + expect(calls).toBe(2); + + runtime.reconnect('workspace-a'); + const afterReconnect = await runtime.read(KEY_A); + expect(afterReconnect.state).toMatchObject({ + status: 'fresh', + errorCode: null, + reason: 'reconnect', + }); + expect(calls).toBe(3); + runtime.dispose(); + }); + + it('rejects an authoritative reconnect snapshot from a retired revision epoch', async () => { + vi.useFakeTimers(); + let clock = { epoch: 'billing-epoch-a', counter: '9' }; + let calls = 0; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + calls += 1; + return projection( + 'workspace-a', + 'member-a', + String(calls), + `billing:v1:${clock.counter}`, + 'wallet:v1:1', + 'team_plus', + { + billing: clock, + wallet: { epoch: 'wallet-epoch-a', counter: '1' }, + }, + ); + }, + }); + + await runtime.read(KEY_A); + + clock = { epoch: 'billing-epoch-b', counter: '1' }; + runtime.invalidate({ + domain: 'subscription', + workspaceId: 'workspace-a', + revision: 'billing:v1:1', + revisionClock: clock, + }); + await runtime.read(KEY_A); + + clock = { epoch: 'billing-epoch-c', counter: '1' }; + runtime.invalidate({ + domain: 'subscription', + workspaceId: 'workspace-a', + revision: 'billing:v1:1', + revisionClock: clock, + }); + const current = await runtime.read(KEY_A); + expect(current).toMatchObject({ + projection: { + snapshot: { + revisionClocks: { + billing: { epoch: 'billing-epoch-c', counter: '1' }, + }, + }, + }, + state: { status: 'fresh', errorCode: null }, + }); + + // An authoritative reconnect can lag behind the already accepted C fence. + // B is retired even though its counter is numerically newer, so it must + // fail closed and preserve the last-good C projection. + clock = { epoch: 'billing-epoch-b', counter: '99' }; + runtime.reconnect('workspace-a'); + const afterReconnect = await runtime.read(KEY_A); + + expect(afterReconnect).toMatchObject({ + projection: { + snapshot: { + revisionClocks: { + billing: { epoch: 'billing-epoch-c', counter: '1' }, + }, + }, + }, + state: { + status: 'error', + errorCode: 'workspace_billing_revision_not_caught_up', + reason: 'reconnect', + retryAt: expect.any(String), + }, + }); + expect(calls).toBe(4); + + clock = { epoch: 'billing-epoch-c', counter: '2' }; + await vi.advanceTimersByTimeAsync(5_000); + await vi.waitFor(() => expect(calls).toBe(5)); + expect(runtime.peek(KEY_A)).toMatchObject({ + projection: { + snapshot: { + revisionClocks: { + billing: { epoch: 'billing-epoch-c', counter: '2' }, + }, + }, + }, + state: { + status: 'fresh', + errorCode: null, + reason: 'bounded-retry', + retryAt: null, + }, + }); + runtime.dispose(); + }); + + it('rejects a direct authoritative refresh from a retired revision epoch', async () => { + let clock = { epoch: 'billing-epoch-a', counter: '9' }; + let calls = 0; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + calls += 1; + return projection( + 'workspace-a', + 'member-a', + String(calls), + `billing:v1:${clock.counter}`, + 'wallet:v1:1', + 'team_plus', + { + billing: clock, + wallet: { epoch: 'wallet-epoch-a', counter: '1' }, + }, + ); + }, + retryDelaysMs: [], + }); + + await runtime.read(KEY_A); + + clock = { epoch: 'billing-epoch-b', counter: '1' }; + runtime.invalidate({ + domain: 'subscription', + workspaceId: 'workspace-a', + revision: 'billing:v1:1', + revisionClock: clock, + }); + await runtime.read(KEY_A); + + clock = { epoch: 'billing-epoch-c', counter: '1' }; + runtime.invalidate({ + domain: 'subscription', + workspaceId: 'workspace-a', + revision: 'billing:v1:1', + revisionClock: clock, + }); + await runtime.read(KEY_A); + + clock = { epoch: 'billing-epoch-b', counter: '99' }; + runtime.refreshAll('authoritative-refresh'); + const afterRefresh = await runtime.read(KEY_A); + + expect(afterRefresh).toMatchObject({ + projection: { + snapshot: { + revisionClocks: { + billing: { epoch: 'billing-epoch-c', counter: '1' }, + }, + }, + }, + state: { + status: 'error', + errorCode: 'workspace_billing_revision_not_caught_up', + reason: 'authoritative-refresh', + }, + }); + expect(calls).toBe(4); + runtime.dispose(); + }); + + it('rejects a fenced read that remains on the pre-event authoritative epoch', async () => { + let clock = { epoch: 'billing-epoch-a', counter: '9' }; + let calls = 0; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + calls += 1; + return projection( + 'workspace-a', + 'member-a', + String(calls), + `billing:v1:${clock.counter}`, + 'wallet:v1:1', + 'team_plus', + { + billing: clock, + wallet: { epoch: 'wallet-epoch-a', counter: '1' }, + }, + ); + }, + retryDelaysMs: [], + }); + await runtime.read(KEY_A); + + clock = { epoch: 'billing-epoch-a', counter: '10' }; + runtime.invalidate({ + domain: 'subscription', + workspaceId: 'workspace-a', + revision: 'billing:v1:1', + revisionClock: { epoch: 'billing-epoch-b', counter: '1' }, + }); + const stale = await runtime.read(KEY_A); + + expect(stale).toMatchObject({ + projection: { + snapshot: { + revisionClocks: { + billing: { epoch: 'billing-epoch-a', counter: '9' }, + }, + }, + }, + state: { + status: 'error', + errorCode: 'workspace_billing_revision_not_caught_up', + }, + }); + runtime.reconnect('workspace-a'); + const afterReconnect = await runtime.read(KEY_A); + expect(afterReconnect.state).toMatchObject({ + status: 'error', + errorCode: 'workspace_billing_revision_not_caught_up', + }); + expect(calls).toBe(3); + runtime.dispose(); + }); + + it('dedupes clocked subscription and legacy aliases across billing domains', async () => { + for (const domains of [ + ['subscription', 'legacy'], + ['legacy', 'subscription'], + ] as const) { + let clock = { epoch: 'billing-epoch-a', counter: '1' }; + let calls = 0; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + calls += 1; + return projection( + 'workspace-a', + 'member-a', + String(calls), + `billing:v1:${clock.counter}`, + 'wallet:v1:1', + 'team_plus', + { + billing: clock, + wallet: { epoch: 'wallet-epoch-a', counter: '1' }, + }, + ); + }, + }); + await runtime.read(KEY_A); + + clock = { epoch: 'billing-epoch-b', counter: '1' }; + for (const domain of domains) { + runtime.invalidate({ + domain, + workspaceId: 'workspace-a', + revision: 'billing:v1:1', + revisionClock: clock, + }); + } + await runtime.read(KEY_A); + + expect(calls).toBe(2); + runtime.dispose(); + } + }); + + it('keeps unclocked subscription and legacy aliases as independent compatibility invalidations', async () => { + let revision = 'billing:v1:1'; + let calls = 0; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + calls += 1; + return projection( + 'workspace-a', + 'member-a', + String(calls), + revision, + 'wallet:v1:1', + ); + }, + }); + await runtime.read(KEY_A); + + revision = 'billing:v1:2'; + runtime.invalidate({ + domain: 'subscription', + workspaceId: 'workspace-a', + revision, + }); + runtime.invalidate({ + domain: 'legacy', + workspaceId: 'workspace-a', + revision, + }); + await runtime.read(KEY_A); + + expect(calls).toBe(3); + runtime.dispose(); + }); + + it('requires a clocked snapshot to catch up to the accepted event clock', async () => { + vi.useFakeTimers(); + let counter = '1'; + let calls = 0; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + calls += 1; + return projection( + 'workspace-a', + 'member-a', + counter, + `billing:v1:${counter}`, + 'wallet:v1:1', + 'team_plus', + { + billing: { epoch: 'billing-epoch-a', counter }, + wallet: { epoch: 'wallet-epoch-a', counter: '1' }, + }, + ); + }, + }); + await runtime.read(KEY_A); + + runtime.invalidate({ + domain: 'subscription', + workspaceId: 'workspace-a', + revision: 'billing:v1:2', + revisionClock: { epoch: 'billing-epoch-a', counter: '2' }, + }); + const behind = await runtime.read(KEY_A); + expect(behind.state).toMatchObject({ + status: 'error', + errorCode: 'workspace_billing_revision_not_caught_up', + }); + + counter = '2'; + await vi.advanceTimersByTimeAsync(5_000); + await vi.waitFor(() => expect(calls).toBe(3)); + expect(runtime.peek(KEY_A)?.state.status).toBe('fresh'); + runtime.dispose(); + }); + + it('keeps prefixed legacy revisions opaque when no valid clock is available', async () => { + let revision = 'billing:v1:9'; + let calls = 0; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + calls += 1; + return projection( + 'workspace-a', + 'member-a', + String(calls), + revision, + 'wallet:v1:1', + ); + }, + }); + await runtime.read(KEY_A); + + revision = 'billing:v1:1'; + runtime.invalidate({ + domain: 'subscription', + workspaceId: 'workspace-a', + revision, + revisionClock: { epoch: '', counter: '-1' }, + }); + const refreshed = await runtime.read(KEY_A); + + expect(calls).toBe(2); + expect(refreshed.state).toMatchObject({ + status: 'fresh', + sourceGapDetected: false, + }); + runtime.dispose(); + }); + + it('uses the 30 second daemon floor to recover a lost SSE invalidation', async () => { + vi.useFakeTimers(); + let balance = '1.00'; + let calls = 0; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + calls += 1; + return projection('workspace-a', 'member-a', balance); + }, + }); + await runtime.read(KEY_A); + balance = '2.00'; + + await vi.advanceTimersByTimeAsync(29_999); + expect(calls).toBe(1); + await vi.advanceTimersByTimeAsync(1); + await vi.waitFor(() => expect(calls).toBe(2)); + expect(runtime.peek(KEY_A)?.projection.workspaceBalance?.balanceUsd).toBe('2.00'); + runtime.dispose(); + }); + + it('polls only the exact workspace/member interests still owned by clients', async () => { + vi.useFakeTimers(); + const calls: string[] = []; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async (key) => { + calls.push(key.workspaceId); + return projection(key.workspaceId, key.workspaceMemberId, '1.00'); + }, + }); + + await runtime.read(KEY_A, { + clientId: 'window-1', + clientGeneration: '1', + }); + await runtime.read(KEY_B, { + clientId: 'window-1', + clientGeneration: '2', + }); + expect(calls).toEqual(['workspace-a', 'workspace-b']); + + await vi.advanceTimersByTimeAsync(30_000); + await vi.waitFor(() => expect(calls).toHaveLength(3)); + expect(calls).toEqual(['workspace-a', 'workspace-b', 'workspace-b']); + runtime.dispose(); + }); + + it('forces an authoritative catch-up after reconnect', async () => { + let balance = '1.00'; + let calls = 0; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + calls += 1; + return projection('workspace-a', 'member-a', balance); + }, + }); + await runtime.read(KEY_A); + balance = '2.00'; + + runtime.reconnect('workspace-a'); + await runtime.read(KEY_A); + expect(calls).toBe(2); + expect(runtime.peek(KEY_A)?.projection.workspaceBalance?.balanceUsd).toBe('2.00'); + runtime.dispose(); + }); + + it('retries when an event revision arrives before the read model catches up', async () => { + vi.useFakeTimers(); + let revision = '1'; + let calls = 0; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + calls += 1; + return projection('workspace-a', 'member-a', revision, revision, revision); + }, + }); + await runtime.read(KEY_A); + + runtime.invalidate({ + domain: 'wallet', + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + revision: '2', + }); + const staleRead = await runtime.read(KEY_A); + expect(staleRead.state).toMatchObject({ + status: 'error', + errorCode: 'workspace_billing_revision_not_caught_up', + }); + revision = '2'; + await vi.advanceTimersByTimeAsync(5_000); + await vi.waitFor(() => expect(calls).toBe(3)); + expect(runtime.peek(KEY_A)).toMatchObject({ + projection: { workspaceBalance: { balanceUsd: '2' } }, + state: { status: 'fresh' }, + }); + runtime.dispose(); + }); + + it('starts a new daemon runtime stale-free after a long offline interval', async () => { + vi.useFakeTimers(); + let balance = '1.00'; + let calls = 0; + const createRuntime = () => + createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + calls += 1; + return projection('workspace-a', 'member-a', balance); + }, + }); + + const beforeOffline = createRuntime(); + await beforeOffline.read(KEY_A); + beforeOffline.dispose(); + balance = '9.00'; + await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1_000); + + const afterOffline = createRuntime(); + const result = await afterOffline.read(KEY_A); + expect(calls).toBe(2); + expect(result.projection.workspaceBalance?.balanceUsd).toBe('9.00'); + expect(result.state.status).toBe('fresh'); + afterOffline.dispose(); + }); + + it('does not serialize simultaneous windows interested in different workspaces', async () => { + const heldA = deferred(); + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async (key) => + key.workspaceId === 'workspace-a' + ? heldA.promise + : projection('workspace-b', 'member-b', '8.00'), + }); + + const a = runtime.read(KEY_A, { + clientId: 'window-a', + clientGeneration: '1', + }); + const b = await runtime.read(KEY_B, { + clientId: 'window-b', + clientGeneration: '1', + }); + expect(b.projection.workspaceBalance?.balanceUsd).toBe('8.00'); + + heldA.resolve(projection('workspace-a', 'member-a', '7.00')); + await expect(a).resolves.toMatchObject({ + projection: { workspaceBalance: { balanceUsd: '7.00' } }, + }); + runtime.dispose(); + }); + + it('clears sensitive data on member removal and refreshes a plan invalidation', async () => { + let planId: string | null = 'team_plus'; + let billingRevision = '1'; + let calls = 0; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + calls += 1; + return projection( + 'workspace-a', + 'member-a', + '4.00', + billingRevision, + '1', + planId, + ); + }, + }); + await runtime.read(KEY_A); + + runtime.revokeWorkspace('workspace-a'); + expect(runtime.peek(KEY_A)).toMatchObject({ + projection: { snapshot: null, workspaceBalance: null }, + state: { status: 'access-revoked', errorCode: 'workspace_not_authorized' }, + }); + expect(runtime.interestedKeys()).toEqual([]); + + runtime.authorizeWorkspaceMember(KEY_A); + await runtime.read(KEY_A); + planId = 'team_max'; + billingRevision = '9'; + runtime.invalidate({ + domain: 'subscription', + workspaceId: 'workspace-a', + revision: '9', + }); + const refreshed = await runtime.read(KEY_A); + expect(refreshed.projection.snapshot?.billing.planId).toBe('team_max'); + expect(calls).toBe(3); + runtime.dispose(); + }); + + it('cannot commit an in-flight pre-revoke response after reauthorization', async () => { + const stale = deferred(); + let calls = 0; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + calls += 1; + return calls === 1 + ? stale.promise + : projection('workspace-a', 'member-a', '8.00'); + }, + }); + const first = runtime.read(KEY_A); + await vi.waitFor(() => expect(calls).toBe(1)); + + runtime.revokeWorkspace('workspace-a'); + runtime.authorizeWorkspaceMember(KEY_A); + const reauthorized = runtime.read(KEY_A); + await vi.waitFor(() => expect(calls).toBe(2)); + stale.resolve(projection('workspace-a', 'member-a', '1.00')); + + await expect(reauthorized).resolves.toMatchObject({ + projection: { workspaceBalance: { balanceUsd: '8.00' } }, + state: { status: 'fresh' }, + }); + expect((await first).projection.workspaceBalance?.balanceUsd).not.toBe('1.00'); + runtime.dispose(); + }); + + it('never revives access-revoked state from invalidation, reconnect, or poll', async () => { + let calls = 0; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + calls += 1; + return projection('workspace-a', 'member-a', String(calls)); + }, + }); + await runtime.read(KEY_A); + runtime.revokeWorkspace('workspace-a'); + + runtime.invalidate({ + domain: 'wallet', + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + revision: '99', + }); + runtime.reconnect('workspace-a'); + runtime.refreshAll('poll-floor'); + await Promise.resolve(); + + expect(calls).toBe(1); + expect(runtime.peek(KEY_A)).toMatchObject({ + projection: { snapshot: null, workspaceBalance: null }, + state: { status: 'access-revoked' }, + }); + runtime.dispose(); + }); + + it('treats a snapshot-only projection as refreshable last-good data', async () => { + const held = deferred(); + let calls = 0; + const snapshotOnly = { + ...projection('workspace-a', 'member-a', '0.00'), + workspaceBalance: null, + }; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + calls += 1; + return calls === 1 ? snapshotOnly : held.promise; + }, + }); + await runtime.read(KEY_A); + runtime.invalidate({ + domain: 'subscription', + workspaceId: 'workspace-a', + revision: '2', + }); + + expect(runtime.peek(KEY_A)).toMatchObject({ + projection: { snapshot: { workspaceId: 'workspace-a' }, workspaceBalance: null }, + state: { status: 'refreshing' }, + }); + held.resolve({ + ...projection('workspace-a', 'member-a', '0.00', '2'), + workspaceBalance: null, + }); + await runtime.read(KEY_A); + runtime.dispose(); + }); + + it('retries transient failures with a bounded schedule and keeps the floor', async () => { + vi.useFakeTimers(); + let calls = 0; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + calls += 1; + if (calls < 3) throw Object.assign(new Error('temporary outage'), { code: 'temporary' }); + return projection('workspace-a', 'member-a', '5.00'); + }, + retryDelaysMs: [5_000, 15_000, 30_000], + }); + + const first = await runtime.read(KEY_A); + expect(first.state).toMatchObject({ status: 'error', errorCode: 'temporary' }); + expect(first.state.retryAt).not.toBeNull(); + const webReadAfterErrorSignal = await runtime.read(KEY_A); + expect(webReadAfterErrorSignal.state.status).toBe('error'); + expect(calls).toBe(1); + + await vi.advanceTimersByTimeAsync(5_000); + await vi.waitFor(() => expect(calls).toBe(2)); + expect(runtime.peek(KEY_A)?.state.status).toBe('error'); + + await vi.advanceTimersByTimeAsync(15_000); + await vi.waitFor(() => expect(calls).toBe(3)); + expect(runtime.peek(KEY_A)).toMatchObject({ + projection: { workspaceBalance: { balanceUsd: '5.00' } }, + state: { status: 'fresh', retryAt: null }, + }); + runtime.dispose(); + }); + + it('requires a live authoritative projection for an execution read', async () => { + let calls = 0; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + calls += 1; + if (calls === 1) return projection('workspace-a', 'member-a', '9.00'); + throw Object.assign(new Error('temporary outage'), { code: 'temporary' }); + }, + retryDelaysMs: [], + }); + + await runtime.read(KEY_A); + await expect(runtime.read(KEY_A, { + reason: 'authoritative-action-read', + requireFresh: true, + })).rejects.toMatchObject({ code: 'temporary' }); + expect(runtime.peek(KEY_A)).toMatchObject({ + state: { status: 'error', errorCode: 'temporary' }, + }); + expect(runtime.peek(KEY_A)?.projection.workspaceBalance?.balanceUsd).toBe('9.00'); + expect(calls).toBe(2); + runtime.dispose(); + }); + + it('publishes every freshness transition and expires last-good data at the hard TTL', async () => { + vi.useFakeTimers(); + const statuses: string[] = []; + let calls = 0; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + calls += 1; + if (calls === 1) return projection('workspace-a', 'member-a', '9.00'); + throw Object.assign(new Error('temporary outage'), { code: 'temporary' }); + }, + retryDelaysMs: [], + softTtlMs: 100, + hardTtlMs: 200, + onStateChange: (state) => statuses.push(state.status), + }); + + await runtime.read(KEY_A); + await vi.advanceTimersByTimeAsync(101); + const softExpired = await runtime.read(KEY_A); + expect(softExpired).toMatchObject({ + projection: { workspaceBalance: { balanceUsd: '9.00' } }, + state: { status: 'error' }, + }); + expect(statuses).toEqual(expect.arrayContaining([ + 'loading', + 'fresh', + 'stale', + 'refreshing', + 'error', + ])); + + await vi.advanceTimersByTimeAsync(100); + expect(runtime.peek(KEY_A)).toMatchObject({ + projection: { snapshot: null, workspaceBalance: null }, + state: { status: 'error' }, + }); + runtime.dispose(); + }); + + it('does not turn a terminal-state nudge into an event-read-event loop', async () => { + let calls = 0; + let revision = '1'; + let runtime!: ReturnType; + const nudgeReads: Promise[] = []; + runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + calls += 1; + return projection('workspace-a', 'member-a', revision, revision, revision); + }, + onStateChange: (state) => { + if (shouldEmitWorkspaceBillingRuntimeNudge(state)) { + nudgeReads.push(runtime.read(KEY_A)); + } + }, + }); + await runtime.read(KEY_A, { reason: 'explicit-read' }); + revision = '2'; + runtime.invalidate({ + domain: 'wallet', + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + revision: '2', + reason: 'vela-wallet-balance-changed', + }); + await runtime.read(KEY_A); + await Promise.all(nudgeReads); + + expect(calls).toBe(2); + expect(nudgeReads).toHaveLength(1); + runtime.dispose(); + }); + + it('does not double-nudge the web after one upstream billing event', async () => { + let revision = '1'; + let projectionCalls = 0; + let downstreamNudges = 0; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => { + projectionCalls += 1; + return projection( + 'workspace-a', + 'member-a', + revision, + revision, + revision, + ); + }, + onStateChange: (state) => { + if (shouldEmitWorkspaceBillingRuntimeNudge(state)) downstreamNudges += 1; + }, + }); + await runtime.read(KEY_A, { reason: 'explicit-billing-read' }); + + revision = '2'; + runtime.invalidate({ + domain: 'wallet', + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + revision, + reason: 'vela-wallet-balance-changed', + }); + // The hub event path already emits its compatibility nudge directly. + downstreamNudges += 1; + await runtime.read(KEY_A); + + expect(projectionCalls).toBe(2); + expect(downstreamNudges).toBe(1); + + runtime.reconnect('workspace-a'); + await runtime.read(KEY_A); + expect(downstreamNudges).toBe(2); + runtime.dispose(); + }); + + it('accepts the old CLI projection shape and enforces client generations', async () => { + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => ({ + snapshot: null, + workspaceBalance: projection( + 'workspace-a', + 'member-a', + '6.00', + ).workspaceBalance, + }), + }); + const first = await runtime.read(KEY_A, { + clientId: 'window-a', + clientGeneration: '10', + }); + expect(first).toMatchObject({ + projection: { snapshot: null, workspaceBalance: { balanceUsd: '6.00' } }, + state: { status: 'fresh' }, + }); + await expect( + runtime.read(KEY_A, { + clientId: 'window-a', + clientGeneration: '9', + }), + ).rejects.toMatchObject({ + code: 'stale_generation', + acceptedGeneration: '10', + }); + await expect( + runtime.read(KEY_B, { + clientId: 'window-a', + clientGeneration: '10', + }), + ).rejects.toMatchObject({ + code: 'generation_payload_mismatch', + acceptedGeneration: '10', + }); + runtime.dispose(); + }); + + it('rejects a projection that returns another workspace or member', async () => { + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async () => projection('workspace-b', 'member-b', '999.00'), + retryDelaysMs: [], + }); + const result = await runtime.read(KEY_A); + expect(result).toMatchObject({ + projection: { snapshot: null, workspaceBalance: null }, + state: { + status: 'error', + errorCode: 'workspace_billing_scope_mismatch', + }, + }); + runtime.dispose(); + }); + + it('retains a renderer full A + B interest set and replaces it atomically', async () => { + const interestSets: string[][] = []; + const calls: string[] = []; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async (key) => { + calls.push(key.workspaceId); + return projection(key.workspaceId, key.workspaceMemberId, '1.00'); + }, + onInterestSetChange: (interests) => { + interestSets.push(interests.map((interest) => interest.workspaceId).sort()); + }, + }); + + const lease = runtime.setClientInterests({ + clientId: 'renderer-1', + clientGeneration: '1', + interests: [KEY_A, KEY_B], + }); + expect(lease).toMatchObject({ + clientId: 'renderer-1', + acceptedGeneration: '1', + }); + await runtime.read(KEY_A, { + clientId: 'renderer-1', + clientGeneration: '1', + }); + await runtime.read(KEY_B, { + clientId: 'renderer-1', + clientGeneration: '1', + }); + expect(calls).toEqual(['workspace-a', 'workspace-b']); + + runtime.setClientInterests({ + clientId: 'renderer-1', + clientGeneration: '2', + interests: [KEY_B], + }); + expect(runtime.interestedKeys()).toEqual([KEY_B]); + expect(interestSets).toContainEqual(['workspace-a', 'workspace-b']); + expect(interestSets.at(-1)).toEqual(['workspace-b']); + runtime.dispose(); + }); + + it('expires a crashed renderer lease and evicts its inactive snapshot', async () => { + vi.useFakeTimers(); + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async (key) => + projection(key.workspaceId, key.workspaceMemberId, '1.00'), + interestLeaseMs: 100, + interestSweepIntervalMs: 10, + entryRetentionMs: 20, + }); + runtime.setClientInterests({ + clientId: 'crashed-renderer', + clientGeneration: '1', + interests: [KEY_A], + }); + await runtime.read(KEY_A, { + clientId: 'crashed-renderer', + clientGeneration: '1', + }); + expect(runtime.interestedKeys()).toEqual([KEY_A]); + + await vi.advanceTimersByTimeAsync(121); + expect(runtime.interestedKeys()).toEqual([]); + expect(runtime.peek(KEY_A)).toBeNull(); + runtime.dispose(); + }); + + it('bounds clients, per-client scopes, and retained runtime entries', () => { + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async (key) => + projection(key.workspaceId, key.workspaceMemberId, '1.00'), + maxClients: 1, + maxInterestsPerClient: 1, + maxEntries: 1, + }); + runtime.setClientInterests({ + clientId: 'renderer-1', + clientGeneration: '1', + interests: [KEY_A], + }); + expect(() => + runtime.setClientInterests({ + clientId: 'renderer-2', + clientGeneration: '1', + interests: [KEY_B], + }), + ).toThrowError(expect.objectContaining({ code: 'interest_capacity_exceeded' })); + expect(() => + runtime.setClientInterests({ + clientId: 'renderer-1', + clientGeneration: '2', + interests: [KEY_A, KEY_B], + }), + ).toThrowError(expect.objectContaining({ code: 'interest_capacity_exceeded' })); + runtime.dispose(); + }); + + it('does not let empty full sets consume the client cap', () => { + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async (key) => + projection(key.workspaceId, key.workspaceMemberId, '1.00'), + maxClients: 1, + }); + runtime.setClientInterests({ + clientId: 'empty-renderer', + clientGeneration: '1', + interests: [], + }); + expect(() => runtime.setClientInterests({ + clientId: 'real-renderer', + clientGeneration: '1', + interests: [KEY_A], + })).not.toThrow(); + expect(runtime.interestedKeys()).toEqual([KEY_A]); + runtime.dispose(); + }); + + it('resolves a queued read when its declared interest is released', async () => { + const active = deferred(); + const starts: string[] = []; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async (key) => { + starts.push(key.workspaceId); + return key.workspaceId === KEY_A.workspaceId + ? active.promise + : projection(key.workspaceId, key.workspaceMemberId, '2.00'); + }, + maxConcurrentRefreshes: 1, + }); + runtime.setClientInterests({ + clientId: 'renderer', + clientGeneration: '1', + interests: [KEY_A, KEY_B], + }); + const first = runtime.read(KEY_A, { + clientId: 'renderer', + clientGeneration: '1', + }); + await vi.waitFor(() => expect(starts).toEqual(['workspace-a'])); + const queued = runtime.read(KEY_B, { + clientId: 'renderer', + clientGeneration: '1', + }); + await Promise.resolve(); + expect(starts).toEqual(['workspace-a']); + + expect(runtime.releaseClientInterests('renderer', '1')).toBe(true); + await expect(queued).resolves.toMatchObject({ + projection: { snapshot: null, workspaceBalance: null }, + state: { workspaceId: 'workspace-b', status: 'loading' }, + }); + active.resolve(projection('workspace-a', 'member-a', '1.00')); + await first; + runtime.dispose(); + }); + + it('resolves a queued read when its declared interest lease expires', async () => { + vi.useFakeTimers(); + const active = deferred(); + const starts: string[] = []; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async (key) => { + starts.push(key.workspaceId); + return key.workspaceId === KEY_A.workspaceId + ? active.promise + : projection(key.workspaceId, key.workspaceMemberId, '2.00'); + }, + maxConcurrentRefreshes: 1, + interestLeaseMs: 100, + interestSweepIntervalMs: 10, + }); + runtime.setClientInterests({ + clientId: 'renderer', + clientGeneration: '1', + interests: [KEY_A, KEY_B], + }); + const first = runtime.read(KEY_A, { + clientId: 'renderer', + clientGeneration: '1', + }); + await vi.waitFor(() => expect(starts).toEqual(['workspace-a'])); + const queued = runtime.read(KEY_B, { + clientId: 'renderer', + clientGeneration: '1', + }); + await Promise.resolve(); + + await vi.advanceTimersByTimeAsync(101); + await expect(queued).resolves.toMatchObject({ + projection: { snapshot: null, workspaceBalance: null }, + state: { workspaceId: 'workspace-b', status: 'loading' }, + }); + active.resolve(projection('workspace-a', 'member-a', '1.00')); + await first; + runtime.dispose(); + }); + + it('caps global projection concurrency and refresh-start churn', async () => { + vi.useFakeTimers(); + const held = new Map>>(); + let active = 0; + let peak = 0; + const starts: string[] = []; + const runtime = createWorkspaceBillingRuntimeCoordinator({ + fetchProjection: async (key) => { + starts.push(key.workspaceId); + active += 1; + peak = Math.max(peak, active); + const gate = deferred(); + held.set(key.workspaceId, gate); + try { + return await gate.promise; + } finally { + active -= 1; + } + }, + maxConcurrentRefreshes: 2, + maxRefreshStartsPerWindow: 3, + refreshStartWindowMs: 1_000, + }); + const keys = ['a', 'b', 'c', 'd'].map((suffix) => ({ + workspaceId: `workspace-${suffix}`, + workspaceMemberId: `member-${suffix}`, + })); + const reads = keys.map((key) => runtime.read(key)); + + await vi.waitFor(() => expect(starts).toHaveLength(2)); + held.get('workspace-a')!.resolve(projection('workspace-a', 'member-a', '1')); + held.get('workspace-b')!.resolve(projection('workspace-b', 'member-b', '1')); + await vi.waitFor(() => expect(starts).toHaveLength(3)); + held.get('workspace-c')!.resolve(projection('workspace-c', 'member-c', '1')); + await Promise.resolve(); + expect(starts).toHaveLength(3); + + await vi.advanceTimersByTimeAsync(1_000); + await vi.waitFor(() => expect(starts).toHaveLength(4)); + held.get('workspace-d')!.resolve(projection('workspace-d', 'member-d', '1')); + await Promise.all(reads); + expect(peak).toBeLessThanOrEqual(2); + runtime.dispose(); + }); +}); diff --git a/apps/daemon/tests/collab/workspace-events-authority.test.ts b/apps/daemon/tests/collab/workspace-events-authority.test.ts new file mode 100644 index 00000000000..878cb1439de --- /dev/null +++ b/apps/daemon/tests/collab/workspace-events-authority.test.ts @@ -0,0 +1,134 @@ +import express from 'express'; +import { describe, expect, it, vi } from 'vitest'; + +import { + emitWorkspaceEventToScope, + registerCollabContextRoutes, + type WorkspaceEventSinksByWorkspace, +} from '../../src/routes/collab-context.js'; +import { createDevWorkspaceContextProvider } from '../../src/collab/workspace-context.js'; + +function directory() { + return { + ok: true as const, + items: [ + { + workspaceId: 'workspace-a', + workspaceName: 'Workspace A', + workspaceType: 'team' as const, + workspaceMemberId: 'member-a', + role: 'member' as const, + memberStatus: 'active' as const, + lifecycleState: 'active' as const, + }, + { + workspaceId: 'workspace-b', + workspaceName: 'Workspace B', + workspaceType: 'team' as const, + workspaceMemberId: 'member-b', + role: 'member' as const, + memberStatus: 'active' as const, + lifecycleState: 'active' as const, + }, + ], + }; +} + +function captureWorkspaceEventsHandler( + sinks: WorkspaceEventSinksByWorkspace, + sends: Array>, +) { + let handler: ((req: any, res: any) => Promise) | undefined; + const app = { + get(path: string, candidate: (req: any, res: any) => Promise) { + if (path === '/api/workspace/events') handler = candidate; + }, + post() {}, + put() {}, + delete() {}, + }; + registerCollabContextRoutes(app as unknown as express.Express, { + workspaceContext: createDevWorkspaceContextProvider(), + fetchWorkspaceDirectory: async () => directory(), + createSseResponse: () => { + const send = vi.fn(() => true); + sends.push(send); + return { send }; + }, + workspaceEventSinks: sinks, + }); + if (!handler) throw new Error('workspace events route not registered'); + return handler; +} + +function responseDouble() { + return { + statusCode: 200, + body: null as unknown, + listeners: new Map void>(), + status(code: number) { + this.statusCode = code; + return this; + }, + json(body: unknown) { + this.body = body; + return this; + }, + on(name: string, listener: () => void) { + this.listeners.set(name, listener); + return this; + }, + }; +} + +describe('GET /api/workspace/events exact authority', () => { + it('freshly verifies the exact navigation pair before registering a sink', async () => { + const sinks: WorkspaceEventSinksByWorkspace = new Map(); + const sends: Array> = []; + const handler = captureWorkspaceEventsHandler(sinks, sends); + const res = responseDouble(); + + await handler({ + query: { + workspaceId: 'workspace-a', + workspaceMemberId: 'member-other', + }, + get: () => undefined, + }, res); + + expect(res.statusCode).toBe(403); + expect(sinks.size).toBe(0); + expect(sends).toHaveLength(0); + }); + + it('partitions delivery by verified workspace instead of broadcasting globally', async () => { + const sinks: WorkspaceEventSinksByWorkspace = new Map(); + const sends: Array> = []; + const handler = captureWorkspaceEventsHandler(sinks, sends); + + for (const [workspaceId, workspaceMemberId] of [ + ['workspace-a', 'member-a'], + ['workspace-b', 'member-b'], + ]) { + await handler({ + query: { workspaceId, workspaceMemberId }, + get: () => undefined, + }, responseDouble()); + } + + expect(sinks.size).toBe(2); + expect(emitWorkspaceEventToScope( + sinks, + 'workspace-a', + { type: 'members-changed', at: 1 }, + )).toBe(true); + expect(sends[0]).toHaveBeenCalledWith( + 'members-changed', + { type: 'members-changed', at: 1 }, + ); + expect(sends[1]).not.toHaveBeenCalledWith( + 'members-changed', + { type: 'members-changed', at: 1 }, + ); + }); +}); diff --git a/apps/daemon/tests/collab/workspace-hub-subscriptions.test.ts b/apps/daemon/tests/collab/workspace-hub-subscriptions.test.ts new file mode 100644 index 00000000000..15d41befaa0 --- /dev/null +++ b/apps/daemon/tests/collab/workspace-hub-subscriptions.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { HubEventsSubscriber } from '../../src/collab/hub-events-subscriber.js'; +import { createWorkspaceHubSubscriptionManager } from '../../src/collab/workspace-hub-subscriptions.js'; + +describe('WorkspaceHubSubscriptionManager', () => { + it('dedupes explicit billing interests into one upstream per workspace', () => { + const started: string[] = []; + const stopped: string[] = []; + const manager = createWorkspaceHubSubscriptionManager({ + start: (workspaceId): HubEventsSubscriber => { + started.push(workspaceId); + return { + connected: () => true, + refreshEndpoint: vi.fn(), + stop: () => stopped.push(workspaceId), + }; + }, + }); + + manager.setBillingInterests(['workspace-a', 'workspace-b', 'workspace-b']); + expect(started).toEqual(['workspace-a', 'workspace-b']); + expect(manager.activeWorkspaceIds()).toEqual(['workspace-a', 'workspace-b']); + + manager.setBillingInterests(['workspace-b']); + expect(stopped).toEqual(['workspace-a']); + expect(manager.activeWorkspaceIds()).toEqual(['workspace-b']); + + manager.dispose(); + expect(stopped).toEqual(['workspace-a', 'workspace-b']); + }); + + it('stops a workspace immediately after its final reason is revoked', () => { + const stop = vi.fn(); + const manager = createWorkspaceHubSubscriptionManager({ + start: (): HubEventsSubscriber => ({ + connected: () => false, + refreshEndpoint: vi.fn(), + stop, + }), + }); + manager.setBillingInterests(['workspace-a']); + manager.setBillingInterests([]); + expect(stop).toHaveBeenCalledTimes(1); + expect(manager.activeWorkspaceIds()).toEqual([]); + manager.dispose(); + }); + + it('caps live upstream SSE connections without consulting ambient selection', () => { + const started: string[] = []; + const stopped: string[] = []; + const manager = createWorkspaceHubSubscriptionManager({ + maxSubscribers: 2, + start: (workspaceId): HubEventsSubscriber => { + started.push(workspaceId); + return { + connected: () => true, + refreshEndpoint: vi.fn(), + stop: () => stopped.push(workspaceId), + }; + }, + }); + + manager.setBillingInterests(['workspace-a', 'workspace-b', 'workspace-c']); + expect(manager.activeWorkspaceIds()).toEqual(['workspace-a', 'workspace-b']); + expect(started).toEqual(['workspace-a', 'workspace-b']); + expect(stopped).toEqual([]); + manager.dispose(); + }); +}); diff --git a/apps/daemon/tests/collab/workspace-multi-client-scope.test.ts b/apps/daemon/tests/collab/workspace-multi-client-scope.test.ts new file mode 100644 index 00000000000..aa9eaf7f323 --- /dev/null +++ b/apps/daemon/tests/collab/workspace-multi-client-scope.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from 'vitest'; +import { createVelaWorkspaceContextProvider } from '../../src/collab/vela-workspace-context.js'; + +// Two OD clients, ONE vela account. These suites model B from its source so the +// account-level Active Workspace can be reasoned about without a live backend: +// +// - `active_workspace_selections` is keyed by app_user_id ALONE +// (db/schema/public.hcl `primary_key { columns = [column.app_user_id] }`), +// so an account has exactly one selection — there is no client axis. +// - `GET /api/v1/workspaces` (the membership directory) is scoped by app user, +// NOT by that selection, so it answers the same for every client. +// - `GET /api/v1/workspaces/current` USED to answer only from that selection. +// It now also honours `x-vela-workspace-id`, the per-request workspace scope +// B already accepted on its resource plane, its billing scope routes and the +// Link gateway. URL query hints stay ignored either way — B asserts that in +// its own suite ("ignores URL workspace hints..."). + +const TEAM = 'ws-team-1'; +const PERSONAL = 'ws-personal-1'; + +const B_TEAM_CONTEXT = { + userId: 'auth-user-1', + appUserId: 'app-user-1', + workspaceId: TEAM, + workspaceName: 'Team', + workspaceType: 'team', + workspaceMemberId: 'wm-1', + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + billingState: 'active', + planId: 'team-pro', + providerMode: 'platform_credits', + seatSummary: { seatLimit: 5, usedSeats: 2, availableSeats: 3, isSeatFull: false }, +}; + +const B_PERSONAL_CONTEXT = { + ...B_TEAM_CONTEXT, + workspaceId: PERSONAL, + workspaceName: 'Personal', + workspaceType: 'personal', + workspaceMemberId: 'wm-p1', + role: 'owner', + planId: 'personal-pro', +}; + +const DIRECTORY = { + items: [ + { + workspaceId: TEAM, + workspaceName: 'Team', + workspaceType: 'team', + workspaceMemberId: 'wm-1', + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + }, + { + workspaceId: PERSONAL, + workspaceName: 'Personal', + workspaceType: 'personal', + workspaceMemberId: 'wm-p1', + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + }, + ], +}; + +const SESSION = { + profile: 'prod', + apiUrl: 'https://vela.example', + controlKey: 'ck-1', + user: null, + configMtimeMs: null, +}; + +function jsonResponse(status: number, body: unknown): Response { + return { ok: status >= 200 && status < 300, status, json: async () => body } as unknown as Response; +} + +const BODIES: Record = { + [TEAM]: B_TEAM_CONTEXT, + [PERSONAL]: B_PERSONAL_CONTEXT, +}; + +/** + * One vela account: one account-level selection row, shared by every client + * signed into it. `honoursWorkspaceHeader` is the only difference between B + * before and after the current-context scoping change. + */ +function createOneAccountVela(options: { + selection: string; + honoursWorkspaceHeader: boolean; +}) { + let selection = options.selection; + let directoryReads = 0; + let putCount = 0; + + const fetchImpl = (async (url: URL | string, init?: RequestInit) => { + const u = String(url); + const method = init?.method ?? 'GET'; + if (u.includes('/workspaces/current') && method === 'PUT') { + putCount += 1; + const body = JSON.parse(String(init?.body ?? '{}')) as { workspaceId?: string }; + if (body.workspaceId) selection = body.workspaceId; + return jsonResponse(200, BODIES[selection]); + } + if (u.includes('/workspaces/current') && method === 'GET') { + // A `?workspaceId=` hint is ignored by B in both eras; only the header + // can scope this read, and only after the scoping change. + const headers = (init?.headers ?? {}) as Record; + const requested = options.honoursWorkspaceHeader + ? headers['x-vela-workspace-id'] + : undefined; + return jsonResponse(200, BODIES[requested ?? selection]); + } + if (u.endsWith('/api/v1/workspaces') && method === 'GET') { + directoryReads += 1; + return jsonResponse(200, DIRECTORY); + } + throw new Error(`unexpected fetch ${method} ${u}`); + }) as unknown as typeof fetch; + + return { + fetchImpl, + selection: () => selection, + directoryReads: () => directoryReads, + putCount: () => putCount, + }; +} + +/** One OD daemon: its own local pin over the account's shared vela session. */ +function createClient(fetchImpl: typeof fetch, pinned: string) { + let pin: string | null = pinned; + const provider = createVelaWorkspaceContextProvider({ + fetch: fetchImpl, + readSession: () => SESSION, + getActiveWorkspaceId: () => pin, + setLocalSelection: (id) => { + pin = id; + }, + clearLocalSelection: () => { + pin = null; + }, + }); + return { + pin: () => pin, + context: () => provider.current({}), + exact: (workspaceId: string) => provider.resolveExact!({ workspaceId }), + /** + * What `PUT /api/workspace/active` does now: move the LOCAL pin. There is + * no backend selection call left to make. + */ + switchTo(workspaceId: string) { + pin = workspaceId; + }, + }; +} + +describe('one account, two clients, against a B that only knows an account-level workspace', () => { + it('holds each client on its own pin rather than following the account row', async () => { + const vela = createOneAccountVela({ selection: PERSONAL, honoursWorkspaceHeader: false }); + const clientA = createClient(vela.fetchImpl, TEAM); + + // The account row names the OTHER client's workspace. This daemon must not + // follow it. + const a = await clientA.context(); + expect(a?.workspaceId).toBe(TEAM); + expect(clientA.pin()).toBe(TEAM); + }); + + it('degrades the losing client to a directory synthesis, which reads as seats-full', async () => { + const winning = createOneAccountVela({ selection: TEAM, honoursWorkspaceHeader: false }); + const enriched = await createClient(winning.fetchImpl, TEAM).context(); + // While the account row happens to name THIS client's workspace, B can + // describe it fully. + expect(enriched?.planId).toBe('team-pro'); + expect(enriched?.seatSummary.isSeatFull).toBe(false); + + const losing = createOneAccountVela({ selection: PERSONAL, honoursWorkspaceHeader: false }); + const clientA = createClient(losing.fetchImpl, TEAM); + const degraded = await clientA.context(); + + // Same workspace, same membership, same seats in reality... + expect(degraded?.workspaceId).toBe(TEAM); + // ...but the billing plane is gone, because one row cannot describe two + // workspaces and OD has to synthesize this one from the directory. + expect(degraded?.planId).toBeNull(); + // Worse than blank: a 0/0 seat summary derives isSeatFull, so a workspace + // with three free seats reads as full to this client. The seat gate is a + // client-side check, so that is a user-visible block on inviting. + expect(degraded?.seatSummary).toEqual({ + seatLimit: 0, + usedSeats: 0, + availableSeats: 0, + isSeatFull: true, + }); + // And it costs a second round-trip the winning client never makes. + expect(losing.directoryReads()).toBeGreaterThan(winning.directoryReads()); + }); + + it('leaves the losing client with no context at all when that extra call blips', async () => { + const vela = createOneAccountVela({ selection: PERSONAL, honoursWorkspaceHeader: false }); + let failDirectory = true; + const fetchImpl = (async (url: URL | string, init?: RequestInit) => { + if (String(url).endsWith('/api/v1/workspaces') && failDirectory) { + throw new Error('directory blip'); + } + return (vela.fetchImpl as unknown as typeof fetch)(url as never, init as never); + }) as unknown as typeof fetch; + const clientA = createClient(fetchImpl, TEAM); + + // Pinned to TEAM while the account row says PERSONAL, this client can only + // resolve its own scope through the directory — which just failed. + expect(await clientA.context()).toBeNull(); + // The pin survives, because nothing was confirmed. + expect(clientA.pin()).toBe(TEAM); + + failDirectory = false; + expect((await clientA.context())?.workspaceId).toBe(TEAM); + }); +}); + +describe('one account, two clients, against a B that honours a per-request workspace', () => { + it('resolves A and B concurrently without reading or mutating the daemon pin', async () => { + const vela = createOneAccountVela({ selection: PERSONAL, honoursWorkspaceHeader: true }); + const client = createClient(vela.fetchImpl, 'unrelated-daemon-pin'); + + const [a, b] = await Promise.all([ + client.exact(TEAM), + client.exact(PERSONAL), + ]); + + expect(a?.workspaceId).toBe(TEAM); + expect(a?.planId).toBe('team-pro'); + expect(b?.workspaceId).toBe(PERSONAL); + expect(b?.planId).toBe('personal-pro'); + expect(client.pin()).toBe('unrelated-daemon-pin'); + expect(vela.putCount()).toBe(0); + }); + + it('rejects a mismatched upstream current answer and falls back only to the exact directory item', async () => { + const vela = createOneAccountVela({ selection: PERSONAL, honoursWorkspaceHeader: false }); + const client = createClient(vela.fetchImpl, 'unrelated-daemon-pin'); + + const resolved = await client.exact(TEAM); + + expect(resolved).toMatchObject({ + workspaceId: TEAM, + workspaceMemberId: 'wm-1', + role: 'member', + }); + // The Personal response must not leak into Team. Directory synthesis has no + // Personal plan enrichment and does not touch the unrelated daemon pin. + expect(resolved?.planId).toBeNull(); + expect(client.pin()).toBe('unrelated-daemon-pin'); + expect(vela.directoryReads()).toBe(1); + }); + + it('serves BOTH clients their own workspace, fully enriched', async () => { + const vela = createOneAccountVela({ selection: PERSONAL, honoursWorkspaceHeader: true }); + const clientA = createClient(vela.fetchImpl, TEAM); + const clientB = createClient(vela.fetchImpl, PERSONAL); + + const a = await clientA.context(); + const b = await clientB.context(); + + expect(a?.workspaceId).toBe(TEAM); + expect(b?.workspaceId).toBe(PERSONAL); + // Neither client is second-class: the billing plane survives for both, + // which is the whole point of scoping the read per request. + expect(a?.planId).toBe('team-pro'); + expect(a?.seatSummary).toEqual({ + seatLimit: 5, + usedSeats: 2, + availableSeats: 3, + isSeatFull: false, + }); + expect(b?.planId).toBe('personal-pro'); + // No directory synthesis was needed for either of them. + expect(vela.directoryReads()).toBe(0); + }); + + it('never writes the account-level selection, so no client can yank another', async () => { + const vela = createOneAccountVela({ selection: PERSONAL, honoursWorkspaceHeader: true }); + const clientA = createClient(vela.fetchImpl, TEAM); + const clientB = createClient(vela.fetchImpl, PERSONAL); + + clientA.switchTo(PERSONAL); + clientA.switchTo(TEAM); + await clientA.context(); + await clientB.context(); + + // The account row is never touched — not on a switch, not on a read. This + // is the regression guard: reintroducing a server-side selection write + // brings back the cross-client yank. + expect(vela.putCount()).toBe(0); + expect(vela.selection()).toBe(PERSONAL); + expect(clientB.pin()).toBe(PERSONAL); + }); + + it('still ignores a mismatched account row when the header path is available', async () => { + // Defence in depth: even if B answered for the wrong workspace, the pin + // stays authoritative. + const vela = createOneAccountVela({ selection: PERSONAL, honoursWorkspaceHeader: false }); + const clientA = createClient(vela.fetchImpl, TEAM); + expect((await clientA.context())?.workspaceId).toBe(TEAM); + }); +}); diff --git a/apps/daemon/tests/collab/workspace-project-home.test.ts b/apps/daemon/tests/collab/workspace-project-home.test.ts new file mode 100644 index 00000000000..802d4599952 Binary files /dev/null and b/apps/daemon/tests/collab/workspace-project-home.test.ts differ diff --git a/apps/daemon/tests/collab/workspace-projects-hub-wiring.test.ts b/apps/daemon/tests/collab/workspace-projects-hub-wiring.test.ts new file mode 100644 index 00000000000..e12f3200991 --- /dev/null +++ b/apps/daemon/tests/collab/workspace-projects-hub-wiring.test.ts @@ -0,0 +1,505 @@ +// Coverage for the two seams that trigger `reconcileWorkspaceProjectsWithRemote` +// in server.ts: the hub's real-time `team-projects-changed` SSE push +// (`startHubEventsSubscriber`) and the ~15s `workspaceInvalidationPoller`'s +// own diff-and-signal cadence. Mirrors the existing precedent in +// `hub-workspace-context-changed-poll.test.ts` (same extracted-named-function +// + source-scan-boundary-guard style) for the sibling +// `workspace-context-changed` fix. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it, vi } from 'vitest'; + +import { + handleHubTeamProjectsChanged, + handlePolledWorkspaceInvalidation, +} from '../../src/collab/workspace-projects-reconciler.js'; +import { parseHubWorkspaceEvent, startHubEventsSubscriber } from '../../src/collab/hub-events-subscriber.js'; + +function sseResponse(frames: string[]) { + const encoder = new TextEncoder(); + let started = false; + const stream = new ReadableStream({ + async pull(controller) { + if (!started) { + started = true; + for (const frame of frames) controller.enqueue(encoder.encode(frame)); + return; + } + // Never enqueue again — keeps the connection open so the subscriber + // does not immediately loop into a reconnect after the one event. + await new Promise(() => undefined); + }, + }); + return new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } }); +} + +describe('handleHubTeamProjectsChanged', () => { + it('emits the thin display-cache signal AND kicks a reconciliation pass', async () => { + const emit = vi.fn(); + const reconcile = vi.fn(async () => undefined); + handleHubTeamProjectsChanged(emit, reconcile); + expect(emit).toHaveBeenCalledTimes(1); + expect(reconcile).toHaveBeenCalledTimes(1); + }); + + it('never lets a reconciliation failure throw or reject out of the hub event handler', async () => { + const emit = vi.fn(); + const reconcile = vi.fn(() => Promise.reject(new Error('vela unreachable'))); + const unhandled = vi.fn(); + process.once('unhandledRejection', unhandled); + + expect(() => handleHubTeamProjectsChanged(emit, reconcile)).not.toThrow(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(reconcile).toHaveBeenCalledTimes(1); + expect(unhandled).not.toHaveBeenCalled(); + process.removeListener('unhandledRejection', unhandled); + }); + + // End-to-end through the REAL SSE parser/dispatcher (`startHubEventsSubscriber` + // + `parseHubWorkspaceEvent`), not a hand-called function — this is the + // "real push" half of the verification: a genuine `team-projects-changed` + // wire frame, parsed by real code, must reach the reconciler. + it('fires from a genuine team-projects-changed SSE frame parsed by the real hub subscriber', async () => { + const emit = vi.fn(); + const reconcile = vi.fn(async () => undefined); + let resolveDone!: () => void; + const done = new Promise((resolve) => { + resolveDone = resolve; + }); + + const readyFrame = 'event: ready\ndata: {"workspaceId":"w1"}\n\n'; + const frame = + 'event: workspace-event\ndata: {"type":"team-projects-changed","workspaceId":"w1","at":123}\n\n'; + const subscriber = startHubEventsSubscriber({ + resolveEndpoint: async () => ({ + url: 'https://hub/api/v1/collab/events', + headers: {}, + workspaceId: 'w1', + }), + onEvent: (event) => { + expect(parseHubWorkspaceEvent(JSON.stringify(event))).toEqual(event); + if (event.type === 'team-projects-changed') { + handleHubTeamProjectsChanged(emit, reconcile); + resolveDone(); + } + }, + fetchImpl: async () => sseResponse([readyFrame, frame]), + }); + + try { + await done; + expect(emit).toHaveBeenCalledTimes(1); + expect(reconcile).toHaveBeenCalledTimes(1); + } finally { + subscriber.stop(); + } + }); +}); + +describe('handlePolledWorkspaceInvalidation', () => { + it('forwards every payload to emit unchanged', () => { + const emit = vi.fn(); + const reconcile = vi.fn(async () => undefined); + const payload = { type: 'members-changed' as const, at: 1 }; + handlePolledWorkspaceInvalidation(payload, emit, reconcile); + expect(emit).toHaveBeenCalledWith(payload); + }); + + it('kicks reconciliation only for a team-projects-changed payload', () => { + const emit = vi.fn(); + const reconcile = vi.fn(async () => undefined); + + handlePolledWorkspaceInvalidation({ type: 'workspace-context-changed', at: 1 }, emit, reconcile); + handlePolledWorkspaceInvalidation({ type: 'members-changed', at: 1 }, emit, reconcile); + handlePolledWorkspaceInvalidation({ type: 'billing-changed', at: 1 }, emit, reconcile); + expect(reconcile).not.toHaveBeenCalled(); + + handlePolledWorkspaceInvalidation({ type: 'team-projects-changed', at: 1 }, emit, reconcile); + expect(reconcile).toHaveBeenCalledTimes(1); + }); + + it('never lets a reconciliation failure throw out of the poller emit path', async () => { + const emit = vi.fn(); + const reconcile = vi.fn(() => Promise.reject(new Error('vela unreachable'))); + const unhandled = vi.fn(); + process.once('unhandledRejection', unhandled); + + expect(() => + handlePolledWorkspaceInvalidation({ type: 'team-projects-changed', at: 1 }, emit, reconcile), + ).not.toThrow(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(unhandled).not.toHaveBeenCalled(); + process.removeListener('unhandledRejection', unhandled); + }); +}); + +// Scope-boundary guard (real source, not a re-implementation) — the sibling of +// `hub-workspace-context-changed-poll.test.ts`'s own switch-boundary test. +// Confirms the wiring actually landed in server.ts: exactly the +// `team-projects-changed` case calls `handleHubTeamProjectsChanged`, and the +// poller's `emit` wiring calls `handlePolledWorkspaceInvalidation`. +describe('server.ts wiring (source boundary)', () => { + const serverSourcePath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../src/server.ts', + ); + const source = fs.readFileSync(serverSourcePath, 'utf8'); + + function extractOnEventSwitchBody(): string { + const anchor = 'onEvent: (event) => {'; + const start = source.indexOf(anchor); + expect(start, 'expected to find the hub events onEvent handler in server.ts').toBeGreaterThan(-1); + const switchStart = source.indexOf('switch (event.type) {', start); + expect(switchStart, 'expected a switch(event.type) right after onEvent').toBeGreaterThan(-1); + let depth = 0; + let i = switchStart + 'switch (event.type) {'.length - 1; + for (; i < source.length; i += 1) { + if (source[i] === '{') depth += 1; + else if (source[i] === '}') { + depth -= 1; + if (depth === 0) break; + } + } + expect(depth, 'expected the switch braces to balance').toBe(0); + return source.slice(switchStart, i + 1); + } + + it('calls handleHubTeamProjectsChanged from exactly one case: team-projects-changed', () => { + const switchBody = extractOnEventSwitchBody(); + const cases = switchBody.split(/(?=case '[a-z-]+':)/g).filter((chunk) => chunk.startsWith("case '")); + expect(cases.length).toBeGreaterThanOrEqual(7); + + const casesCallingReconcile = cases.filter((chunk) => /handleHubTeamProjectsChanged\(/.test(chunk)); + const caseNames = casesCallingReconcile.map((chunk) => chunk.match(/^case '([a-z-]+)':/)?.[1]); + expect(caseNames).toEqual(['team-projects-changed']); + }); + + it('only starts hub missing-project recovery for a targeted project id', () => { + const switchBody = extractOnEventSwitchBody(); + const teamProjectsCase = switchBody + .split(/(?=case '[a-z-]+':)/g) + .find((chunk) => chunk.startsWith("case 'team-projects-changed':")); + + expect(teamProjectsCase).toContain( + 'if (event.workspaceId && event.projectId) {', + ); + expect(teamProjectsCase).toContain( + 'proactiveContentPull.materializeMissingProjects(\n' + + ' event.workspaceId,\n' + + ' event.projectId,\n' + + ' );', + ); + }); + + it('logs broad-head cooldown deferrals in catch-up completion diagnostics', () => { + expect(source).toContain( + '`suppressed=${event.suppressed ?? 0} complete=${event.complete === true}`', + ); + }); + + it('wires exact-scope recovery pollers through reconciliation and bounded full recovery', () => { + const anchor = 'createWorkspaceInvalidationPoller({'; + const start = source.indexOf(anchor); + expect(start, 'expected to find createWorkspaceInvalidationPoller(...) in server.ts').toBeGreaterThan(-1); + // Brace-balance from the opening `{` (not a naive `indexOf('});')`, which + // would stop at the first NESTED closing brace inside e.g. + // `getWorkspaceContext: async () => { ... }`). + let depth = 0; + let i = start + anchor.length - 1; // position of the opening brace + for (; i < source.length; i += 1) { + if (source[i] === '{') depth += 1; + else if (source[i] === '}') { + depth -= 1; + if (depth === 0) break; + } + } + expect(depth, 'expected createWorkspaceInvalidationPoller({...}) braces to balance').toBe(0); + const configBody = source.slice(start, i + 1); + expect(configBody).toContain( + 'activeTeamWorkspaceIdentity(context)?.workspaceId ?? workspaceId,', + ); + expect(configBody).toContain( + 'listTeamProjects: (context) => teamProjectsForDisplay(context),', + ); + expect(configBody).not.toContain('polledWorkspaceIdForReconcile'); + expect(configBody).toContain( + 'onTeamProjectsObserved: ({ workspaceId: observedWorkspaceId }) =>\n' + + ' proactiveContentPull.advanceRecoveryFloor(observedWorkspaceId),', + ); + expect(configBody).not.toContain('activeWorkspace'); + expect(configBody).toContain( + 'resolveAuthoritativeTeamWorkspaceContext(workspaceId)', + ); + expect(configBody).not.toContain('collab.workspaceContext.current({})'); + const emitStart = configBody.indexOf('emit: (payload, context) => {'); + const emitEnd = configBody.indexOf( + 'onTeamProjectsObserved:', + emitStart, + ); + expect(emitStart).toBeGreaterThan(-1); + expect(emitEnd).toBeGreaterThan(emitStart); + expect(configBody.slice(emitStart, emitEnd)).not.toContain( + 'proactiveContentPull.advanceRecoveryFloor', + ); + }); + + it('disposes proactive pull retry timers during daemon shutdown', () => { + const anchor = 'const cleanupDaemonBackgroundWork = () => {'; + const start = source.indexOf(anchor); + expect(start, 'expected daemon background cleanup in server.ts').toBeGreaterThan(-1); + const end = source.indexOf('};', start); + expect(end, 'expected daemon background cleanup to close').toBeGreaterThan(start); + const cleanupBody = source.slice(start, end + 2); + expect(cleanupBody).toContain('proactiveContentPull.dispose();'); + }); + + it('recovers promotion journals before registering collaboration pull routes', () => { + const recovery = source.indexOf( + 'await recoverAuthorizedTeamProjectPromotions({', + ); + const routes = source.indexOf( + 'const collabSyncRoutes = registerCollabSyncRoutes(app, {', + ); + expect(recovery).toBeGreaterThan(-1); + expect(routes).toBeGreaterThan(recovery); + expect(source.slice(recovery, routes)).toContain( + 'allowedProjectsRoot: PROJECTS_DIR', + ); + expect(source.slice(recovery, routes)).toContain( + 'getTeamProjectMaterialization(', + ); + }); + + it('wires the exact proactive invocation and transactional receipt materializer', () => { + expect(source).toContain( + 'materializeAuthorizedTeamMirror: (input, scope, receipt) =>\n' + + ' materializePulledTeamMirror(db, input, scope, receipt)', + ); + expect(source).toContain( + '}, target.authorizationWitness, expectedVersion, target.authorizedStageInvocation)', + ); + expect(source).not.toContain( + 'getActiveWorkspaceSnapshot: authorizedActiveWorkspaceSnapshot', + ); + expect(source).toContain( + 'authorizedTeamProjectPull: {\n' + + ' journalDir: teamMirrorPromotionJournalDir,\n' + + ' },', + ); + }); + + it('authorizes reconnect catch-up from the expected Workspace directory identity', () => { + const anchor = 'listSharedProjects: async (workspaceId) => {'; + const start = source.indexOf(anchor); + const end = source.indexOf('hasMaterializedProject:', start); + const body = source.slice(start, end); + expect(body).not.toContain('activeWorkspace.get()'); + expect(body).not.toContain('collab.workspaceContext.current({})'); + expect(body).not.toContain('lastKnown'); + expect(body).toContain( + 'resolveAuthoritativeTeamWorkspaceContext(workspaceId)', + ); + expect( + body.split('resolveAuthoritativeTeamWorkspaceContext(workspaceId)') + .length - 1, + ).toBe(2); + }); + + it('does not drop subscribed Workspace A hub data when Workspace B is ambient', () => { + const start = source.indexOf( + 'const startWorkspaceHubSubscriber = (subscribedWorkspaceId: string) =>', + ); + const end = source.indexOf( + 'workspaceHubSubscriptions = createWorkspaceHubSubscriptionManager({', + start, + ); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + const body = source.slice(start, end); + + expect(body).not.toContain( + 'subscribedWorkspaceId === activeWorkspace.get()?.trim()', + ); + expect(body).not.toContain('if (!isAmbientWorkspace'); + expect(body).toContain( + 'event.workspaceId ?? subscribedWorkspaceId', + ); + expect(body).toContain( + 'workspaceId: eventWorkspaceId', + ); + }); + + it('runs reconnect and source-gap recovery for the exact subscribed Workspace', () => { + const start = source.indexOf( + 'const startWorkspaceHubSubscriber = (subscribedWorkspaceId: string) =>', + ); + const end = source.indexOf( + 'workspaceHubSubscriptions = createWorkspaceHubSubscriptionManager({', + start, + ); + const body = source.slice(start, end); + const reconnectStart = body.indexOf('onReconnect: () => {'); + const sourceGapStart = body.indexOf('onSourceGap:', reconnectStart); + const errorStart = body.indexOf('onError:', sourceGapStart); + expect(reconnectStart).toBeGreaterThan(-1); + expect(sourceGapStart).toBeGreaterThan(reconnectStart); + expect(errorStart).toBeGreaterThan(sourceGapStart); + + const reconnectBody = body.slice(reconnectStart, sourceGapStart); + const sourceGapBody = body.slice(sourceGapStart, errorStart); + expect(reconnectBody).not.toContain('activeWorkspace.get()'); + expect(sourceGapBody).not.toContain('activeWorkspace.get()'); + expect(reconnectBody).toContain( + 'reconcileWorkspaceProjectsFromRemote(subscribedWorkspaceId)', + ); + expect(reconnectBody).toContain( + 'reconcileTeamResourcesFromRemote(undefined, subscribedWorkspaceId)', + ); + expect(sourceGapBody).toContain( + 'workspaceId ?? subscribedWorkspaceId', + ); + }); + + it('polls remembered, subscribed, and persisted Team resource Workspaces instead of only ambient', () => { + const idsStart = source.indexOf( + 'const teamResourceBackgroundWorkspaceIds = (): string[] => {', + ); + const timerStart = source.indexOf( + 'const teamResourcesPollTimer = setInterval(() => {', + idsStart, + ); + expect(idsStart).toBeGreaterThan(-1); + expect(timerStart).toBeGreaterThan(idsStart); + const idsBody = source.slice(idsStart, timerStart); + expect(idsBody).toContain('rememberedTeamResourceScopes.keys()'); + expect(idsBody).toContain( + 'workspaceHubSubscriptions?.activeWorkspaceIds()', + ); + expect(idsBody).toContain('listTeamWorkspaceProjectShares(db)'); + expect(idsBody).toContain( + 'listTeamWorkspaceResourceWorkspaceIds(db)', + ); + + const start = source.indexOf('const teamResourcesPollTimer = setInterval(() => {'); + const end = source.indexOf('teamResourcesPollTimer.unref?.();', start); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + const body = source.slice(start, end); + + expect(body).not.toContain('activeWorkspace.get()'); + expect(body).toContain('teamResourceBackgroundWorkspaceIds()'); + expect(body).toContain( + 'reconcileTeamResourcesFromRemote(undefined, workspaceId)', + ); + }); + + it('has no ambient active-workspace invalidation poller or hub subscription', () => { + expect(source).not.toContain( + 'const workspaceInvalidationPoller = createWorkspaceInvalidationPoller({', + ); + expect(source).not.toContain( + 'workspaceHubSubscriptions.setAmbientWorkspace(', + ); + expect(source).not.toContain('activeWorkspace.subscribe('); + expect(source).toContain( + 'const workspaceInvalidationPollerFor = (workspaceIdInput: string) => {', + ); + }); + + it('never lets display cache readers infer scope from workspaceContext current or lastKnown', () => { + const projectsStart = source.indexOf('const teamProjectsForDisplay = async ('); + const projectsEnd = source.indexOf('const teamProjectsForRequest = async (', projectsStart); + const projectsBody = source.slice(projectsStart, projectsEnd); + expect(projectsBody).not.toContain('workspaceContext.current'); + expect(projectsBody).not.toContain('lastKnown'); + + const membersStart = source.indexOf('const teamMembersForDisplay = async ('); + const membersEnd = source.indexOf('let workspaceHubSubscriptions:', membersStart); + const membersBody = source.slice(membersStart, membersEnd); + expect(membersBody).not.toContain('workspaceContext.current'); + expect(membersBody).not.toContain('lastKnown'); + }); + + it('serves repeated collab status owner reads from the explicit display cache while revocation stays fresh', () => { + const freshOwnerStart = source.indexOf( + 'const resolveSharedProjectOwner = async (', + ); + const statusOwnerStart = source.indexOf( + 'const resolveSharedProjectOwnerForStatus = async (', + freshOwnerStart, + ); + const presenceStart = source.indexOf( + 'const authoritativePresenceWorkspaces', + statusOwnerStart, + ); + expect(freshOwnerStart).toBeGreaterThan(-1); + expect(statusOwnerStart).toBeGreaterThan(freshOwnerStart); + expect(presenceStart).toBeGreaterThan(statusOwnerStart); + const freshOwnerBody = source.slice(freshOwnerStart, statusOwnerStart); + const statusOwnerBody = source.slice(statusOwnerStart, presenceStart); + + expect(statusOwnerBody).toContain( + 'await teamProjectsDisplayCache(explicitScope)', + ); + expect(statusOwnerBody).not.toContain( + 'await teamProjectsLister(explicitScope.workspaceId)', + ); + expect(freshOwnerBody).toContain( + 'await teamProjectsLister(explicitScope.workspaceId)', + ); + expect(freshOwnerBody).not.toContain('teamProjectsDisplayCache'); + + const pullStart = source.indexOf('const resolveSharedProject = async ('); + expect(pullStart).toBeGreaterThan(-1); + expect(pullStart).toBeLessThan(freshOwnerStart); + const pullBody = source.slice(pullStart, freshOwnerStart); + expect(pullBody).toContain( + 'await teamProjectsLister(scope.workspaceId)', + ); + expect(pullBody).not.toContain('teamProjectsDisplayCache'); + }); + + it('recognizes an authorized remote mirror from exact version and a real live directory, not a local project manifest', () => { + const helperStart = source.indexOf( + 'const proactiveTeamProjectMaterializedVersion = (', + ); + const configStart = source.indexOf( + 'const proactiveContentPull = createProactiveContentPull({', + helperStart, + ); + expect(helperStart).toBeGreaterThan(-1); + expect(configStart).toBeGreaterThan(helperStart); + const helperBody = source.slice(helperStart, configStart); + expect(helperBody).toContain('getTeamProjectMaterialization('); + expect(helperBody).toContain('target.workspaceId'); + expect(helperBody).toContain( + 'teamProjectContentResourceId(target.projectId, target)', + ); + expect(helperBody).toContain( + 'latestTeamProjectMaterializationVersion(', + ); + + const probeStart = source.indexOf( + 'hasMaterializedProject: async (projectId, target) => {', + configStart, + ); + const probeEnd = source.indexOf( + 'materializedVersion: proactiveTeamProjectMaterializedVersion,', + probeStart, + ); + expect(probeStart).toBeGreaterThan(configStart); + expect(probeEnd).toBeGreaterThan(probeStart); + const probeBody = source.slice(probeStart, probeEnd); + expect(probeBody).toContain('const project = getProject(db, projectId);'); + expect(probeBody).toContain( + 'proactiveTeamProjectMaterializedVersion(target)', + ); + expect(probeBody).toContain('fs.promises.lstat(projectDir)'); + expect(probeBody).toContain('entry.isDirectory()'); + expect(probeBody).toContain('!entry.isSymbolicLink()'); + expect(probeBody).not.toContain('readProjectManifest'); + }); +}); diff --git a/apps/daemon/tests/collab/workspace-projects-reconcile-http.test.ts b/apps/daemon/tests/collab/workspace-projects-reconcile-http.test.ts new file mode 100644 index 00000000000..ef668d76a51 --- /dev/null +++ b/apps/daemon/tests/collab/workspace-projects-reconcile-http.test.ts @@ -0,0 +1,760 @@ +// Real-HTTP-layer coverage for `reconcileWorkspaceProjectsWithRemote` +// (collab/workspace-projects-reconciler.ts), wired to the REAL sqlite +// `workspace_projects` CRUD (db.ts) and asserted through the REAL +// `GET /api/workspaces/:workspaceId/projects` endpoint +// (routes/project/index.ts) — not a shallow "the function was called" check. +// +// The concrete, repeatedly-reported scenario this closes: a member's local +// `workspace_projects` row keeps claiming `visibility: 'team'` forever after +// the owner unshares (or deletes) the project, because neither the hub-push +// nor the 15s poller ever re-examined the row — they only refreshed the +// DISPLAY cache. See that file's header comment for the full design. +import express from 'express'; +import type http from 'node:http'; +import { mkdtemp, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerProjectRoutes } from '../../src/routes/project/index.js'; +import { + closeDatabase, + ensureWorkspaceProject, + insertConversation, + getProject, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + insertProject, + listConversations, + listWorkspaceProjectBindings, + listWorkspaceProjects, + openDatabase, + rebindWorkspaceProject, + updateProject, + updateWorkspaceProject, +} from '../../src/db.js'; +import { + reconcileWorkspaceProjectsWithRemote, + type LocalTeamProjectBinding, + type RemoteTeamProjectRef, +} from '../../src/collab/workspace-projects-reconciler.js'; +import { materializePulledTeamMirror } from '../../src/collab/team-mirror-materializer.js'; +import { createAuthorizeProjectRequest } from '../../src/collab/project-request-authority.js'; +import { workspaceContextFromDirectoryItem } from '../../src/collab/vela-workspace-context.js'; + +const TEAM_WORKSPACE_ID = 'ws-team-1'; +const OWNER_MEMBER_ID = 'member-owner'; +const READER_MEMBER_ID = 'member-reader'; + +function readerTeamHeaders(extra: Record = {}) { + return { + 'content-type': 'application/json', + 'x-od-workspace-id': TEAM_WORKSPACE_ID, + 'x-od-workspace-member-id': READER_MEMBER_ID, + 'x-od-workspace-role': 'member', + 'x-od-workspace-type': 'team', + 'x-od-workspace-member-status': 'active', + 'x-od-workspace-lifecycle-state': 'active', + 'x-od-workspace-can-share-projects': 'true', + 'x-od-workspace-can-write-synced-files': 'true', + ...extra, + }; +} + +async function listen(app: express.Express): Promise<{ server: http.Server; url: string }> { + return new Promise((resolve) => { + const server = app.listen(0, () => { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + resolve({ server, url: `http://127.0.0.1:${port}` }); + }); + }); +} + +async function close(server: http.Server): Promise { + return new Promise((resolve) => server.close(() => resolve())); +} + +describe('reconcileWorkspaceProjectsWithRemote, verified through the real workspace projects HTTP endpoint', () => { + let tempDir: string; + let projectsRoot: string; + let db: ReturnType; + + beforeEach(async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'od-workspace-projects-reconcile-')); + projectsRoot = path.join(tempDir, 'projects'); + db = openDatabase(projectsRoot, { dataDir: tempDir }); + }); + + afterEach(async () => { + closeDatabase(); + await rm(tempDir, { recursive: true, force: true }); + }); + + // Real deps for `registerProjectRoutes`, wired to the SAME real sqlite db + // the reconciler under test writes into — every `projectStore` function is + // the genuine `db.ts` implementation, matching + // `tests/routes/project-move-to-personal.test.ts`'s established pattern. + function buildProjectRoutesDeps( + teamProjectCatalog: { list: () => Promise } | undefined, + overrides: { + ensureWorkspaceProject?: (db: unknown, input: unknown) => unknown; + appConfig?: Record; + validateLinkedDirs?: (dirs: string[]) => { dirs: string[]; error?: string }; + } = {}, + ) { + const noop = vi.fn(); + const sendApiError = ( + res: any, + status: number, + code: string, + message: string, + ) => res.status(status).json({ error: { code, message } }); + const verifiedContext = workspaceContextFromDirectoryItem({ + workspaceId: TEAM_WORKSPACE_ID, + workspaceName: 'Team', + workspaceType: 'team', + workspaceMemberId: READER_MEMBER_ID, + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + }); + const authorizeProjectRequest = createAuthorizeProjectRequest({ + db, + getWorkspaceProject: (_db, workspaceId, projectId) => + getWorkspaceProject(db, workspaceId, projectId), + getWorkspaceProjectByProjectId: (_db, projectId) => + getWorkspaceProjectByProjectId(db, projectId), + verifyWorkspaceReadAuthority: async () => ({ + ok: true, + context: verifiedContext, + }), + verifyWorkspaceRequestAuthority: async () => ({ + ok: true, + context: verifiedContext, + }), + sendApiError, + }); + return { + db, + design: {}, + http: { + createSseResponse: noop, + sendApiError, + }, + paths: { + DESIGN_SYSTEMS_DIR: '', + PROJECTS_DIR: projectsRoot, + RUNTIME_DATA_DIR: tempDir, + RUNTIME_DATA_DIR_CANONICAL: tempDir, + SKILLS_DIR: '', + BRANDS_DIR: path.join(tempDir, 'brands'), + USER_DESIGN_SYSTEMS_DIR: path.join(tempDir, 'user-design-systems'), + }, + projectStore: { + insertProject: (row: any) => insertProject(db, row), + validateLinkedDirs: + overrides.validateLinkedDirs ?? (() => ({ dirs: [] })), + getProject: (_db: unknown, id: string) => getProject(db, id), + updateProject: noop, + dbDeleteProject: noop, + removeProjectDir: noop, + stageProjectDirsForDelete: vi.fn(async () => ({ rollback: vi.fn(async () => {}), commit: vi.fn(async () => {}) })), + deleteWorkspaceProject: noop, + countWorkspaceProjectRefs: vi.fn(() => 1), + ensureWorkspaceProject: + overrides.ensureWorkspaceProject + ?? ((_db: unknown, input: any) => ensureWorkspaceProject(db, input)), + getWorkspaceProject: (_db: unknown, workspaceId: string, projectId: string) => + getWorkspaceProject(db, workspaceId, projectId), + getWorkspaceProjectByProjectId: (_db: unknown, projectId: string) => + getWorkspaceProjectByProjectId(db, projectId), + listWorkspaceProjectBindings: () => listWorkspaceProjectBindings(db), + listWorkspaceProjects: (_db: unknown, workspaceId: string) => listWorkspaceProjects(db, workspaceId), + updateWorkspaceProject: (_db: unknown, workspaceId: string, projectId: string, patch: any) => + updateWorkspaceProject(db, workspaceId, projectId, patch), + rebindWorkspaceProject: (_db: unknown, projectId: string, patch: any) => + rebindWorkspaceProject(db, projectId, patch), + }, + projectFiles: { + writeProjectFile: noop, + readProjectFile: noop, + ensureProject: noop, + listFiles: () => [], + listTabs: () => [], + setTabs: noop, + resolveProjectDir: () => '', + }, + conversations: { + insertConversation: (_db: unknown, input: any) => insertConversation(db, input), + }, + templates: { + getTemplate: noop, + listTemplates: () => [], + deleteTemplate: noop, + insertTemplate: noop, + findTemplateByNameAndProject: noop, + updateTemplate: noop, + }, + status: { + listLatestProjectRunStatuses: () => new Map(), + listProjectsAwaitingInput: () => new Set(), + normalizeProjectDisplayStatus: (status: string) => status, + composeProjectDisplayStatus: (status: unknown) => status, + listProjects: () => [], + }, + events: { subscribeFileEvents: noop, activeProjectEventSinks: new Map() }, + ids: { randomId: () => `id-${Math.random().toString(36).slice(2)}` }, + telemetry: { reportFinalizedMessage: noop }, + appConfig: { + readAppConfig: vi.fn(async () => overrides.appConfig ?? {}), + writeAppConfig: noop, + }, + agents: {}, + validation: { + validateProjectDesignSystemId: async () => ({ ok: true, id: null }), + validateProjectSkillId: async () => ({ ok: true, id: null }), + }, + collabSync: { requestTeamShare: noop, requestTeamUnshare: noop, invalidateTeamProjectCatalog: noop }, + teamProjectCatalog, + authorizeProjectRequest, + } as unknown as Parameters[1]; + } + + function seedProject(id: string, name: string) { + return insertProject(db, { + id, + name, + skillId: null, + designSystemId: null, + pendingPrompt: null, + metadata: null, + customInstructions: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + } + + function pulledProjectInput(id: string) { + return { + id, + name: 'Pulled team project', + skillId: null, + designSystemId: null, + createdAt: 10, + updatedAt: 20, + }; + } + + it('rolls back project and conversation rows when workspace binding fails', async () => { + const projectId = 'create-bind-failure'; + const app = express(); + app.use(express.json()); + registerProjectRoutes( + app, + buildProjectRoutesDeps( + { list: async () => [] }, + { + ensureWorkspaceProject: () => { + throw new Error('injected workspace bind failure'); + }, + }, + ), + ); + const routeServer = await listen(app); + try { + const response = await fetch(`${routeServer.url}/api/projects`, { + method: 'POST', + headers: readerTeamHeaders(), + body: JSON.stringify({ + id: projectId, + name: 'Must roll back', + skillId: null, + designSystemId: null, + }), + }); + + expect(response.status).toBe(400); + expect(getProject(db, projectId)).toBeNull(); + expect(listConversations(db, projectId)).toEqual([]); + expect(getWorkspaceProjectByProjectId(db, projectId)).toBeUndefined(); + } finally { + await close(routeServer.server); + } + }); + + it('removes an external project directory when workspace binding fails', async () => { + const projectId = 'external-create-bind-failure'; + const externalRoot = await mkdtemp( + path.join(tmpdir(), 'od-create-bind-external-'), + ); + const app = express(); + app.use(express.json()); + registerProjectRoutes( + app, + buildProjectRoutesDeps( + { list: async () => [] }, + { + appConfig: { + projectLocations: [ + { + id: 'external-test', + name: 'External test', + path: externalRoot, + }, + ], + }, + validateLinkedDirs: (dirs) => ({ dirs }), + ensureWorkspaceProject: () => { + throw new Error('injected workspace bind failure'); + }, + }, + ), + ); + const routeServer = await listen(app); + try { + const response = await fetch(`${routeServer.url}/api/projects`, { + method: 'POST', + headers: readerTeamHeaders(), + body: JSON.stringify({ + id: projectId, + name: 'Must clean external dir', + skillId: null, + designSystemId: null, + projectLocationId: 'external-test', + }), + }); + + expect(response.status).toBe(400); + expect(getProject(db, projectId)).toBeNull(); + expect(listConversations(db, projectId)).toEqual([]); + expect(await readdir(externalRoot)).toEqual([]); + } finally { + await close(routeServer.server); + await rm(externalRoot, { recursive: true, force: true }); + } + }); + + it('atomically materializes a read-only team mirror that rejects headerless PATCH and DELETE', async () => { + const projectId = 'fresh-pulled-mirror'; + materializePulledTeamMirror(db, pulledProjectInput(projectId), { + workspaceId: TEAM_WORKSPACE_ID, + resourceTeamId: TEAM_WORKSPACE_ID, + viewerMemberId: READER_MEMBER_ID, + ownerMemberId: OWNER_MEMBER_ID, + }); + + expect(getProject(db, projectId)).toMatchObject({ id: projectId, name: 'Pulled team project' }); + expect(getWorkspaceProject(db, TEAM_WORKSPACE_ID, projectId)).toMatchObject({ + workspaceId: TEAM_WORKSPACE_ID, + visibility: 'team', + resourceState: 'active', + createdByWorkspaceMemberId: null, + updatedByWorkspaceMemberId: READER_MEMBER_ID, + cloudTombstonedAt: null, + syncState: 'synced', + }); + + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, buildProjectRoutesDeps({ list: async () => [] })); + const routeServer = await listen(app); + try { + const patch = await fetch(`${routeServer.url}/api/projects/${projectId}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Illicit rename' }), + }); + expect(patch.status).toBe(400); + const deletion = await fetch(`${routeServer.url}/api/projects/${projectId}`, { + method: 'DELETE', + }); + expect(deletion.status).toBe(400); + expect(getProject(db, projectId)).toMatchObject({ name: 'Pulled team project' }); + } finally { + await close(routeServer.server); + } + }); + + it.each([ + { + label: 'personal binding', + patch: { workspaceId: TEAM_WORKSPACE_ID, visibility: 'personal', resourceState: 'active' }, + }, + { + label: 'other workspace', + patch: { workspaceId: 'ws-other', visibility: 'team', resourceState: 'active' }, + }, + { + label: 'tombstoned mirror', + patch: { + workspaceId: TEAM_WORKSPACE_ID, + visibility: 'team', + resourceState: 'active', + cloudTombstonedAt: 123, + }, + }, + { + label: 'deleted mirror', + patch: { workspaceId: TEAM_WORKSPACE_ID, visibility: 'team', resourceState: 'deleted' }, + }, + { + label: 'owner conflict', + patch: { + workspaceId: TEAM_WORKSPACE_ID, + visibility: 'team', + resourceState: 'active', + createdByWorkspaceMemberId: 'someone-else', + }, + }, + ])('fails closed without rewriting an existing $label', ({ patch }) => { + const projectId = `conflict-${String(patch.visibility)}-${String(patch.workspaceId)}-${String(patch.resourceState)}-${String(patch.cloudTombstonedAt ?? 'none')}-${String(patch.createdByWorkspaceMemberId ?? 'none')}`; + seedProject(projectId, 'Original local project'); + ensureWorkspaceProject(db, { + projectId, + updatedByWorkspaceMemberId: READER_MEMBER_ID, + resourceHubResourceId: null, + syncState: 'local_only', + cloudTombstonedAt: null, + createdByWorkspaceMemberId: null, + ...patch, + }); + const before = getWorkspaceProjectByProjectId(db, projectId); + + expect(() => materializePulledTeamMirror(db, pulledProjectInput(projectId), { + workspaceId: TEAM_WORKSPACE_ID, + resourceTeamId: TEAM_WORKSPACE_ID, + viewerMemberId: READER_MEMBER_ID, + ownerMemberId: OWNER_MEMBER_ID, + })).toThrow(/binding conflict/); + expect(getProject(db, projectId)).toMatchObject({ name: 'Original local project' }); + expect(getWorkspaceProjectByProjectId(db, projectId)).toEqual(before); + }); + + // Real db.ts wiring for the reconciler under test — the same functions + // `server.ts` itself calls, not a re-implementation. `getWorkspaceIdentity` + // is fixed to the reader's identity for these tests; the identity-gating + // behavior itself is covered by the fake-deps unit tests in + // `workspace-projects-reconciler.test.ts`. + function reconcileAsReader( + remoteProjects: RemoteTeamProjectRef[], + options: { onError?: (error: unknown) => void } = {}, + ) { + return reconcileWorkspaceProjectsWithRemote({ + getWorkspaceIdentity: async () => ({ workspaceId: TEAM_WORKSPACE_ID, workspaceMemberId: READER_MEMBER_ID }), + listRemoteTeamProjects: async () => remoteProjects, + hasLocalProject: (projectId) => getProject(db, projectId) != null, + listLocalTeamRows: (workspaceId): LocalTeamProjectBinding[] => + listWorkspaceProjects(db, workspaceId) + .filter((row: any) => row.workspaceVisibility === 'team') + .map((row: any) => ({ + projectId: row.id, + workspaceId: row.workspaceId, + visibility: row.workspaceVisibility, + resourceState: row.resourceState ?? null, + createdByWorkspaceMemberId: row.createdByWorkspaceMemberId ?? null, + resourceHubResourceId: row.resourceHubResourceId ?? null, + })), + getLocalBinding: (projectId): LocalTeamProjectBinding | null => { + const row = getWorkspaceProjectByProjectId(db, projectId) as any; + if (!row) return null; + return { + projectId, + workspaceId: row.workspaceId, + visibility: row.visibility, + resourceState: row.resourceState ?? null, + createdByWorkspaceMemberId: row.createdByWorkspaceMemberId ?? null, + resourceHubResourceId: row.resourceHubResourceId ?? null, + }; + }, + applyBind: (projectId, patch) => { + // Mirrors server.ts's real wiring: `rebindWorkspaceProject` only + // corrects an existing row (see db.ts), so a project this daemon has + // never locally bound needs `ensureWorkspaceProject` instead. + if (rebindWorkspaceProject(db, projectId, patch)) return; + ensureWorkspaceProject(db, { projectId, ...patch }); + }, + applyDemote: (workspaceId, projectId, patch) => updateWorkspaceProject(db, workspaceId, projectId, patch), + applyRevoke: (workspaceId, projectId, patch) => { + updateWorkspaceProject(db, workspaceId, projectId, patch); + const project = getProject(db, projectId); + updateProject(db, projectId, { + metadata: { + ...((project?.metadata as Record | null) ?? {}), + teamMirrorRevokedAt: Date.now(), + }, + }); + }, + ...(options.onError ? { onError: options.onError } : {}), + }); + } + + it('quarantines a member mirror once the owner unshares it and denies stale direct reads', async () => { + const projectId = 'shared-then-unshared'; + seedProject(projectId, 'Shared then unshared'); + // Simulates the state right after this member pulled a project the owner + // shared: a local `visibility: 'team'` row, read-only (not the creator). + ensureWorkspaceProject(db, { + projectId, + workspaceId: TEAM_WORKSPACE_ID, + visibility: 'team', + resourceState: 'active', + createdByWorkspaceMemberId: null, + updatedByWorkspaceMemberId: OWNER_MEMBER_ID, + resourceHubResourceId: `project-${projectId}`, + syncState: 'synced', + }); + expect(getWorkspaceProjectByProjectId(db, projectId)).toMatchObject({ visibility: 'team' }); + + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, buildProjectRoutesDeps({ list: async () => [] })); + const routeServer = await listen(app); + const detailUrl = `${routeServer.url}/api/projects/${projectId}`; + const filesUrl = `${routeServer.url}/api/projects/${projectId}/files`; + const rawUrl = `${routeServer.url}/api/projects/${projectId}/raw/index.html`; + expect((await fetch(detailUrl, { headers: readerTeamHeaders() })).status).toBe(200); + + // The owner has since unshared (or deleted) the project: the hub's team + // catalog no longer reports it at all. + const result = await reconcileAsReader([]); + expect(result).toEqual({ bound: 0, demoted: 0, revoked: 1 }); + + // The binding remains Team-scoped but is quarantined. Stale bytes remain + // on disk for safe re-share recovery and are no longer readable. + const row = getWorkspaceProjectByProjectId(db, projectId); + expect(row).toMatchObject({ + visibility: 'team', + resourceState: 'deleted', + createdByWorkspaceMemberId: null, + resourceHubResourceId: `project-${projectId}`, + syncState: 'synced', + }); + expect(getProject(db, projectId)?.metadata).toMatchObject({ + teamMirrorRevokedAt: expect.any(Number), + }); + expect((await fetch(detailUrl, { headers: readerTeamHeaders() })).status).toBe(403); + expect((await fetch(filesUrl, { headers: readerTeamHeaders() })).status).toBe(404); + expect((await fetch(rawUrl, { headers: readerTeamHeaders() })).status).toBe(404); + + try { + const teamResp = await fetch( + `${routeServer.url}/api/workspaces/${TEAM_WORKSPACE_ID}/projects?view=team`, + { headers: readerTeamHeaders() }, + ); + expect(teamResp.status).toBe(200); + const teamBody = (await teamResp.json()) as { projects: Array<{ id: string }> }; + expect(teamBody.projects.some((p) => p.id === projectId)).toBe(false); + + const allResp = await fetch( + `${routeServer.url}/api/workspaces/${TEAM_WORKSPACE_ID}/projects`, + { headers: readerTeamHeaders() }, + ); + const allBody = (await allResp.json()) as { projects: Array<{ id: string; visibility: string }> }; + const found = allBody.projects.find((p) => p.id === projectId); + expect(found, 'revoked mirror must not become a personal draft').toBeUndefined(); + } finally { + await close(routeServer.server); + } + }); + + it('reactivates a quarantined mirror only after an authoritative re-share is materialized', async () => { + const projectId = 'shared-unshared-reshared'; + materializePulledTeamMirror(db, pulledProjectInput(projectId), { + workspaceId: TEAM_WORKSPACE_ID, + resourceTeamId: TEAM_WORKSPACE_ID, + viewerMemberId: READER_MEMBER_ID, + ownerMemberId: OWNER_MEMBER_ID, + }); + + expect(await reconcileAsReader([])).toEqual({ + bound: 0, + demoted: 0, + revoked: 1, + }); + expect(getWorkspaceProjectByProjectId(db, projectId)).toMatchObject({ + visibility: 'team', + resourceState: 'deleted', + }); + + // The catalog row reappearing is only a discovery signal. It must not + // unlock stale local bytes before the fresh hub version is pulled. + expect(await reconcileAsReader([ + { projectId, ownerMemberId: OWNER_MEMBER_ID }, + ])).toEqual({ + bound: 0, + demoted: 0, + revoked: 0, + }); + expect(getWorkspaceProjectByProjectId(db, projectId)).toMatchObject({ + resourceState: 'deleted', + }); + + materializePulledTeamMirror( + db, + { + ...pulledProjectInput(projectId), + name: 'Re-shared project', + updatedAt: 30, + }, + { + workspaceId: TEAM_WORKSPACE_ID, + resourceTeamId: TEAM_WORKSPACE_ID, + viewerMemberId: READER_MEMBER_ID, + ownerMemberId: OWNER_MEMBER_ID, + }, + ); + expect(getWorkspaceProjectByProjectId(db, projectId)).toMatchObject({ + visibility: 'team', + resourceState: 'active', + createdByWorkspaceMemberId: null, + }); + expect(getProject(db, projectId)?.metadata ?? {}).not.toHaveProperty( + 'teamMirrorRevokedAt', + ); + + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, buildProjectRoutesDeps({ list: async () => [] })); + const routeServer = await listen(app); + try { + expect( + ( + await fetch(`${routeServer.url}/api/projects/${projectId}`, { + headers: readerTeamHeaders(), + }) + ).status, + ).toBe(200); + } finally { + await close(routeServer.server); + } + }); + + it('binds a brand-new remote share this daemon has never locally bound, visible immediately over real HTTP', async () => { + const projectId = 'freshly-shared'; + seedProject(projectId, 'Freshly shared'); + expect(getWorkspaceProjectByProjectId(db, projectId)).toBeUndefined(); + + const result = await reconcileAsReader([{ projectId, ownerMemberId: OWNER_MEMBER_ID }]); + expect(result).toEqual({ bound: 1, demoted: 0, revoked: 0 }); + + const row = getWorkspaceProjectByProjectId(db, projectId); + expect(row).toMatchObject({ workspaceId: TEAM_WORKSPACE_ID, visibility: 'team', createdByWorkspaceMemberId: null }); + + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, buildProjectRoutesDeps({ list: async () => [] })); + const routeServer = await listen(app); + try { + const resp = await fetch( + `${routeServer.url}/api/workspaces/${TEAM_WORKSPACE_ID}/projects?view=team`, + { headers: readerTeamHeaders() }, + ); + expect(resp.status).toBe(200); + const body = (await resp.json()) as { projects: Array<{ id: string; visibility: string }> }; + const found = body.projects.find((p) => p.id === projectId); + expect(found, 'expected the newly-bound project to show under the team view immediately').toBeTruthy(); + expect(found?.visibility).toBe('team'); + } finally { + await close(routeServer.server); + } + }); + + // The production P1 this encodes (recvqmnuxxKHaI): a member daemon whose + // team catalog lists projects the member has NEVER opened or pulled. Those + // projects have no local `projects` row, and `workspace_projects.project_id` + // is a FOREIGN KEY into `projects(id)` (db.ts), so the bind fallback's + // INSERT threw SQLITE_CONSTRAINT_FOREIGNKEY on every single reconciliation + // pass (hub push + ~15s poller), forever — 4700+ log lines across restarts + // on the live member instance. Materializing a project is the open/pull + // path's job (`ensureSharedProjectPlaceholder` / `registerPulledProject` in + // routes/collab-sync.ts); the reconciler must SKIP what has never been + // materialized here, exactly like its request-scoped sibling + // `reconcileLocalRowWithRemoteTeamAccess` (rebind-only, never inserts). + it('skips a remote team project this daemon never materialized locally instead of failing the FK, without disturbing the rest of the pass', async () => { + // A materialized project the same pass must still bind… + const materializedId = 'materialized-but-unbound'; + seedProject(materializedId, 'Materialized but unbound'); + // …and a stale foreign team mirror the same pass must still revoke. + const unsharedId = 'unshared-during-outage'; + seedProject(unsharedId, 'Unshared during outage'); + ensureWorkspaceProject(db, { + projectId: unsharedId, + workspaceId: TEAM_WORKSPACE_ID, + visibility: 'team', + resourceState: 'active', + createdByWorkspaceMemberId: null, + updatedByWorkspaceMemberId: OWNER_MEMBER_ID, + resourceHubResourceId: `project-${unsharedId}`, + syncState: 'synced', + }); + // The culprit: on the remote catalog, never seen locally — no `projects` + // row, no `workspace_projects` row. + const neverMaterializedId = 'never-materialized-remote-share'; + expect(getProject(db, neverMaterializedId)).toBeFalsy(); + + const onError = vi.fn(); + const result = await reconcileAsReader( + [ + { projectId: neverMaterializedId, ownerMemberId: OWNER_MEMBER_ID }, + { projectId: materializedId, ownerMemberId: OWNER_MEMBER_ID }, + ], + { onError }, + ); + + // No FOREIGN KEY error — the unmaterialized project is not an error, it + // is simply not this daemon's row to write yet. + expect(onError).not.toHaveBeenCalled(); + expect(getWorkspaceProjectByProjectId(db, neverMaterializedId)).toBeUndefined(); + // The skip is not counted as work done, and the rest of the pass ran. + expect(result).toEqual({ bound: 1, demoted: 0, revoked: 1 }); + expect(getWorkspaceProjectByProjectId(db, materializedId)).toMatchObject({ + workspaceId: TEAM_WORKSPACE_ID, + visibility: 'team', + }); + expect(getWorkspaceProjectByProjectId(db, unsharedId)).toMatchObject({ + visibility: 'team', + resourceState: 'deleted', + }); + }); + + it('does not demote on a failed remote read, leaving the row (and the HTTP-visible list) untouched', async () => { + const projectId = 'still-shared'; + seedProject(projectId, 'Still shared'); + ensureWorkspaceProject(db, { + projectId, + workspaceId: TEAM_WORKSPACE_ID, + visibility: 'team', + resourceState: 'active', + createdByWorkspaceMemberId: null, + updatedByWorkspaceMemberId: OWNER_MEMBER_ID, + resourceHubResourceId: `project-${projectId}`, + syncState: 'synced', + }); + + const result = await reconcileWorkspaceProjectsWithRemote({ + getWorkspaceIdentity: async () => ({ workspaceId: TEAM_WORKSPACE_ID, workspaceMemberId: READER_MEMBER_ID }), + listRemoteTeamProjects: async () => { + throw new Error('vela unreachable'); + }, + hasLocalProject: (id) => getProject(db, id) != null, + listLocalTeamRows: (workspaceId): LocalTeamProjectBinding[] => + listWorkspaceProjects(db, workspaceId) + .filter((row: any) => row.workspaceVisibility === 'team') + .map((row: any) => ({ + projectId: row.id, + workspaceId: row.workspaceId, + visibility: row.workspaceVisibility, + resourceState: row.resourceState ?? null, + createdByWorkspaceMemberId: row.createdByWorkspaceMemberId ?? null, + resourceHubResourceId: row.resourceHubResourceId ?? null, + })), + getLocalBinding: () => null, + applyBind: (projectId, patch) => rebindWorkspaceProject(db, projectId, patch), + applyDemote: (workspaceId, projectId, patch) => updateWorkspaceProject(db, workspaceId, projectId, patch), + applyRevoke: (workspaceId, projectId, patch) => updateWorkspaceProject(db, workspaceId, projectId, patch), + }); + expect(result).toEqual({ bound: 0, demoted: 0, revoked: 0 }); + expect(getWorkspaceProjectByProjectId(db, projectId)).toMatchObject({ visibility: 'team' }); + }); +}); diff --git a/apps/daemon/tests/collab/workspace-projects-reconcile-membership.test.ts b/apps/daemon/tests/collab/workspace-projects-reconcile-membership.test.ts new file mode 100644 index 00000000000..45a26f66c31 --- /dev/null +++ b/apps/daemon/tests/collab/workspace-projects-reconcile-membership.test.ts @@ -0,0 +1,472 @@ +// Red-spec coverage for recvqzjnshIlOe: a teammate's team project whose hub +// catalog row is `syncState: 'failed'` must NOT be demoted into a personal +// draft attributed to the local viewer. +// +// The failure chain this encodes: the display catalog read (`toTeamProject` +// in collab/vela-cli-team-projects.ts) deliberately drops every non-`synced` +// row so teammates never open empty project shells. The realtime reconciler +// (collab/workspace-projects-reconciler.ts) was fed exactly that +// display-filtered list as its "remote membership" — so a mere publish +// failure on the owner's side became indistinguishable from a real unshare, +// and the demote direction rewrote the viewer's read-only mirror into +// `visibility: 'personal'` + `createdByWorkspaceMemberId: ` + +// `syncState: 'local_only'`. That row then passed the drafts view's +// "personal AND created by me" filter, rendered as "created by me", offered +// "move to team space", and the move could only ever fail with the hub's +// `team_project_owner_conflict` (the failed row still occupies the +// ownership slot; see vela services/api/src/team-projects/postgres.ts). +// +// The remote payloads below run through the REAL vela CLI wire parsers — +// `createVelaCliTeamProjectCatalog` (display, drops non-synced) and +// `createVelaCliTeamProjectCatalogClient` (raw membership, keeps them) — +// composed by the REAL `reconcilerRemoteTeamProjects` source selector, so +// this suite fails against the display-filtered wiring and passes against +// the membership wiring without mimicking either parser. +import express from 'express'; +import type http from 'node:http'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerProjectRoutes } from '../../src/routes/project/index.js'; +import { + closeDatabase, + ensureWorkspaceProject, + getProject, + getWorkspaceProjectByProjectId, + insertProject, + listWorkspaceProjectBindings, + listWorkspaceProjects, + getWorkspaceProject, + openDatabase, + rebindWorkspaceProject, + updateProject, + updateWorkspaceProject, +} from '../../src/db.js'; +import { + reconcileWorkspaceProjectsWithRemote, + reconcilerRemoteTeamProjects, + type LocalTeamProjectBinding, + type RemoteTeamProjectRef, +} from '../../src/collab/workspace-projects-reconciler.js'; +import { + createVelaCliTeamProjectCatalog, + createVelaCliTeamProjectCatalogClient, +} from '../../src/collab/vela-cli-team-projects.js'; + +const TEAM_WORKSPACE_ID = 'ws-team-1'; +const OWNER_MEMBER_ID = 'member-owner'; +const READER_MEMBER_ID = 'member-reader'; + +interface HubCatalogRow { + projectId: string; + ownerMemberId: string; + syncState: 'pending_upload' | 'syncing' | 'synced' | 'failed'; +} + +/** One raw hub catalog row, shaped like `vela team-projects list` output — + * satisfying BOTH real wire parsers (`toTeamProject` needs + * projectId/ownerMemberId/createdAt, `toVelaTeamProjectRecord` additionally + * needs id/workspaceId/resourceId/syncState/updatedAt). */ +function hubWireRow(row: HubCatalogRow) { + return { + id: `row-${row.projectId}`, + workspaceId: TEAM_WORKSPACE_ID, + projectId: row.projectId, + resourceId: `project-${row.projectId}`, + ownerMemberId: row.ownerMemberId, + displayName: row.projectId, + syncState: row.syncState, + createdAt: '2026-07-24T09:02:40.747Z', + updatedAt: '2026-07-25T13:15:20.689Z', + }; +} + +function readerTeamHeaders(extra: Record = {}) { + return { + 'content-type': 'application/json', + 'x-od-workspace-id': TEAM_WORKSPACE_ID, + 'x-od-workspace-member-id': READER_MEMBER_ID, + 'x-od-workspace-role': 'member', + 'x-od-workspace-type': 'team', + 'x-od-workspace-member-status': 'active', + 'x-od-workspace-lifecycle-state': 'active', + 'x-od-workspace-can-share-projects': 'true', + 'x-od-workspace-can-write-synced-files': 'true', + ...extra, + }; +} + +async function listen(app: express.Express): Promise<{ server: http.Server; url: string }> { + return new Promise((resolve) => { + const server = app.listen(0, () => { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + resolve({ server, url: `http://127.0.0.1:${port}` }); + }); + }); +} + +async function close(server: http.Server): Promise { + return new Promise((resolve) => server.close(() => resolve())); +} + +describe('reconciler remote membership vs the display-filtered catalog (recvqzjnshIlOe)', () => { + let tempDir: string; + let projectsRoot: string; + let db: ReturnType; + + beforeEach(async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'od-reconcile-membership-')); + projectsRoot = path.join(tempDir, 'projects'); + db = openDatabase(projectsRoot, { dataDir: tempDir }); + }); + + afterEach(async () => { + closeDatabase(); + await rm(tempDir, { recursive: true, force: true }); + }); + + function seedProject(id: string, name: string) { + insertProject(db, { + id, + name, + skillId: null, + designSystemId: null, + pendingPrompt: null, + metadata: null, + customInstructions: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + } + + /** The healthy shape `materializePulledTeamMirror` writes for a teammate's + * shared project this viewer pulled. */ + function seedForeignTeamMirror(projectId: string) { + ensureWorkspaceProject(db, { + projectId, + workspaceId: TEAM_WORKSPACE_ID, + visibility: 'team', + resourceState: 'active', + createdByWorkspaceMemberId: null, + updatedByWorkspaceMemberId: READER_MEMBER_ID, + resourceHubResourceId: `project-${projectId}`, + cloudTombstonedAt: null, + syncState: 'synced', + }); + } + + /** Compose the two REAL catalog reads exactly the way server.ts wires the + * reconciler's `listRemoteTeamProjects` dep. */ + function remoteMembershipReader(hubRows: HubCatalogRow[]) { + const run = vi.fn(async (args: string[]) => { + if (args[0] !== 'list') throw new Error(`unexpected vela args: ${args.join(' ')}`); + return JSON.stringify({ + workspaceId: TEAM_WORKSPACE_ID, + projects: hubRows.map(hubWireRow), + }); + }); + const displayCatalog = createVelaCliTeamProjectCatalog({ + run, + supportsTeamProjects: () => true, + }); + const membershipCatalog = createVelaCliTeamProjectCatalogClient({ + run, + supportsTeamProjects: () => true, + }); + return () => + reconcilerRemoteTeamProjects({ + listCatalogMembership: async () => + (await membershipCatalog.list({ + memberId: READER_MEMBER_ID, + teamId: TEAM_WORKSPACE_ID, + role: 'member', + lifecycleState: 'active', + })).map((record) => ({ + projectId: record.projectId, + ownerMemberId: record.ownerMemberId, + })), + listDisplayTeamProjects: async () => + (await displayCatalog.list(TEAM_WORKSPACE_ID)).map((project) => ({ + projectId: project.projectId, + ownerMemberId: project.ownerMemberId, + })), + }); + } + + /** Real db.ts wiring for the reconciler — the same functions server.ts + * itself calls (mirrors workspace-projects-reconcile-http.test.ts). */ + function reconcileAsReader(listRemoteTeamProjects: () => Promise) { + return reconcileWorkspaceProjectsWithRemote({ + getWorkspaceIdentity: async () => ({ + workspaceId: TEAM_WORKSPACE_ID, + workspaceMemberId: READER_MEMBER_ID, + }), + listRemoteTeamProjects, + hasLocalProject: (projectId) => getProject(db, projectId) != null, + listLocalTeamRows: (workspaceId): LocalTeamProjectBinding[] => + listWorkspaceProjects(db, workspaceId) + .filter((row: any) => row.workspaceVisibility === 'team') + .map((row: any) => ({ + projectId: row.id, + workspaceId: row.workspaceId, + visibility: row.workspaceVisibility, + resourceState: row.resourceState ?? null, + createdByWorkspaceMemberId: row.createdByWorkspaceMemberId ?? null, + resourceHubResourceId: row.resourceHubResourceId ?? null, + })), + getLocalBinding: (projectId): LocalTeamProjectBinding | null => { + const row = getWorkspaceProjectByProjectId(db, projectId) as any; + if (!row) return null; + return { + projectId, + workspaceId: row.workspaceId, + visibility: row.visibility, + resourceState: row.resourceState ?? null, + createdByWorkspaceMemberId: row.createdByWorkspaceMemberId ?? null, + resourceHubResourceId: row.resourceHubResourceId ?? null, + }; + }, + applyBind: (projectId, patch) => { + if (rebindWorkspaceProject(db, projectId, patch)) return; + ensureWorkspaceProject(db, { projectId, ...patch }); + }, + applyDemote: (workspaceId, projectId, patch) => + updateWorkspaceProject(db, workspaceId, projectId, patch), + applyRevoke: (workspaceId, projectId, patch) => { + updateWorkspaceProject(db, workspaceId, projectId, patch); + const project = getProject(db, projectId); + updateProject(db, projectId, { + metadata: { + ...((project?.metadata as Record | null) ?? {}), + teamMirrorRevokedAt: Date.now(), + }, + }); + }, + }); + } + + function buildProjectRoutesDeps() { + const noop = vi.fn(); + return { + db, + design: {}, + http: { + createSseResponse: noop, + sendApiError: (res: any, status: number, code: string, message: string) => + res.status(status).json({ error: { code, message } }), + }, + paths: { + DESIGN_SYSTEMS_DIR: '', + PROJECTS_DIR: projectsRoot, + RUNTIME_DATA_DIR: tempDir, + RUNTIME_DATA_DIR_CANONICAL: tempDir, + SKILLS_DIR: '', + BRANDS_DIR: path.join(tempDir, 'brands'), + USER_DESIGN_SYSTEMS_DIR: path.join(tempDir, 'user-design-systems'), + }, + projectStore: { + insertProject: (row: any) => insertProject(db, row), + validateLinkedDirs: () => ({ dirs: [] }), + getProject: (_db: unknown, id: string) => getProject(db, id), + updateProject: noop, + dbDeleteProject: noop, + removeProjectDir: noop, + stageProjectDirsForDelete: vi.fn(async () => ({ + rollback: vi.fn(async () => {}), + commit: vi.fn(async () => {}), + })), + deleteWorkspaceProject: noop, + countWorkspaceProjectRefs: vi.fn(() => 1), + ensureWorkspaceProject: (_db: unknown, input: any) => ensureWorkspaceProject(db, input), + getWorkspaceProject: (_db: unknown, workspaceId: string, projectId: string) => + getWorkspaceProject(db, workspaceId, projectId), + getWorkspaceProjectByProjectId: (_db: unknown, projectId: string) => + getWorkspaceProjectByProjectId(db, projectId), + listWorkspaceProjectBindings: () => listWorkspaceProjectBindings(db), + listWorkspaceProjects: (_db: unknown, workspaceId: string) => listWorkspaceProjects(db, workspaceId), + updateWorkspaceProject: (_db: unknown, workspaceId: string, projectId: string, patch: any) => + updateWorkspaceProject(db, workspaceId, projectId, patch), + rebindWorkspaceProject: (_db: unknown, projectId: string, patch: any) => + rebindWorkspaceProject(db, projectId, patch), + }, + projectFiles: { + writeProjectFile: noop, + readProjectFile: noop, + ensureProject: noop, + listFiles: () => [], + listTabs: () => [], + setTabs: noop, + resolveProjectDir: () => '', + }, + conversations: { insertConversation: noop }, + templates: { + getTemplate: noop, + listTemplates: () => [], + deleteTemplate: noop, + insertTemplate: noop, + findTemplateByNameAndProject: noop, + updateTemplate: noop, + }, + status: { + listLatestProjectRunStatuses: () => new Map(), + listProjectsAwaitingInput: () => new Set(), + normalizeProjectDisplayStatus: (status: string) => status, + composeProjectDisplayStatus: (status: unknown) => status, + listProjects: () => [], + }, + events: { subscribeFileEvents: noop, activeProjectEventSinks: new Map() }, + ids: { randomId: () => `id-${Math.random().toString(36).slice(2)}` }, + telemetry: { reportFinalizedMessage: noop }, + appConfig: { readAppConfig: vi.fn(async () => ({})), writeAppConfig: noop }, + agents: {}, + validation: { + validateProjectDesignSystemId: async () => ({ ok: true, id: null }), + validateProjectSkillId: async () => ({ ok: true, id: null }), + }, + collabSync: { requestTeamShare: noop, requestTeamUnshare: noop, invalidateTeamProjectCatalog: noop }, + teamProjectCatalog: undefined, + } as unknown as Parameters[1]; + } + + async function draftsProjectIds(): Promise { + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, buildProjectRoutesDeps()); + const routeServer = await listen(app); + try { + const resp = await fetch( + `${routeServer.url}/api/workspaces/${TEAM_WORKSPACE_ID}/projects?view=drafts`, + { headers: readerTeamHeaders() }, + ); + expect(resp.status).toBe(200); + const body = (await resp.json()) as { projects: Array<{ id: string }> }; + return body.projects.map((p) => p.id); + } finally { + await close(routeServer.server); + } + } + + it("keeps a teammate's sync-failed catalog row bound as a team mirror instead of demoting it into the viewer's drafts", async () => { + const projectId = 'wsclone-visual-verify'; + seedProject(projectId, 'Website Clone Visual Verify'); + seedForeignTeamMirror(projectId); + + // The hub still registers the row to its owner — only its latest publish + // failed. The display read drops it; membership must not. + const result = await reconcileAsReader( + remoteMembershipReader([ + { projectId, ownerMemberId: OWNER_MEMBER_ID, syncState: 'failed' }, + ]), + ); + + expect(result).toMatchObject({ demoted: 0, revoked: 0 }); + const row = getWorkspaceProjectByProjectId(db, projectId); + expect(row).toMatchObject({ + visibility: 'team', + createdByWorkspaceMemberId: null, + }); + // The observable symptom: it must not surface in the viewer's drafts. + expect(await draftsProjectIds()).not.toContain(projectId); + }); + + it('heals an already-leaked self-attributed draft back into a team mirror once membership confirms the foreign owner', async () => { + const projectId = 'wsclone-visual-verify'; + seedProject(projectId, 'Website Clone Visual Verify'); + // The exact corrupted row from the live incident (owner client, + // od-owner-data app.sqlite): the earlier display-filtered pass demoted + // the mirror into the viewer's own personal draft. + ensureWorkspaceProject(db, { + projectId, + workspaceId: TEAM_WORKSPACE_ID, + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: READER_MEMBER_ID, + updatedByWorkspaceMemberId: OWNER_MEMBER_ID, + resourceHubResourceId: null, + cloudTombstonedAt: null, + syncState: 'local_only', + }); + expect(await draftsProjectIds()).toContain(projectId); + + const result = await reconcileAsReader( + remoteMembershipReader([ + { projectId, ownerMemberId: OWNER_MEMBER_ID, syncState: 'failed' }, + ]), + ); + + expect(result.bound).toBe(1); + const row = getWorkspaceProjectByProjectId(db, projectId); + expect(row).toMatchObject({ + visibility: 'team', + createdByWorkspaceMemberId: null, + }); + expect(await draftsProjectIds()).not.toContain(projectId); + }); + + it('quarantines a mirror whose hub row is genuinely gone without turning it into the viewer personal draft', async () => { + const projectId = 'genuinely-unshared'; + seedProject(projectId, 'Genuinely unshared'); + seedForeignTeamMirror(projectId); + + const result = await reconcileAsReader(remoteMembershipReader([])); + + expect(result).toMatchObject({ demoted: 0, revoked: 1 }); + expect(getWorkspaceProjectByProjectId(db, projectId)).toMatchObject({ + visibility: 'team', + resourceState: 'deleted', + createdByWorkspaceMemberId: null, + syncState: 'synced', + }); + expect(getProject(db, projectId)?.metadata).toMatchObject({ + teamMirrorRevokedAt: expect.any(Number), + }); + expect(await draftsProjectIds()).not.toContain(projectId); + }); + + it('does not revoke on a partially parseable catalog response', async () => { + const projectId = 'must-survive-partial-catalog'; + seedProject(projectId, 'Must survive partial catalog'); + seedForeignTeamMirror(projectId); + const client = createVelaCliTeamProjectCatalogClient({ + supportsTeamProjects: () => true, + run: async () => JSON.stringify({ + projects: [ + hubWireRow({ + projectId: 'some-other-project', + ownerMemberId: OWNER_MEMBER_ID, + syncState: 'synced', + }), + { + id: 'malformed-row', + workspaceId: TEAM_WORKSPACE_ID, + projectId, + }, + ], + }), + }); + + const result = await reconcileAsReader(async () => + (await client.list({ + memberId: READER_MEMBER_ID, + teamId: TEAM_WORKSPACE_ID, + role: 'member', + lifecycleState: 'active', + })).map((record) => ({ + projectId: record.projectId, + ownerMemberId: record.ownerMemberId, + })), + ); + + expect(result).toEqual({ bound: 0, demoted: 0, revoked: 0 }); + expect(getWorkspaceProjectByProjectId(db, projectId)).toMatchObject({ + visibility: 'team', + resourceState: 'active', + }); + expect(getProject(db, projectId)?.metadata ?? {}).not.toHaveProperty( + 'teamMirrorRevokedAt', + ); + }); +}); diff --git a/apps/daemon/tests/collab/workspace-projects-reconciler.test.ts b/apps/daemon/tests/collab/workspace-projects-reconciler.test.ts new file mode 100644 index 00000000000..753913b9f4f --- /dev/null +++ b/apps/daemon/tests/collab/workspace-projects-reconciler.test.ts @@ -0,0 +1,407 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + planWorkspaceProjectReconciliation, + reconcileWorkspaceProjectsWithRemote, + type LocalTeamProjectBinding, +} from '../../src/collab/workspace-projects-reconciler.js'; + +const WORKSPACE_ID = 'team-1'; +const OWNER_MEMBER_ID = 'member-owner'; +const READER_MEMBER_ID = 'member-reader'; + +describe('planWorkspaceProjectReconciliation (pure)', () => { + it('binds a remote project this daemon has never locally bound, as a reader when someone else owns it', () => { + const actions = planWorkspaceProjectReconciliation({ + workspaceId: WORKSPACE_ID, + workspaceMemberId: READER_MEMBER_ID, + remoteProjects: [{ projectId: 'p1', ownerMemberId: OWNER_MEMBER_ID }], + localBindings: new Map(), + }); + expect(actions).toEqual([ + { + kind: 'bind', + projectId: 'p1', + patch: { + workspaceId: WORKSPACE_ID, + visibility: 'team', + resourceState: 'active', + createdByWorkspaceMemberId: null, + updatedByWorkspaceMemberId: READER_MEMBER_ID, + resourceHubResourceId: null, + cloudTombstonedAt: null, + syncState: 'synced', + }, + }, + ]); + }); + + it('binds a remote project as editable when the current member is its owner', () => { + const actions = planWorkspaceProjectReconciliation({ + workspaceId: WORKSPACE_ID, + workspaceMemberId: OWNER_MEMBER_ID, + remoteProjects: [{ projectId: 'p1', ownerMemberId: OWNER_MEMBER_ID }], + localBindings: new Map(), + }); + expect(actions).toEqual([ + expect.objectContaining({ + kind: 'bind', + projectId: 'p1', + patch: expect.objectContaining({ createdByWorkspaceMemberId: OWNER_MEMBER_ID }), + }), + ]); + }); + + it('preserves an already-known resourceHubResourceId when correcting a row', () => { + const local: LocalTeamProjectBinding = { + projectId: 'p1', + workspaceId: WORKSPACE_ID, + visibility: 'personal', // stale: remote says team + createdByWorkspaceMemberId: null, + resourceHubResourceId: 'resource-abc', + }; + const actions = planWorkspaceProjectReconciliation({ + workspaceId: WORKSPACE_ID, + workspaceMemberId: OWNER_MEMBER_ID, + remoteProjects: [{ projectId: 'p1', ownerMemberId: OWNER_MEMBER_ID }], + localBindings: new Map([['p1', local]]), + }); + expect(actions).toEqual([ + expect.objectContaining({ + kind: 'bind', + patch: expect.objectContaining({ resourceHubResourceId: 'resource-abc' }), + }), + ]); + }); + + it('is a no-op when the local row already matches remote exactly', () => { + const local: LocalTeamProjectBinding = { + projectId: 'p1', + workspaceId: WORKSPACE_ID, + visibility: 'team', + createdByWorkspaceMemberId: OWNER_MEMBER_ID, + resourceHubResourceId: 'resource-abc', + }; + const actions = planWorkspaceProjectReconciliation({ + workspaceId: WORKSPACE_ID, + workspaceMemberId: OWNER_MEMBER_ID, + remoteProjects: [{ projectId: 'p1', ownerMemberId: OWNER_MEMBER_ID }], + localBindings: new Map([['p1', local]]), + }); + expect(actions).toEqual([]); + }); + + it('corrects ownership when the local row wrongly claims edit rights on a project someone else now owns', () => { + const local: LocalTeamProjectBinding = { + projectId: 'p1', + workspaceId: WORKSPACE_ID, + visibility: 'team', + createdByWorkspaceMemberId: READER_MEMBER_ID, // stale: I am no longer the owner + resourceHubResourceId: 'resource-abc', + }; + const actions = planWorkspaceProjectReconciliation({ + workspaceId: WORKSPACE_ID, + workspaceMemberId: READER_MEMBER_ID, + remoteProjects: [{ projectId: 'p1', ownerMemberId: OWNER_MEMBER_ID }], + localBindings: new Map([['p1', local]]), + }); + expect(actions).toEqual([ + expect.objectContaining({ + kind: 'bind', + patch: expect.objectContaining({ createdByWorkspaceMemberId: null }), + }), + ]); + }); + + // A pulled teammate mirror is not this member's personal project. Once the + // authoritative catalog confirms the share is gone, quarantine the mirror + // in place instead of misattributing its stale bytes to the reader. + it('revokes a local teammate mirror the remote catalog no longer lists', () => { + const local: LocalTeamProjectBinding = { + projectId: 'p1', + workspaceId: WORKSPACE_ID, + visibility: 'team', + createdByWorkspaceMemberId: null, // this member was a reader, not the owner + resourceHubResourceId: 'resource-abc', + }; + const actions = planWorkspaceProjectReconciliation({ + workspaceId: WORKSPACE_ID, + workspaceMemberId: READER_MEMBER_ID, + remoteProjects: [], // owner unshared: the hub no longer reports this project at all + localBindings: new Map([['p1', local]]), + }); + expect(actions).toEqual([ + { + kind: 'revoke', + projectId: 'p1', + workspaceId: WORKSPACE_ID, + patch: { + visibility: 'team', + resourceState: 'deleted', + createdByWorkspaceMemberId: null, + resourceHubResourceId: 'resource-abc', + cloudTombstonedAt: null, + syncState: 'synced', + }, + }, + ]); + }); + + it('still demotes the current member own project when another client unshares it', () => { + const local: LocalTeamProjectBinding = { + projectId: 'p1', + workspaceId: WORKSPACE_ID, + visibility: 'team', + createdByWorkspaceMemberId: OWNER_MEMBER_ID, + resourceHubResourceId: 'resource-abc', + }; + const actions = planWorkspaceProjectReconciliation({ + workspaceId: WORKSPACE_ID, + workspaceMemberId: OWNER_MEMBER_ID, + remoteProjects: [], + localBindings: new Map([['p1', local]]), + }); + expect(actions).toEqual([ + expect.objectContaining({ + kind: 'demote', + patch: expect.objectContaining({ + visibility: 'personal', + createdByWorkspaceMemberId: OWNER_MEMBER_ID, + }), + }), + ]); + }); + + it('does not touch a local row bound to a DIFFERENT workspace even if it is visibility team', () => { + const local: LocalTeamProjectBinding = { + projectId: 'p1', + workspaceId: 'some-other-workspace', + visibility: 'team', + createdByWorkspaceMemberId: null, + resourceHubResourceId: 'resource-abc', + }; + const actions = planWorkspaceProjectReconciliation({ + workspaceId: WORKSPACE_ID, + workspaceMemberId: READER_MEMBER_ID, + remoteProjects: [], + localBindings: new Map([['p1', local]]), + }); + expect(actions).toEqual([]); + }); + + it('does not touch a local row that is already personal-visibility (nothing to demote)', () => { + const local: LocalTeamProjectBinding = { + projectId: 'p1', + workspaceId: WORKSPACE_ID, + visibility: 'personal', + createdByWorkspaceMemberId: READER_MEMBER_ID, + resourceHubResourceId: null, + }; + const actions = planWorkspaceProjectReconciliation({ + workspaceId: WORKSPACE_ID, + workspaceMemberId: READER_MEMBER_ID, + remoteProjects: [], + localBindings: new Map([['p1', local]]), + }); + expect(actions).toEqual([]); + }); +}); + +describe('reconcileWorkspaceProjectsWithRemote (orchestrator, fake deps)', () => { + function baseDeps(overrides: Partial[0]> = {}) { + return { + getWorkspaceIdentity: async () => ({ workspaceId: WORKSPACE_ID, workspaceMemberId: READER_MEMBER_ID }), + listRemoteTeamProjects: async () => [], + // Materialized by default: these tests exercise binding/demoting logic, + // not the materialization gate (covered by its own tests below). + hasLocalProject: () => true, + listLocalTeamRows: () => [] as LocalTeamProjectBinding[], + getLocalBinding: () => null, + applyBind: vi.fn(), + applyDemote: vi.fn(), + applyRevoke: vi.fn(), + onError: vi.fn(), + ...overrides, + }; + } + + it('is a total no-op off-team (null identity) — never reads or writes anything', async () => { + const listRemoteTeamProjects = vi.fn(async () => []); + const applyBind = vi.fn(); + const applyDemote = vi.fn(); + const result = await reconcileWorkspaceProjectsWithRemote( + baseDeps({ getWorkspaceIdentity: async () => null, listRemoteTeamProjects, applyBind, applyDemote }), + ); + expect(result).toEqual({ bound: 0, demoted: 0, revoked: 0 }); + expect(listRemoteTeamProjects).not.toHaveBeenCalled(); + expect(applyBind).not.toHaveBeenCalled(); + expect(applyDemote).not.toHaveBeenCalled(); + }); + + it('never demotes on a failed remote read (best-effort: missing data is not empty data)', async () => { + const applyDemote = vi.fn(); + const onError = vi.fn(); + const result = await reconcileWorkspaceProjectsWithRemote( + baseDeps({ + listLocalTeamRows: () => [ + { projectId: 'p1', workspaceId: WORKSPACE_ID, visibility: 'team', createdByWorkspaceMemberId: null, resourceHubResourceId: 'r1' }, + ], + listRemoteTeamProjects: async () => { + throw new Error('vela unreachable'); + }, + applyDemote, + onError, + }), + ); + expect(result).toEqual({ bound: 0, demoted: 0, revoked: 0 }); + expect(applyDemote).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('passes the once-captured workspace identity through the remote read when the ambient workspace switches', async () => { + let ambientWorkspaceId = 'team-a'; + const capturedIdentity = { + workspaceId: ambientWorkspaceId, + workspaceMemberId: 'member-a', + }; + const listRemoteTeamProjects = vi.fn(async (identity: typeof capturedIdentity) => { + // Model the user switching to B while A's catalog request is in flight. + ambientWorkspaceId = 'team-b'; + return identity.workspaceId === 'team-a' + ? [{ projectId: 'project-a', ownerMemberId: 'member-a' }] + : [{ projectId: 'project-b', ownerMemberId: 'member-b' }]; + }); + const applyBind = vi.fn(); + + await reconcileWorkspaceProjectsWithRemote( + baseDeps({ + getWorkspaceIdentity: async () => capturedIdentity, + listRemoteTeamProjects, + applyBind, + }), + ); + + expect(ambientWorkspaceId).toBe('team-b'); + expect(listRemoteTeamProjects).toHaveBeenCalledWith(capturedIdentity); + expect(applyBind).toHaveBeenCalledWith( + 'project-a', + expect.objectContaining({ workspaceId: 'team-a' }), + ); + expect(applyBind).not.toHaveBeenCalledWith( + 'project-b', + expect.anything(), + ); + }); + + it('looks up getLocalBinding only for remote projects not already covered by listLocalTeamRows', async () => { + const getLocalBinding = vi.fn(() => null); + await reconcileWorkspaceProjectsWithRemote( + baseDeps({ + listLocalTeamRows: () => [ + { projectId: 'p1', workspaceId: WORKSPACE_ID, visibility: 'team', createdByWorkspaceMemberId: READER_MEMBER_ID, resourceHubResourceId: 'r1' }, + ], + listRemoteTeamProjects: async () => [ + { projectId: 'p1', ownerMemberId: READER_MEMBER_ID }, + { projectId: 'p2', ownerMemberId: OWNER_MEMBER_ID }, + ], + getLocalBinding, + }), + ); + expect(getLocalBinding).toHaveBeenCalledTimes(1); + expect(getLocalBinding).toHaveBeenCalledWith('p2'); + }); + + it('applies bind and revoke actions through the injected writers and reports counts', async () => { + const applyBind = vi.fn(); + const applyRevoke = vi.fn(); + const result = await reconcileWorkspaceProjectsWithRemote( + baseDeps({ + listLocalTeamRows: () => [ + { projectId: 'gone', workspaceId: WORKSPACE_ID, visibility: 'team', createdByWorkspaceMemberId: null, resourceHubResourceId: 'r-gone' }, + ], + listRemoteTeamProjects: async () => [{ projectId: 'new', ownerMemberId: READER_MEMBER_ID }], + applyBind, + applyRevoke, + }), + ); + expect(result).toEqual({ bound: 1, demoted: 0, revoked: 1 }); + expect(applyBind).toHaveBeenCalledWith('new', expect.objectContaining({ visibility: 'team' })); + expect(applyRevoke).toHaveBeenCalledWith( + WORKSPACE_ID, + 'gone', + expect.objectContaining({ visibility: 'team', resourceState: 'deleted' }), + ); + }); + + // recvqmnuxxKHaI: `workspace_projects.project_id` is a FOREIGN KEY into + // `projects(id)`, so a bind for a project this daemon never materialized + // (no `projects` row — e.g. a teammate's share the member never opened) + // can never be written. The reconciler must skip it silently — the pull + // path owns materialization — not throw SQLITE_CONSTRAINT_FOREIGNKEY on + // every pass forever. + it('skips the bind for a remote project with no local binding and no local projects row', async () => { + const applyBind = vi.fn(); + const onError = vi.fn(); + const result = await reconcileWorkspaceProjectsWithRemote( + baseDeps({ + listRemoteTeamProjects: async () => [ + { projectId: 'never-materialized', ownerMemberId: OWNER_MEMBER_ID }, + { projectId: 'materialized', ownerMemberId: OWNER_MEMBER_ID }, + ], + hasLocalProject: (projectId) => projectId === 'materialized', + applyBind, + onError, + }), + ); + expect(result).toEqual({ bound: 1, demoted: 0, revoked: 0 }); + expect(applyBind).toHaveBeenCalledTimes(1); + expect(applyBind).toHaveBeenCalledWith('materialized', expect.objectContaining({ visibility: 'team' })); + expect(onError).not.toHaveBeenCalled(); + }); + + it('still revokes a foreign mirror whose binding exists even when hasLocalProject is consulted for others only', async () => { + // A bound row always implies a projects row (the FK guarantees it), so + // the materialization gate must never suppress the demote direction: a + // team row remote no longer lists still collapses back to personal. + const hasLocalProject = vi.fn(() => false); + const applyRevoke = vi.fn(); + const result = await reconcileWorkspaceProjectsWithRemote( + baseDeps({ + listLocalTeamRows: () => [ + { projectId: 'gone-remote', workspaceId: WORKSPACE_ID, visibility: 'team', createdByWorkspaceMemberId: null, resourceHubResourceId: 'r1' }, + ], + listRemoteTeamProjects: async () => [], + hasLocalProject, + applyRevoke, + }), + ); + expect(result).toEqual({ bound: 0, demoted: 0, revoked: 1 }); + expect(applyRevoke).toHaveBeenCalledWith( + WORKSPACE_ID, + 'gone-remote', + expect.objectContaining({ visibility: 'team', resourceState: 'deleted' }), + ); + }); + + it('reports one writer failure through onError without aborting the rest of the pass', async () => { + const onError = vi.fn(); + const applyRevoke = vi.fn(); + const result = await reconcileWorkspaceProjectsWithRemote( + baseDeps({ + listLocalTeamRows: () => [ + { projectId: 'a', workspaceId: WORKSPACE_ID, visibility: 'team', createdByWorkspaceMemberId: null, resourceHubResourceId: null }, + { projectId: 'b', workspaceId: WORKSPACE_ID, visibility: 'team', createdByWorkspaceMemberId: null, resourceHubResourceId: null }, + ], + listRemoteTeamProjects: async () => [], + applyRevoke: vi.fn((workspaceId: string, projectId: string) => { + if (projectId === 'a') throw new Error('sqlite busy'); + applyRevoke(projectId); + }), + onError, + }), + ); + expect(result).toEqual({ bound: 0, demoted: 0, revoked: 2 }); + expect(onError).toHaveBeenCalledTimes(1); + expect(applyRevoke).toHaveBeenCalledWith('b'); + }); +}); diff --git a/apps/daemon/tests/collab/workspace-resource-mutation.test.ts b/apps/daemon/tests/collab/workspace-resource-mutation.test.ts new file mode 100644 index 00000000000..b36bea6ad5d --- /dev/null +++ b/apps/daemon/tests/collab/workspace-resource-mutation.test.ts @@ -0,0 +1,701 @@ +import { describe, expect, it } from 'vitest'; +import { + enforceVerifiedWorkspaceResourceMutation, + enforceWorkspaceResourceMutation, + type WorkspaceResourceAccessInput, +} from '../../src/collab/workspace-resource-mutation.js'; +import { workspaceContextFromDirectoryItem } from '../../src/collab/vela-workspace-context.js'; + +// Cheapest layer that can see the symptom: exercise the shared gate directly +// against fake req/res/db seams, without spinning up an Express server or a +// real SQLite file. `enforceWorkspaceProjectMutation` in +// routes/project/index.ts is now a one-line delegation to this function, and +// `tests/routes/workspace-projects.test.ts` covers the end-to-end HTTP +// behavior for project; this file covers the shared decision logic itself so +// a future resource type (plugin today) can trust it without re-deriving +// project's full HTTP suite. + +function fakeReq(headers: Record = {}): any { + return { + get(name: string) { + return headers[name] ?? undefined; + }, + }; +} + +function fakeRes(): any { + return {}; +} + +function spySendApiError() { + const calls: Array<{ status: number; code: string; message: string }> = []; + const sendApiError = (_res: unknown, status: number, code: string, message: string) => { + calls.push({ status, code, message }); + }; + return { calls, sendApiError }; +} + +function workspaceHeaders(opts: { + workspaceId?: string; + memberId?: string; + role?: string; + lifecycleState?: string; + canWriteSyncedFiles?: string; +} = {}): Record { + const headers: Record = {}; + if (opts.workspaceId) headers['x-od-workspace-id'] = opts.workspaceId; + if (opts.memberId) headers['x-od-workspace-member-id'] = opts.memberId; + if (opts.role) headers['x-od-workspace-role'] = opts.role; + if (opts.lifecycleState) headers['x-od-workspace-lifecycle-state'] = opts.lifecycleState; + if (opts.canWriteSyncedFiles) headers['x-od-workspace-can-write-synced-files'] = opts.canWriteSyncedFiles; + return headers; +} + +function makeLookups(rowsByResourceId: Record) { + const getWorkspaceResource = (_db: unknown, workspaceId: string, resourceId: string) => { + const row = rowsByResourceId[resourceId]; + if (!row || row.workspaceId !== workspaceId) return undefined; + return row; + }; + const getWorkspaceResourceByResourceId = (_db: unknown, resourceId: string) => rowsByResourceId[resourceId]; + return { getWorkspaceResource, getWorkspaceResourceByResourceId }; +} + +describe('enforceWorkspaceResourceMutation', () => { + it('allows a headerless caller against a resource with no team binding', () => { + const { getWorkspaceResource, getWorkspaceResourceByResourceId } = makeLookups({}); + const { calls, sendApiError } = spySendApiError(); + const allowed = enforceWorkspaceResourceMutation( + 'plugin', + fakeReq(), + fakeRes(), + sendApiError, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + {}, + 'plugin-a', + 'delete', + ); + expect(allowed).toBe(true); + expect(calls).toHaveLength(0); + }); + + it('rejects a headerless caller against a team-visibility resource', () => { + const { getWorkspaceResource, getWorkspaceResourceByResourceId } = makeLookups({ + 'plugin-a': { workspaceId: 'ws-1', visibility: 'team', resourceState: 'active', createdByWorkspaceMemberId: 'member-owner' }, + }); + const { calls, sendApiError } = spySendApiError(); + const allowed = enforceWorkspaceResourceMutation( + 'plugin', + fakeReq(), + fakeRes(), + sendApiError, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + {}, + 'plugin-a', + 'delete', + ); + expect(allowed).toBe(false); + expect(calls).toEqual([{ status: 401, code: 'WORKSPACE_CONTEXT_REQUIRED', message: 'workspace context is required' }]); + }); + + // spec 04 §10 fix #3 (recvqbeDjAsejl / recvqbklNGDqYY): before this fix, the + // null-ctx branch only refused a `visibility: 'team'` row and let ANY + // `personal` row through unconditionally — so a signed-out caller (or a + // plain `curl` with no workspace headers) could still mutate someone else's + // personal-but-CLAIMED resource. A claimed resource is a claimed resource + // regardless of whether it's also shared with a team; only a genuinely + // UNBOUND resource (no row at all — the case above already covers this) + // should pass a headerless caller through. + it('rejects a headerless caller against a personal-visibility (but bound) resource', () => { + const { getWorkspaceResource, getWorkspaceResourceByResourceId } = makeLookups({ + 'plugin-a': { workspaceId: 'ws-1', visibility: 'personal', resourceState: 'active', createdByWorkspaceMemberId: 'member-owner' }, + }); + const { calls, sendApiError } = spySendApiError(); + const allowed = enforceWorkspaceResourceMutation( + 'plugin', + fakeReq(), + fakeRes(), + sendApiError, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + {}, + 'plugin-a', + 'delete', + ); + expect(allowed).toBe(false); + expect(calls).toEqual([{ status: 401, code: 'WORKSPACE_CONTEXT_REQUIRED', message: 'workspace context is required' }]); + }); + + it('rejects a caller carrying only a partial workspace header pair', () => { + const { getWorkspaceResource, getWorkspaceResourceByResourceId } = makeLookups({}); + const { calls, sendApiError } = spySendApiError(); + const allowed = enforceWorkspaceResourceMutation( + 'plugin', + fakeReq({ 'x-od-workspace-id': 'ws-1' }), // no member id + fakeRes(), + sendApiError, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + {}, + 'plugin-a', + 'delete', + ); + expect(allowed).toBe(false); + expect(calls).toEqual([{ status: 401, code: 'WORKSPACE_CONTEXT_REQUIRED', message: 'workspace context is required' }]); + }); + + it('allows the member who created the resource to mutate it', () => { + const { getWorkspaceResource, getWorkspaceResourceByResourceId } = makeLookups({ + 'plugin-a': { workspaceId: 'ws-1', visibility: 'personal', resourceState: 'active', createdByWorkspaceMemberId: 'member-a' }, + }); + const { calls, sendApiError } = spySendApiError(); + const allowed = enforceWorkspaceResourceMutation( + 'plugin', + fakeReq(workspaceHeaders({ workspaceId: 'ws-1', memberId: 'member-a', role: 'member' })), + fakeRes(), + sendApiError, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + {}, + 'plugin-a', + 'delete', + ); + expect(allowed).toBe(true); + expect(calls).toHaveLength(0); + }); + + it('rejects a different, non-privileged member from mutating someone else\'s resource', () => { + const { getWorkspaceResource, getWorkspaceResourceByResourceId } = makeLookups({ + 'plugin-a': { workspaceId: 'ws-1', visibility: 'personal', resourceState: 'active', createdByWorkspaceMemberId: 'member-a' }, + }); + const { calls, sendApiError } = spySendApiError(); + const allowed = enforceWorkspaceResourceMutation( + 'plugin', + fakeReq(workspaceHeaders({ workspaceId: 'ws-1', memberId: 'member-b', role: 'member' })), + fakeRes(), + sendApiError, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + {}, + 'plugin-a', + 'delete', + ); + expect(allowed).toBe(false); + expect(calls).toEqual([{ + status: 403, + code: 'WORKSPACE_PLUGIN_PERMISSION_DENIED', + message: 'workspace plugin mutation is not allowed', + }]); + }); + + it('allows a privileged owner/admin to mutate a resource they did not create', () => { + const { getWorkspaceResource, getWorkspaceResourceByResourceId } = makeLookups({ + 'plugin-a': { workspaceId: 'ws-1', visibility: 'personal', resourceState: 'active', createdByWorkspaceMemberId: 'member-a' }, + }); + const { calls, sendApiError } = spySendApiError(); + const allowed = enforceWorkspaceResourceMutation( + 'plugin', + fakeReq(workspaceHeaders({ workspaceId: 'ws-1', memberId: 'member-owner', role: 'owner' })), + fakeRes(), + sendApiError, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + {}, + 'plugin-a', + 'delete', + ); + expect(allowed).toBe(true); + expect(calls).toHaveLength(0); + }); + + it('rejects mutation of a resource bound to a different workspace than the caller\'s', () => { + const { getWorkspaceResource, getWorkspaceResourceByResourceId } = makeLookups({ + 'plugin-a': { workspaceId: 'ws-other', visibility: 'personal', resourceState: 'active', createdByWorkspaceMemberId: 'member-a' }, + }); + const { calls, sendApiError } = spySendApiError(); + const allowed = enforceWorkspaceResourceMutation( + 'plugin', + fakeReq(workspaceHeaders({ workspaceId: 'ws-1', memberId: 'member-a', role: 'owner' })), + fakeRes(), + sendApiError, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + {}, + 'plugin-a', + 'delete', + ); + expect(allowed).toBe(false); + expect(calls).toEqual([{ + status: 403, + code: 'WORKSPACE_PLUGIN_PERMISSION_DENIED', + message: 'workspace plugin mutation is not allowed', + }]); + }); + + it('rejects mutation of a frozen resource even for a privileged caller', () => { + const { getWorkspaceResource, getWorkspaceResourceByResourceId } = makeLookups({ + 'plugin-a': { workspaceId: 'ws-1', visibility: 'team', resourceState: 'frozen', createdByWorkspaceMemberId: 'member-owner' }, + }); + const { calls, sendApiError } = spySendApiError(); + const allowed = enforceWorkspaceResourceMutation( + 'plugin', + fakeReq(workspaceHeaders({ workspaceId: 'ws-1', memberId: 'member-owner', role: 'owner' })), + fakeRes(), + sendApiError, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + {}, + 'plugin-a', + 'delete', + ); + expect(allowed).toBe(false); + expect(calls).toEqual([{ + status: 403, + code: 'WORKSPACE_PLUGIN_PERMISSION_DENIED', + message: 'workspace plugin mutation is not allowed', + }]); + }); + + // recvqbbQ4yljNC / recvqbeDjAsejl: a member removed from the workspace keeps + // sending stale "active" workspace headers (its own client hasn't re-polled + // /api/workspace/context yet) until the daemon cross-checks them against its + // own last-verified membership state. + describe('membership cross-check against the daemon\'s own last-known context', () => { + it('BUG: allows a removed member\'s write when only client headers are consulted', () => { + // This test pins the CURRENT (vulnerable) behavior: no + // `getLastKnownMembership` is wired up, so the gate has only the + // client's own claim to go on — exactly the pre-fix code path. + const { getWorkspaceResource, getWorkspaceResourceByResourceId } = makeLookups({ + 'plugin-a': { workspaceId: 'ws-1', visibility: 'personal', resourceState: 'active', createdByWorkspaceMemberId: 'member-a' }, + }); + const { calls, sendApiError } = spySendApiError(); + const allowed = enforceWorkspaceResourceMutation( + 'plugin', + fakeReq(workspaceHeaders({ workspaceId: 'ws-1', memberId: 'member-a', role: 'member' })), + fakeRes(), + sendApiError, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + {}, + 'plugin-a', + 'delete', + ); + expect(allowed).toBe(true); + expect(calls).toHaveLength(0); + }); + + it('rejects the write once the daemon\'s own last-known context says the caller was removed', () => { + const { getWorkspaceResource, getWorkspaceResourceByResourceId } = makeLookups({ + 'plugin-a': { workspaceId: 'ws-1', visibility: 'personal', resourceState: 'active', createdByWorkspaceMemberId: 'member-a' }, + }); + const { calls, sendApiError } = spySendApiError(); + // Client headers still say "active" (stale) — the daemon's own cache + // says this same workspace's caller has been removed. + const getLastKnownMembership = () => ({ workspaceId: 'ws-1', memberStatus: 'removed' as const }); + const allowed = enforceWorkspaceResourceMutation( + 'plugin', + fakeReq(workspaceHeaders({ workspaceId: 'ws-1', memberId: 'member-a', role: 'member' })), + fakeRes(), + sendApiError, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + {}, + 'plugin-a', + 'delete', + getLastKnownMembership, + ); + expect(allowed).toBe(false); + expect(calls).toEqual([{ + status: 403, + code: 'WORKSPACE_PLUGIN_PERMISSION_DENIED', + message: 'workspace plugin mutation is not allowed', + }]); + }); + + it('does not override an already-removed header (redundant agreement)', () => { + const { getWorkspaceResource, getWorkspaceResourceByResourceId } = makeLookups({ + 'plugin-a': { workspaceId: 'ws-1', visibility: 'team', resourceState: 'active', createdByWorkspaceMemberId: 'member-owner' }, + }); + const { calls, sendApiError } = spySendApiError(); + const getLastKnownMembership = () => ({ workspaceId: 'ws-1', memberStatus: 'removed' as const }); + const allowed = enforceWorkspaceResourceMutation( + 'plugin', + fakeReq({ + ...workspaceHeaders({ workspaceId: 'ws-1', memberId: 'member-owner', role: 'owner' }), + 'x-od-workspace-member-status': 'removed', + }), + fakeRes(), + sendApiError, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + {}, + 'plugin-a', + 'delete', + getLastKnownMembership, + ); + expect(allowed).toBe(false); + expect(calls).toEqual([{ + status: 403, + code: 'WORKSPACE_PLUGIN_PERMISSION_DENIED', + message: 'workspace plugin mutation is not allowed', + }]); + }); + + it('trusts the header when the cache has no opinion for this workspace (never queried it)', () => { + const { getWorkspaceResource, getWorkspaceResourceByResourceId } = makeLookups({ + 'plugin-a': { workspaceId: 'ws-1', visibility: 'personal', resourceState: 'active', createdByWorkspaceMemberId: 'member-a' }, + }); + const { calls, sendApiError } = spySendApiError(); + const getLastKnownMembership = () => null; + const allowed = enforceWorkspaceResourceMutation( + 'plugin', + fakeReq(workspaceHeaders({ workspaceId: 'ws-1', memberId: 'member-a', role: 'member' })), + fakeRes(), + sendApiError, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + {}, + 'plugin-a', + 'delete', + getLastKnownMembership, + ); + expect(allowed).toBe(true); + expect(calls).toHaveLength(0); + }); + + it('trusts the header when the cache last resolved a DIFFERENT workspace', () => { + const { getWorkspaceResource, getWorkspaceResourceByResourceId } = makeLookups({ + 'plugin-a': { workspaceId: 'ws-1', visibility: 'personal', resourceState: 'active', createdByWorkspaceMemberId: 'member-a' }, + }); + const { calls, sendApiError } = spySendApiError(); + // Cache holds a real "removed" fact, but for a DIFFERENT workspace than + // the one this request is scoped to — must not leak across workspaces. + const getLastKnownMembership = () => ({ workspaceId: 'ws-other', memberStatus: 'removed' as const }); + const allowed = enforceWorkspaceResourceMutation( + 'plugin', + fakeReq(workspaceHeaders({ workspaceId: 'ws-1', memberId: 'member-a', role: 'member' })), + fakeRes(), + sendApiError, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + {}, + 'plugin-a', + 'delete', + getLastKnownMembership, + ); + expect(allowed).toBe(true); + expect(calls).toHaveLength(0); + }); + }); + + it('reports WORKSPACE_LOCKED instead of a permission denial when the workspace itself is locked', () => { + const { getWorkspaceResource, getWorkspaceResourceByResourceId } = makeLookups({ + 'plugin-a': { workspaceId: 'ws-1', visibility: 'personal', resourceState: 'active', createdByWorkspaceMemberId: 'member-a' }, + }); + const { calls, sendApiError } = spySendApiError(); + const allowed = enforceWorkspaceResourceMutation( + 'plugin', + fakeReq(workspaceHeaders({ workspaceId: 'ws-1', memberId: 'member-a', role: 'owner', lifecycleState: 'locked' })), + fakeRes(), + sendApiError, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + {}, + 'plugin-a', + 'delete', + ); + expect(allowed).toBe(false); + expect(calls).toEqual([{ + status: 403, + code: 'WORKSPACE_LOCKED', + message: 'workspace plugin mutation is not allowed', + }]); + }); +}); + +// The gate used to be ASYMMETRIC on a resource that no workspace has claimed: +// +// headerless -> allowed (`headerlessMutationAllowed` short-circuits on +// "no row anywhere" before it even asks for an identity) +// identity asserted -> refused, because the asserted path resolves the row +// inside the caller's OWN workspace and treats a missing +// row as a refusal +// +// That asymmetry protected nothing. Any caller who wanted the permissive answer +// could simply drop its headers and get it, so the only thing the refusal did was +// punish honest clients for identifying themselves — and it is what forced the web +// client to tiptoe about WHEN it may name itself, which produced a +// 401 WORKSPACE_CONTEXT_REQUIRED on the Home example-prompt send. +// +// `routes/plugins/index.ts` already ships exactly the behavior asserted below, +// and names the rule: an unbound resource "stays outside the isolation regime +// rather than becoming permanently un-uninstallable the moment a caller happens +// to carry workspace headers" (the design's "no retroactive tagging" rule, which +// design systems' `designSystemVisibleFromWorkspace` also follows). Project was +// the one resource type that disagreed. +// +// This ONLY permits the operation. The gate returns a boolean and writes nothing, +// so a previously-unbound resource is not adopted into the asserting caller's +// workspace — #6213's objection to silently rebinding an orphan is untouched. +describe('enforceWorkspaceResourceMutation — a resource no workspace has claimed', () => { + it('allows an asserted identity, exactly as it already allows a headerless caller', () => { + const { getWorkspaceResource, getWorkspaceResourceByResourceId } = makeLookups({}); + const { calls, sendApiError } = spySendApiError(); + + const allowed = enforceWorkspaceResourceMutation( + 'project', + fakeReq(workspaceHeaders({ workspaceId: 'ws-1', memberId: 'member-1' })), + fakeRes(), + sendApiError, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + {}, + 'project-unbound', + 'writeFiles', + ); + + expect(allowed, 'the same caller would be allowed by simply omitting its headers').toBe(true); + expect(calls).toEqual([]); + }); + + // The boundary that must NOT move: "no row in MY workspace" is not the same + // fact as "no row anywhere". A resource bound to someone else's workspace stays + // refused, which is what `e2e/tests/collab/headerless-mutation.test.ts` pins + // for the headerless path and what stops dropping/forging headers from becoming + // an escalation route. + it('still refuses an asserted identity when the resource is bound to another workspace', () => { + const { getWorkspaceResource, getWorkspaceResourceByResourceId } = makeLookups({ + 'project-elsewhere': { + workspaceId: 'ws-someone-else', + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: 'member-other', + }, + }); + const { calls, sendApiError } = spySendApiError(); + + const allowed = enforceWorkspaceResourceMutation( + 'project', + fakeReq(workspaceHeaders({ workspaceId: 'ws-1', memberId: 'member-1' })), + fakeRes(), + sendApiError, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + {}, + 'project-elsewhere', + 'writeFiles', + ); + + expect(allowed).toBe(false); + expect(calls).toEqual([{ + status: 403, + code: 'WORKSPACE_PROJECT_PERMISSION_DENIED', + message: 'workspace project mutation is not allowed', + }]); + }); +}); + +describe('authoritative Workspace-bound mutation regression', () => { + it('rejects a forged owner role when the authoritative member is ordinary', async () => { + const { getWorkspaceResource, getWorkspaceResourceByResourceId } = makeLookups({ + 'project-a': { + workspaceId: 'workspace-a', + visibility: 'team', + resourceState: 'active', + createdByWorkspaceMemberId: 'member-owner', + }, + }); + const { calls, sendApiError } = spySendApiError(); + const allowed = await enforceVerifiedWorkspaceResourceMutation( + 'project', + fakeReq(workspaceHeaders({ + workspaceId: 'workspace-a', + memberId: 'member-attacker', + role: 'owner', + })), + fakeRes(), + sendApiError, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + {}, + 'project-a', + 'writeFiles', + async () => ({ + ok: true, + context: workspaceContextFromDirectoryItem({ + workspaceId: 'workspace-a', + workspaceName: 'Workspace A', + workspaceType: 'team', + workspaceMemberId: 'member-attacker', + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + }), + }), + ); + + expect(allowed).toBe(false); + expect(calls.at(-1)?.code).toBe('WORKSPACE_PROJECT_PERMISSION_DENIED'); + }); + + it('does not let ambient Workspace A authorize a headerless bound mutation', async () => { + const { getWorkspaceResource, getWorkspaceResourceByResourceId } = makeLookups({ + 'project-a': { + workspaceId: 'workspace-a', + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: 'member-a', + }, + }); + const { calls, sendApiError } = spySendApiError(); + const allowed = await enforceVerifiedWorkspaceResourceMutation( + 'project', + fakeReq(), + fakeRes(), + sendApiError, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + {}, + 'project-a', + 'writeFiles', + async () => ({ + ok: false, + status: 400, + code: 'WORKSPACE_CONTEXT_REQUIRED', + message: 'an explicit workspace context is required', + }), + ); + + expect(allowed).toBe(false); + expect(calls.at(-1)?.code).toBe('WORKSPACE_CONTEXT_REQUIRED'); + }); + + it('fails closed before side effects when membership authority is unavailable', async () => { + const { getWorkspaceResource, getWorkspaceResourceByResourceId } = makeLookups({ + 'project-a': { + workspaceId: 'workspace-a', + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: 'member-a', + }, + }); + const { calls, sendApiError } = spySendApiError(); + let sideEffects = 0; + const allowed = await enforceVerifiedWorkspaceResourceMutation( + 'project', + fakeReq(workspaceHeaders({ workspaceId: 'workspace-a', memberId: 'member-a' })), + fakeRes(), + sendApiError, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + {}, + 'project-a', + 'writeFiles', + async () => ({ + ok: false, + status: 503, + code: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + message: 'workspace membership authority is temporarily unavailable', + retryable: true, + }), + ); + if (allowed) sideEffects += 1; + + expect(allowed).toBe(false); + expect(sideEffects).toBe(0); + expect(calls.at(-1)).toMatchObject({ + status: 503, + code: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + }); + }); + + it('re-verifies every mutation after a prior success and blocks removal or outage with zero new side effects', async () => { + const { getWorkspaceResource, getWorkspaceResourceByResourceId } = makeLookups({ + 'plugin-a': { + workspaceId: 'workspace-a', + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: 'member-a', + }, + }); + const { sendApiError } = spySendApiError(); + const authorityResults = [ + { + ok: true as const, + context: workspaceContextFromDirectoryItem({ + workspaceId: 'workspace-a', + workspaceName: 'Workspace A', + workspaceType: 'team', + workspaceMemberId: 'member-a', + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + }), + }, + { + ok: false as const, + status: 403 as const, + code: 'WORKSPACE_ACCESS_DENIED', + message: 'the member was removed', + }, + { + ok: false as const, + status: 503 as const, + code: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + message: 'workspace authority is unavailable', + retryable: true as const, + }, + ]; + let authorityReads = 0; + let sideEffects = 0; + const mutate = async () => { + const allowed = await enforceVerifiedWorkspaceResourceMutation( + 'plugin', + fakeReq(workspaceHeaders({ + workspaceId: 'workspace-a', + memberId: 'member-a', + })), + fakeRes(), + sendApiError, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + {}, + 'plugin-a', + 'writeFiles', + async () => authorityResults[authorityReads++]!, + ); + if (allowed) sideEffects += 1; + return allowed; + }; + + await expect(mutate()).resolves.toBe(true); + expect(sideEffects).toBe(1); + await expect(mutate()).resolves.toBe(false); + expect(sideEffects).toBe(1); + await expect(mutate()).resolves.toBe(false); + expect(sideEffects).toBe(1); + expect(authorityReads).toBe(3); + }); + + it('keeps a truly unbound legacy local resource mutable', async () => { + const { getWorkspaceResource, getWorkspaceResourceByResourceId } = makeLookups({}); + const { calls, sendApiError } = spySendApiError(); + const allowed = await enforceVerifiedWorkspaceResourceMutation( + 'project', + fakeReq(), + fakeRes(), + sendApiError, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + {}, + 'legacy-local', + 'writeFiles', + undefined, + ); + + expect(allowed).toBe(true); + expect(calls).toEqual([]); + }); +}); diff --git a/apps/daemon/tests/collab/workspace-resources-reconciler.test.ts b/apps/daemon/tests/collab/workspace-resources-reconciler.test.ts new file mode 100644 index 00000000000..182400198dc --- /dev/null +++ b/apps/daemon/tests/collab/workspace-resources-reconciler.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + planWorkspaceResourceReconciliation, + reconcileWorkspaceResourcesWithRemote, + type LocalTeamResourceBinding, +} from '../../src/collab/workspace-resources-reconciler.js'; + +const WORKSPACE_ID = 'team-1'; + +describe('planWorkspaceResourceReconciliation (pure)', () => { + it('retires a local active-team row the remote listing no longer confirms', () => { + const localActiveTeamRows: LocalTeamResourceBinding[] = [ + { resourceId: 'skill-gone', workspaceId: WORKSPACE_ID, visibility: 'team', resourceState: 'active' }, + ]; + const actions = planWorkspaceResourceReconciliation({ + workspaceId: WORKSPACE_ID, + remoteResources: [], + localActiveTeamRows, + }); + expect(actions).toEqual([ + { kind: 'retire', resourceId: 'skill-gone', workspaceId: WORKSPACE_ID }, + ]); + }); + + it('does nothing when the remote listing still confirms the local row', () => { + const localActiveTeamRows: LocalTeamResourceBinding[] = [ + { resourceId: 'skill-still-shared', workspaceId: WORKSPACE_ID, visibility: 'team', resourceState: 'active' }, + ]; + const actions = planWorkspaceResourceReconciliation({ + workspaceId: WORKSPACE_ID, + remoteResources: [{ resourceId: 'skill-still-shared' }], + localActiveTeamRows, + }); + expect(actions).toEqual([]); + }); + + it('ignores a row bound to a DIFFERENT workspace than the one being reconciled', () => { + const localActiveTeamRows: LocalTeamResourceBinding[] = [ + { resourceId: 'skill-other-ws', workspaceId: 'team-2', visibility: 'team', resourceState: 'active' }, + ]; + const actions = planWorkspaceResourceReconciliation({ + workspaceId: WORKSPACE_ID, + remoteResources: [], + localActiveTeamRows, + }); + expect(actions).toEqual([]); + }); + + it('retires multiple stale rows in one pass and leaves confirmed ones alone', () => { + const localActiveTeamRows: LocalTeamResourceBinding[] = [ + { resourceId: 'still-shared', workspaceId: WORKSPACE_ID, visibility: 'team', resourceState: 'active' }, + { resourceId: 'gone-1', workspaceId: WORKSPACE_ID, visibility: 'team', resourceState: 'active' }, + { resourceId: 'gone-2', workspaceId: WORKSPACE_ID, visibility: 'team', resourceState: 'active' }, + ]; + const actions = planWorkspaceResourceReconciliation({ + workspaceId: WORKSPACE_ID, + remoteResources: [{ resourceId: 'still-shared' }], + localActiveTeamRows, + }); + expect(actions).toEqual([ + { kind: 'retire', resourceId: 'gone-1', workspaceId: WORKSPACE_ID }, + { kind: 'retire', resourceId: 'gone-2', workspaceId: WORKSPACE_ID }, + ]); + }); +}); + +describe('reconcileWorkspaceResourcesWithRemote (orchestrator)', () => { + function baseDeps(overrides: Partial[0]> = {}) { + return { + getWorkspaceIdentity: async () => ({ workspaceId: WORKSPACE_ID }), + listRemoteTeamResources: async () => [], + listLocalActiveTeamRows: () => [], + applyRetire: vi.fn(), + ...overrides, + }; + } + + it('is a no-op off-team (getWorkspaceIdentity resolves null)', async () => { + const listRemoteTeamResources = vi.fn(async () => []); + const applyRetire = vi.fn(); + const result = await reconcileWorkspaceResourcesWithRemote( + baseDeps({ getWorkspaceIdentity: async () => null, listRemoteTeamResources, applyRetire }), + ); + expect(result).toEqual({ retired: 0 }); + expect(listRemoteTeamResources).not.toHaveBeenCalled(); + expect(applyRetire).not.toHaveBeenCalled(); + }); + + it('never retires on a failed remote read (best-effort: missing data is not empty data)', async () => { + const applyRetire = vi.fn(); + const onError = vi.fn(); + const result = await reconcileWorkspaceResourcesWithRemote( + baseDeps({ + listLocalActiveTeamRows: () => [ + { resourceId: 'r1', workspaceId: WORKSPACE_ID, visibility: 'team', resourceState: 'active' }, + ], + listRemoteTeamResources: async () => { + throw new Error('vela unreachable'); + }, + applyRetire, + onError, + }), + ); + expect(result).toEqual({ retired: 0 }); + expect(applyRetire).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('never retires on a failed identity read either', async () => { + const applyRetire = vi.fn(); + const onError = vi.fn(); + const result = await reconcileWorkspaceResourcesWithRemote( + baseDeps({ + getWorkspaceIdentity: async () => { + throw new Error('workspace context read failed'); + }, + applyRetire, + onError, + }), + ); + expect(result).toEqual({ retired: 0 }); + expect(applyRetire).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('applies retire actions through the injected writer and reports the count', async () => { + const applyRetire = vi.fn(); + const result = await reconcileWorkspaceResourcesWithRemote( + baseDeps({ + listLocalActiveTeamRows: () => [ + { resourceId: 'gone', workspaceId: WORKSPACE_ID, visibility: 'team', resourceState: 'active' }, + ], + listRemoteTeamResources: async () => [], + applyRetire, + }), + ); + expect(result).toEqual({ retired: 1 }); + expect(applyRetire).toHaveBeenCalledWith(WORKSPACE_ID, 'gone'); + }); + + it('reports one writer failure through onError without aborting the rest of the pass', async () => { + const onError = vi.fn(); + const applied: string[] = []; + const result = await reconcileWorkspaceResourcesWithRemote( + baseDeps({ + listLocalActiveTeamRows: () => [ + { resourceId: 'a', workspaceId: WORKSPACE_ID, visibility: 'team', resourceState: 'active' }, + { resourceId: 'b', workspaceId: WORKSPACE_ID, visibility: 'team', resourceState: 'active' }, + ], + listRemoteTeamResources: async () => [], + applyRetire: (workspaceId: string, resourceId: string) => { + if (resourceId === 'a') throw new Error('sqlite busy'); + applied.push(resourceId); + }, + onError, + }), + ); + expect(result).toEqual({ retired: 2 }); + expect(onError).toHaveBeenCalledTimes(1); + expect(applied).toEqual(['b']); + }); + + it('does not retire a row already resourceState:"deleted" (the caller is expected to prefilter, but a stray row must stay a no-op if it slips through)', async () => { + // Belt-and-suspenders: even if a caller's `listLocalActiveTeamRows` bug + // let a `resourceState: 'deleted'` row through, the planner only acts on + // ABSENCE from the remote listing, so passing an already-retired row that + // the remote ALSO no longer lists would retire it again (idempotent — + // `applyRetire` writing the same 'deleted' state twice is harmless). This + // pins that idempotency rather than asserting a prefilter this module + // does not own. + const applyRetire = vi.fn(); + const result = await reconcileWorkspaceResourcesWithRemote( + baseDeps({ + listLocalActiveTeamRows: () => [ + { resourceId: 'already-retired', workspaceId: WORKSPACE_ID, visibility: 'team', resourceState: 'deleted' }, + ], + listRemoteTeamResources: async () => [], + applyRetire, + }), + ); + expect(result).toEqual({ retired: 1 }); + expect(applyRetire).toHaveBeenCalledWith(WORKSPACE_ID, 'already-retired'); + }); +}); diff --git a/apps/daemon/tests/collab/workspace-scope.test.ts b/apps/daemon/tests/collab/workspace-scope.test.ts new file mode 100644 index 00000000000..33ee942dee9 --- /dev/null +++ b/apps/daemon/tests/collab/workspace-scope.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { resolveWorkspaceScope } from '../../src/collab/workspace-scope.js'; + +// B-line handoff (vela-client-explicit-workspace-handoff): every workspace- +// scoped call resolves its target through ONE entry with a fixed priority — +// explicit per-call id → the project's pinned workspace → the locally +// persisted selection → environment — and only when all are absent does the +// request go out header-less so the server's Active Workspace fallback +// applies. The resolver never invents an id and never touches server state. +describe('resolveWorkspaceScope', () => { + it('prefers the explicit per-call id over everything', () => { + expect( + resolveWorkspaceScope({ + explicit: 'ws-explicit', + projectWorkspaceId: 'ws-project', + localSelection: 'ws-local', + envWorkspaceId: 'ws-env', + }), + ).toEqual({ workspaceId: 'ws-explicit', source: 'explicit' }); + }); + + it('falls back explicit → project → local selection → environment', () => { + expect( + resolveWorkspaceScope({ + projectWorkspaceId: 'ws-project', + localSelection: 'ws-local', + envWorkspaceId: 'ws-env', + }), + ).toEqual({ workspaceId: 'ws-project', source: 'project' }); + expect( + resolveWorkspaceScope({ localSelection: 'ws-local', envWorkspaceId: 'ws-env' }), + ).toEqual({ workspaceId: 'ws-local', source: 'local-selection' }); + expect(resolveWorkspaceScope({ envWorkspaceId: 'ws-env' })).toEqual({ + workspaceId: 'ws-env', + source: 'environment', + }); + }); + + it('treats blank and whitespace ids as absent', () => { + expect( + resolveWorkspaceScope({ + explicit: ' ', + projectWorkspaceId: '', + localSelection: '\n', + envWorkspaceId: ' ws-env ', + }), + ).toEqual({ workspaceId: 'ws-env', source: 'environment' }); + }); + + it('returns the server-current fallback marker when nothing is set', () => { + expect(resolveWorkspaceScope({})).toEqual({ source: 'server-current' }); + }); +}); diff --git a/apps/daemon/tests/collab/workspace-settings-url.test.ts b/apps/daemon/tests/collab/workspace-settings-url.test.ts new file mode 100644 index 00000000000..23600b7c704 --- /dev/null +++ b/apps/daemon/tests/collab/workspace-settings-url.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { + parseWorkspaceCollabContext, + resolveWorkspaceSettingsUrl, +} from '../../src/collab/workspace-context.js'; + +// B's web console takes ?workspaceId deep links (target page opens directly +// when it matches the account's Active Workspace; otherwise the web asks the +// user to confirm the switch). Console links must pin the id — a bare +// /settings link depends on whatever workspace another device left active. +describe('resolveWorkspaceSettingsUrl', () => { + it('builds the settings deep link with the workspace id pinned', () => { + expect( + resolveWorkspaceSettingsUrl('ws-1', undefined, { + OD_VELA_WEB_URL: 'https://web.example', + } as NodeJS.ProcessEnv), + ).toBe('https://web.example/settings?workspaceId=ws-1'); + }); + + it('appends the id to an explicit URL that lacks it and preserves one that has it', () => { + expect(resolveWorkspaceSettingsUrl('ws-1', 'https://web.example/settings')).toBe( + 'https://web.example/settings?workspaceId=ws-1', + ); + expect( + resolveWorkspaceSettingsUrl('ws-1', 'https://web.example/settings?workspaceId=ws-other'), + ).toBe('https://web.example/settings?workspaceId=ws-other'); + }); + + it('returns undefined without a base and leaves unparseable explicit values alone', () => { + expect( + resolveWorkspaceSettingsUrl('ws-1', undefined, {} as NodeJS.ProcessEnv), + ).toBeUndefined(); + expect(resolveWorkspaceSettingsUrl('ws-1', 'not-a-url')).toBe('not-a-url'); + }); +}); + +describe('parseWorkspaceCollabContext', () => { + it('preserves the Personal workspace console link used by team actions', () => { + const context = parseWorkspaceCollabContext({ + workspaceId: 'ws-personal', + workspaceType: 'personal', + workspaceMemberId: 'wm-owner', + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + workspaceSettingsUrl: 'https://web.example/settings', + }); + + expect(context?.workspaceSettingsUrl).toBe( + 'https://web.example/settings?workspaceId=ws-personal', + ); + }); +}); diff --git a/apps/daemon/tests/collab/workspace-switch-warms-caches.test.ts b/apps/daemon/tests/collab/workspace-switch-warms-caches.test.ts new file mode 100644 index 00000000000..c1f7c61a6c8 --- /dev/null +++ b/apps/daemon/tests/collab/workspace-switch-warms-caches.test.ts @@ -0,0 +1,397 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import express from 'express'; +import http from 'node:http'; +import { buildWorkspacePermissions, buildWorkspaceSeatSummary } from '@open-design/contracts'; +import type { + WorkspaceCollabContext, + WorkspaceDirectoryItem, +} from '@open-design/contracts'; +import { + registerCollabContextRoutes, + type RegisterCollabContextRoutesDeps, +} from '../../src/routes/collab-context.js'; +import { + createCachedWorkspaceDirectoryFetcher, + createVelaWorkspaceContextProvider, + fetchVelaWorkspaceDirectory, +} from '../../src/collab/vela-workspace-context.js'; + +// Every workspace-scoped cache in the daemon keys on the active workspace, so a +// switch leaves all of them cold and the FIRST consumer in the new workspace +// pays the refill inline on its own request path. `onWorkspaceSwitched` is the +// seam that lets the owner of those caches warm them during the idle beat right +// after the user switches. +// +// The contract this file pins is deliberately narrow, because getting it wrong +// is worse than not warming at all: the announcement must fire for a CONFIRMED +// switch and for nothing else. Warming on a rejected or rolled-back switch would +// refill the caches against the workspace the daemon just refused to move to. + +let server: http.Server | null = null; + +afterEach(async () => { + if (server) { + const toClose = server; + server = null; + await new Promise((resolve) => toClose.close(() => resolve())); + } +}); + +const PERSONAL = 'ws-personal'; +const TEAM = 'ws-team'; + +function directoryItem(workspaceId: string): WorkspaceDirectoryItem { + return { + workspaceId, + workspaceName: workspaceId === TEAM ? 'Acme' : "Ma Shu's workspace", + workspaceType: workspaceId === TEAM ? 'team' : 'personal', + workspaceMemberId: `wm-${workspaceId}`, + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + }; +} + +function contextFor(workspaceId: string): WorkspaceCollabContext { + return { + workspaceId, + workspaceType: workspaceId === TEAM ? 'team' : 'personal', + workspaceMemberId: `wm-${workspaceId}`, + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + billingState: 'active', + planId: null, + providerMode: 'platform_credits', + seatSummary: buildWorkspaceSeatSummary({ seatLimit: 5, usedSeats: 1 }), + permissions: buildWorkspacePermissions({ role: 'owner', lifecycleState: 'active' }), + }; +} + +/** + * A switch harness with a legacy active-workspace pin. The compatibility route + * must leave it untouched; only the response and exact-scope warm announcement + * describe the tab-local selection. + */ +async function startSwitchServer(options: { + /** What the follow-up context read answers. Default: agrees with the pin. */ + currentContext?: (pinned: string | null) => WorkspaceCollabContext | null; + initial?: string; + /** What the membership directory lists. Default: both workspaces, live. */ + directory?: WorkspaceDirectoryItem[]; +}) { + let pinned: string | null = options.initial ?? PERSONAL; + const onWorkspaceSwitched = vi.fn<(workspaceId: string) => void>(); + + const activeWorkspace: NonNullable = { + get: () => pinned, + set: async (workspaceId: string) => { + pinned = workspaceId; + }, + clear: async () => { + pinned = null; + }, + }; + + const app = express(); + app.use(express.json()); + const workspaceContext = { + current: async () => + options.currentContext + ? options.currentContext(pinned) + : pinned + ? contextFor(pinned) + : null, + }; + registerCollabContextRoutes(app, { + workspaceContext: + workspaceContext as unknown as RegisterCollabContextRoutesDeps['workspaceContext'], + activeWorkspace, + listWorkspaceDirectory: async () => + options.directory ?? [directoryItem(PERSONAL), directoryItem(TEAM)], + onWorkspaceSwitched, + }); + + server = http.createServer(app); + await new Promise((resolve) => server!.listen(0, resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('server did not bind'); + const base = `http://127.0.0.1:${address.port}`; + + return { + onWorkspaceSwitched, + pinnedWorkspace: () => pinned, + /** Proof the route has no backend-selection seam left to call. */ + hasBackendSelectionSeam: () => 'selectWorkspace' in workspaceContext, + async switchTo(workspaceId: string) { + const response = await fetch(`${base}/api/workspace/active`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId, + workspaceMemberId: `wm-${workspaceId}`, + }), + }); + return { status: response.status, body: (await response.json()) as Record }; + }, + }; +} + +describe('PUT /api/workspace/active announces a confirmed switch for cache warming', () => { + it('announces the new workspace exactly once when the switch is confirmed', async () => { + const api = await startSwitchServer({}); + + const result = await api.switchTo(TEAM); + + expect(result.status).toBe(200); + expect(api.pinnedWorkspace()).toBe(PERSONAL); + // The announcement is what lets the daemon refill the `catalog` and + // `members` digest faces during the idle beat after the switch, instead of + // making the first project load or agent run in the new workspace pay for + // it. + expect(api.onWorkspaceSwitched).toHaveBeenCalledTimes(1); + expect(api.onWorkspaceSwitched).toHaveBeenCalledWith(TEAM); + }); + + // Choosing a workspace is a local decision authorized by the membership + // directory, so there is no backend selection to reject and nothing to roll + // back. This replaces the old 502 `workspace_switch_rejected` contract: that + // gate made a purely local action fail on an account-scoped backend write, + // and that write could only ever name ONE workspace per account. + it('does not depend on a backend workspace selection at all', async () => { + const api = await startSwitchServer({}); + + const result = await api.switchTo(TEAM); + + expect(result.status).toBe(200); + expect(api.hasBackendSelectionSeam()).toBe(false); + }); + + it('keeps the switch when the context read cannot confirm it, answering from the directory', async () => { + // An unreadable context is an unconfirmed READ, never evidence that the + // user's choice was wrong. Reverting here used to undo a switch the + // directory had already authorized, and the user saw their click do nothing. + const api = await startSwitchServer({ currentContext: () => null }); + + const result = await api.switchTo(TEAM); + + expect(result.status).toBe(200); + expect(api.pinnedWorkspace()).toBe(PERSONAL); + expect(result.body.activeWorkspaceId).toBe(TEAM); + // Synthesized from the directory entry the route already validated, so the + // response still describes the workspace the user picked. + expect((result.body.context as { workspaceId?: string }).workspaceId).toBe(TEAM); + expect(api.onWorkspaceSwitched).toHaveBeenCalledWith(TEAM); + }); + + it('keeps the switch when the context read still describes the old workspace', async () => { + // A stale/lagging context read is likewise not a refusal. The pin is the + // truth; the web closes the billing plane out on its next context poll. + const api = await startSwitchServer({ currentContext: () => contextFor(PERSONAL) }); + + const result = await api.switchTo(TEAM); + + expect(result.status).toBe(200); + expect(api.pinnedWorkspace()).toBe(PERSONAL); + expect((result.body.context as { workspaceId?: string }).workspaceId).toBe(TEAM); + expect(api.onWorkspaceSwitched).toHaveBeenCalledWith(TEAM); + }); + + it('stays silent for a workspace the directory does not show', async () => { + const api = await startSwitchServer({}); + + const result = await api.switchTo('ws-not-mine'); + + expect(result.status).toBe(404); + expect(api.onWorkspaceSwitched).not.toHaveBeenCalled(); + }); +}); + +// The compatibility route authorizes the tab-local choice from its directory +// read and may ask `resolveExact()` for richer context. `resolveExact()` is +// deliberately read-only: unlike the legacy `current()` path, it cannot clear +// or re-pin daemon-global selection state. The route's directory read can be a +// 5-second cached success while the exact enrichment performs a fresh read and +// returns null; that disagreement must not resurrect active/current authority. +// +// These cases wire the production provider against the production cached +// directory fetcher so the two reads genuinely disagree instead of a stub +// pretending they do. +describe('PUT /api/workspace/active keeps exact enrichment tab-local', () => { + const B_PERSONAL_CONTEXT = { + userId: 'auth-user-1', + appUserId: 'app-user-1', + workspaceId: PERSONAL, + workspaceName: "Ma Shu's workspace", + workspaceType: 'personal', + workspaceMemberId: `wm-${PERSONAL}`, + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + billingState: 'active', + planId: 'personal-pro', + providerMode: 'platform_credits', + seatSummary: { seatLimit: 1, usedSeats: 1, availableSeats: 0, isSeatFull: true }, + }; + + function velaDirectoryBody(items: WorkspaceDirectoryItem[]) { + return { items }; + } + + async function startRealProviderServer() { + let pinned: string | null = PERSONAL; + let directoryCalls = 0; + const onWorkspaceSwitched = vi.fn<(workspaceId: string) => void>(); + + // Legacy pin store: neither the route nor resolveExact may write it. + const activeWorkspace: NonNullable = { + get: () => pinned, + set: async (workspaceId: string) => { + pinned = workspaceId; + }, + clear: async () => { + pinned = null; + }, + }; + + const jsonResponse = (status: number, body: unknown): Response => + ({ ok: status >= 200 && status < 300, status, json: async () => body }) as unknown as Response; + + // Call 1 (the route's read, which gets cached) still lists TEAM. + // Call 2+ (resolveExact's fresh read) no longer does — enrichment returns + // null without mutating the legacy pin. + const fetchImpl = (async (url: URL | string, init?: RequestInit) => { + const u = String(url); + const method = init?.method ?? 'GET'; + if (u.endsWith('/api/v1/workspaces') && method === 'GET') { + directoryCalls += 1; + return jsonResponse( + 200, + velaDirectoryBody( + directoryCalls === 1 + ? [directoryItem(PERSONAL), directoryItem(TEAM)] + : [directoryItem(PERSONAL)], + ), + ); + } + if (u.includes('/workspaces/current') && method === 'GET') { + // TEAM is gone, so B refuses the explicitly scoped read. resolveExact + // then performs the fresh directory lookup above and returns null. + const requested = (init?.headers as Record | undefined)?.[ + 'x-vela-workspace-id' + ]; + if (requested === TEAM) return jsonResponse(404, { error: 'workspace_member_required' }); + return jsonResponse(200, B_PERSONAL_CONTEXT); + } + throw new Error(`unexpected fetch ${method} ${u}`); + }) as unknown as typeof fetch; + + const session = { + profile: 'prod', + apiUrl: 'https://vela.example', + controlKey: 'ck-1', + user: null, + configMtimeMs: null, + }; + + const workspaceContext = createVelaWorkspaceContextProvider({ + fetch: fetchImpl, + readSession: () => session as never, + getActiveWorkspaceId: () => activeWorkspace.get(), + setLocalSelection: (workspaceId: string) => activeWorkspace.set(workspaceId), + clearLocalSelection: () => activeWorkspace.clear(), + }); + + // The production cached fetcher: the route's read is served from cache for + // 5s, which is precisely how it can disagree with the provider's fresh one. + const cachedDirectory = createCachedWorkspaceDirectoryFetcher({ + fetchDirectory: () => fetchVelaWorkspaceDirectory({ fetch: fetchImpl, readSession: () => session as never }), + identityKey: () => 'ck-1', + }); + + const app = express(); + app.use(express.json()); + registerCollabContextRoutes(app, { + workspaceContext, + activeWorkspace, + listWorkspaceDirectory: async () => (await cachedDirectory()).items, + onWorkspaceSwitched, + } as unknown as RegisterCollabContextRoutesDeps); + + server = http.createServer(app); + await new Promise((resolve) => server!.listen(0, resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('server did not bind'); + const base = `http://127.0.0.1:${address.port}`; + + return { + onWorkspaceSwitched, + pinnedWorkspace: () => pinned, + async switchTo(workspaceId: string) { + const response = await fetch(`${base}/api/workspace/active`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId, + workspaceMemberId: `wm-${workspaceId}`, + }), + }); + return { + status: response.status, + body: (await response.json()) as Record, + }; + }, + }; + } + + it('uses cached directory authorization without mutating the legacy pin', async () => { + const api = await startRealProviderServer(); + + const result = await api.switchTo(TEAM); + + // The compatibility route describes this tab's exact selection. The old + // daemon pin remains unrelated and unchanged. + expect(api.pinnedWorkspace()).toBe(PERSONAL); + expect(result.status).toBe(200); + expect(result.body.activeWorkspaceId).toBe(TEAM); + expect((result.body.context as { workspaceId?: string }).workspaceId).toBe(TEAM); + expect(api.onWorkspaceSwitched).toHaveBeenCalledOnce(); + expect(api.onWorkspaceSwitched).toHaveBeenCalledWith(TEAM); + }); + + it('does not restore active/current authority through exact enrichment', async () => { + const api = await startRealProviderServer(); + + await api.switchTo(TEAM); + + // The pin is merely legacy compatibility state and remains untouched. + expect(api.pinnedWorkspace()).toBe(PERSONAL); + }); +}); + +describe('PUT /api/workspace/active authorizes on a live membership', () => { + it('refuses a workspace the directory lists with a removed membership', async () => { + const api = await startSwitchServer({ + directory: [directoryItem(PERSONAL), { ...directoryItem(TEAM), memberStatus: 'removed' }], + }); + + const result = await api.switchTo(TEAM); + + expect(result.status).toBe(404); + expect(result.body.error).toBe('workspace_not_visible'); + expect(api.pinnedWorkspace()).toBe(PERSONAL); + expect(api.onWorkspaceSwitched).not.toHaveBeenCalled(); + }); + + it('refuses a workspace the directory lists as deleted', async () => { + const api = await startSwitchServer({ + directory: [directoryItem(PERSONAL), { ...directoryItem(TEAM), lifecycleState: 'deleted' }], + }); + + const result = await api.switchTo(TEAM); + + expect(result.status).toBe(404); + expect(result.body.error).toBe('workspace_not_visible'); + expect(api.onWorkspaceSwitched).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/daemon/tests/comment-attachments.test.ts b/apps/daemon/tests/comment-attachments.test.ts index 7cf158bc580..df549e97975 100644 --- a/apps/daemon/tests/comment-attachments.test.ts +++ b/apps/daemon/tests/comment-attachments.test.ts @@ -13,6 +13,7 @@ import { listMessages, listPreviewComments, openDatabase, + updatePreviewCommentAnchor, updatePreviewCommentStatus, upsertMessage, upsertPreviewComment, @@ -57,7 +58,68 @@ describe('preview comment persistence', () => { expect(critiqueTable?.name).toBe('critique_runs'); }); - it('upserts the latest comment by conversation, file, and element', () => { + it('adds the team-collab anchor columns on a fresh database', () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-comments-')); + const db = openDatabase(tempDir); + expect(tableColumnNames(db.prepare(`PRAGMA table_info(preview_comments)`).all())).toEqual( + expect.arrayContaining([ + 'anchor_state', + 'anchored_version', + 'author_member_id', + 'last_good_position_json', + ]), + ); + }); + + it('round-trips team-collab anchor creation metadata and defers resolved state', () => { + const db = seededDb(); + const saved = upsertPreviewComment(db, 'project-1', 'conversation-1', { + target: target({ elementId: 'hero-title', anchoredVersion: 7 }), + note: 'Anchor me', + authorMemberId: 'member-42', + }); + if (!saved) throw new Error('comment upsert failed'); + // Creation metadata persists... + expect(saved.anchoredVersion).toBe(7); + expect(saved.authorMemberId).toBe('member-42'); + // ...while the resolved state is left for the drift ladder to fill in. + expect(saved.anchorState).toBeUndefined(); + expect(saved.lastGoodPosition).toBeUndefined(); + // Survives the re-fetch (the list read path). + const [listed] = listPreviewComments(db, 'project-1', 'conversation-1'); + expect(listed?.anchoredVersion).toBe(7); + expect(listed?.authorMemberId).toBe('member-42'); + }); + + it('writes back resolved anchor state and keeps last-good position on a lost resolve', () => { + const db = seededDb(); + const saved = upsertPreviewComment(db, 'project-1', 'conversation-1', { + target: target({ elementId: 'hero-title' }), + note: 'Anchor me', + }); + if (!saved) throw new Error('comment upsert failed'); + + // Engine resolves it (anchored) and writes back a known-good position. + const good = { x: 5, y: 15, width: 120, height: 40 }; + const anchored = updatePreviewCommentAnchor(db, 'project-1', 'conversation-1', saved.id, { + anchorState: 'anchored', + lastGoodPosition: good, + anchoredVersion: 3, + }); + expect(anchored?.anchorState).toBe('anchored'); + expect(anchored?.lastGoodPosition).toEqual(good); + expect(anchored?.anchoredVersion).toBe(3); + + // Later the element vanishes → 'lost' with no new position. Last-good must survive. + const lost = updatePreviewCommentAnchor(db, 'project-1', 'conversation-1', saved.id, { + anchorState: 'lost', + }); + expect(lost?.anchorState).toBe('lost'); + expect(lost?.lastGoodPosition).toEqual(good); // COALESCE preserved it + expect(lost?.anchoredVersion).toBe(3); // COALESCE preserved it + }); + + it('creates multiple comments on the same element unless an id is provided', () => { const db = seededDb(); const first = upsertPreviewComment(db, 'project-1', 'conversation-1', { target: target({ elementId: 'hero-title', text: 'Old title' }), @@ -71,9 +133,27 @@ describe('preview comment persistence', () => { expect(first).not.toBeNull(); expect(second).not.toBeNull(); if (!first || !second) throw new Error('comment upsert failed'); - expect(second.id).toBe(first.id); - expect(second.note).toBe('Make it more specific'); - expect(second.text).toBe('New title'); + expect(second.id).not.toBe(first.id); + expect(listPreviewComments(db, 'project-1', 'conversation-1')).toHaveLength(2); + }); + + it('updates an existing comment when its id is provided', () => { + const db = seededDb(); + const first = upsertPreviewComment(db, 'project-1', 'conversation-1', { + target: target({ elementId: 'hero-title', text: 'Old title' }), + note: 'Shorten this', + }); + if (!first) throw new Error('comment upsert failed'); + + const second = upsertPreviewComment(db, 'project-1', 'conversation-1', { + id: first.id, + target: target({ elementId: 'hero-title', text: 'New title' }), + note: 'Make it more specific', + }); + + expect(second?.id).toBe(first.id); + expect(second?.note).toBe('Make it more specific'); + expect(second?.text).toBe('New title'); expect(listPreviewComments(db, 'project-1', 'conversation-1')).toHaveLength(1); }); @@ -112,6 +192,7 @@ describe('preview comment persistence', () => { ], }); const second = upsertPreviewComment(db, 'project-1', 'conversation-1', { + id: first?.id, target: target({ elementId: 'hero-title' }), note: 'Still match this reference', }); @@ -163,6 +244,7 @@ describe('preview comment persistence', () => { note: 'Fix slide two', }); const firstSlideEdit = upsertPreviewComment(db, 'project-1', 'conversation-1', { + id: firstSlide?.id, target: target({ elementId: 'hero-title', slideIndex: 0, text: 'Updated slide one title' }), note: 'Revise slide one', }); @@ -273,14 +355,26 @@ describe('preview comment persistence', () => { .prepare(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'preview_comments'`) .get() as { sql?: string } | undefined; expect(table?.sql).toMatch(/slide_key INTEGER NOT NULL DEFAULT -1/); - expect(table?.sql).toMatch(/UNIQUE\(project_id, conversation_id, file_path, element_id, slide_key\)/); + // Comments are keyed by id only; multiple notes can target the same element. + expect(table?.sql).not.toMatch(/UNIQUE\(project_id, conversation_id, file_path, element_id/); expect(listPreviewComments(db, 'project-1', 'conversation-1')[0]?.slideIndex).toBe(0); + // Anchor columns are backfilled even though the table was rebuilt for the + // slide-key migration (the ALTERs run after the rebuild). + expect(tableColumnNames(db.prepare(`PRAGMA table_info(preview_comments)`).all())).toEqual( + expect.arrayContaining([ + 'anchor_state', + 'anchored_version', + 'author_member_id', + 'last_good_position_json', + ]), + ); const secondSlide = upsertPreviewComment(db, 'project-1', 'conversation-1', { target: target({ elementId: 'hero-title', slideIndex: 1, text: 'Slide two title' }), note: 'Fix slide two', }); const firstSlideEdit = upsertPreviewComment(db, 'project-1', 'conversation-1', { + id: 'legacy-slide-0', target: target({ elementId: 'hero-title', slideIndex: 0, text: 'Updated slide one title' }), note: 'Revise slide one', }); diff --git a/apps/daemon/tests/comment-pin-seq.test.ts b/apps/daemon/tests/comment-pin-seq.test.ts new file mode 100644 index 00000000000..5b1a42b2968 --- /dev/null +++ b/apps/daemon/tests/comment-pin-seq.test.ts @@ -0,0 +1,302 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { CollabCloudComment } from '@open-design/contracts'; +import { + closeDatabase, + confirmPreviewCommentPinSeq, + getPreviewComment, + insertConversation, + insertProject, + mergeSyncedPreviewComment, + openDatabase, + reorderPreviewComment, + upsertPreviewComment, +} from '../src/db.js'; + +let tempDir: string | null = null; +let extraTempDirs: string[] = []; + +afterEach(() => { + closeDatabase(); + if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + for (const dir of extraTempDirs) fs.rmSync(dir, { recursive: true, force: true }); + extraTempDirs = []; +}); + +function target(patch: Record = {}) { + return { + filePath: 'index.html', + elementId: 'hero-title', + selector: '[data-od-id="hero-title"]', + label: 'h1.hero-title', + text: 'Current title', + position: { x: 10, y: 20, width: 300, height: 80 }, + htmlHint: '

', + ...patch, + }; +} + +/** Opens a fresh sqlite db under its own temp dir, seeded with one project + + * one conversation. Standalone (does not reuse the module-level `tempDir` + * var) so a test can hold two independent "devices" open in the same + * process by re-opening whichever one it needs next — see the concurrency + * test below for why that matters. */ +function newDeviceDb(label: string) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `od-pin-seq-${label}-`)); + const db = openDatabase(dir, { dataDir: dir }); + insertProject(db, { id: 'project-1', name: 'Project', createdAt: 1, updatedAt: 1 }); + insertConversation(db, { id: 'conversation-1', projectId: 'project-1', title: 'Chat', createdAt: 1, updatedAt: 1 }); + return { dir, db }; +} + +function seededDb() { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-pin-seq-')); + const db = openDatabase(tempDir, { dataDir: tempDir }); + insertProject(db, { id: 'project-1', name: 'Project', createdAt: 1, updatedAt: 1 }); + insertConversation(db, { id: 'conversation-1', projectId: 'project-1', title: 'Chat', createdAt: 1, updatedAt: 1 }); + return db; +} + +describe('pin_seq assignment (recvq5BVsolIxi)', () => { + it('assigns pin_seq starting at 1 and never rewrites it on a later edit', () => { + const db = seededDb(); + const first = upsertPreviewComment(db, 'project-1', 'conversation-1', { + target: target({ elementId: 'a' }), + note: 'First', + }); + const second = upsertPreviewComment(db, 'project-1', 'conversation-1', { + target: target({ elementId: 'b' }), + note: 'Second', + }); + expect(first?.pinSeq).toBe(1); + expect(second?.pinSeq).toBe(2); + + // Editing the FIRST comment (by id) must not touch its pin_seq, even + // though a naive "recompute MAX+1" would now see two existing rows. + const edited = upsertPreviewComment(db, 'project-1', 'conversation-1', { + id: first!.id, + target: target({ elementId: 'a' }), + note: 'First, edited', + }); + expect(edited?.pinSeq).toBe(1); + expect(edited?.note).toBe('First, edited'); + }); + + it('scopes pin_seq per (project, file) — a different file restarts at 1', () => { + const db = seededDb(); + const onIndex = upsertPreviewComment(db, 'project-1', 'conversation-1', { + target: target({ filePath: 'index.html', elementId: 'a' }), + note: 'Index comment', + }); + const onAbout = upsertPreviewComment(db, 'project-1', 'conversation-1', { + target: target({ filePath: 'about.html', elementId: 'a' }), + note: 'About comment', + }); + const secondOnIndex = upsertPreviewComment(db, 'project-1', 'conversation-1', { + target: target({ filePath: 'index.html', elementId: 'b' }), + note: 'Second index comment', + }); + expect(onIndex?.pinSeq).toBe(1); + expect(onAbout?.pinSeq).toBe(1); + expect(secondOnIndex?.pinSeq).toBe(2); + }); + + it('assigns a default sort_key so a fresh comment sorts to the front by default', () => { + const db = seededDb(); + const older = upsertPreviewComment(db, 'project-1', 'conversation-1', { + target: target({ elementId: 'a' }), + note: 'Older', + }); + const newer = upsertPreviewComment(db, 'project-1', 'conversation-1', { + target: target({ elementId: 'b' }), + note: 'Newer', + }); + expect(newer!.sortKey!).toBeGreaterThan(older!.sortKey!); + }); + + it('reorderPreviewComment rewrites only the dragged row\'s sort_key, never pin_seq', () => { + const db = seededDb(); + const older = upsertPreviewComment(db, 'project-1', 'conversation-1', { + target: target({ elementId: 'a' }), + note: 'Older', + }); + const newer = upsertPreviewComment(db, 'project-1', 'conversation-1', { + target: target({ elementId: 'b' }), + note: 'Newer', + }); + // Drag the older comment above the newer one. + const reordered = reorderPreviewComment( + db, + 'project-1', + 'conversation-1', + older!.id, + newer!.sortKey! + 1, + ); + expect(reordered?.sortKey).toBe(newer!.sortKey! + 1); + expect(reordered?.pinSeq).toBe(older!.pinSeq); // identity unchanged + // The untouched comment's own sort_key is unaffected. + const untouched = getPreviewComment(db, 'project-1', 'conversation-1', newer!.id); + expect(untouched?.sortKey).toBe(newer!.sortKey); + }); +}); + +describe('pin_seq cloud reconciliation (recvq5BVsolIxi)', () => { + it('confirmPreviewCommentPinSeq overwrites an unconfirmed row exactly once', () => { + const db = seededDb(); + const created = upsertPreviewComment( + db, + 'project-1', + 'conversation-1', + { target: target({ elementId: 'a' }), note: 'Shared project comment' }, + { pinPendingCloudConfirm: true }, + ); + // Provisional local guess while unconfirmed. + expect(created?.pinSeq).toBe(1); + + // The collab-cloud push resolves with the authoritative, globally + // serialized seq for this project's comment stream. + expect(confirmPreviewCommentPinSeq(db, 'project-1', created!.id, 501)).toBe(true); + const confirmed = getPreviewComment(db, 'project-1', 'conversation-1', created!.id); + expect(confirmed?.pinSeq).toBe(501); + + // A later push resolution (e.g. from an edit) must NOT rewrite it again. + expect(confirmPreviewCommentPinSeq(db, 'project-1', created!.id, 999)).toBe(false); + const stillConfirmed = getPreviewComment(db, 'project-1', 'conversation-1', created!.id); + expect(stillConfirmed?.pinSeq).toBe(501); + }); + + it('a non-team comment is already final (pin_seq_confirmed=1) — confirming it later is a no-op', () => { + const db = seededDb(); + const created = upsertPreviewComment(db, 'project-1', 'conversation-1', { + target: target({ elementId: 'a' }), + note: 'Personal workspace comment', + }); + // No `pinPendingCloudConfirm` (matches the off-team POST route path) → + // already final; nothing to reconcile. + expect(confirmPreviewCommentPinSeq(db, 'project-1', created!.id, 42)).toBe(false); + const unchanged = getPreviewComment(db, 'project-1', 'conversation-1', created!.id); + expect(unchanged?.pinSeq).toBe(1); + }); + + it('mergeSyncedPreviewComment adopts the wire seq directly for a brand-new pulled comment', () => { + const db = seededDb(); + const wire: CollabCloudComment = { + id: 'comment-from-peer', + projectId: 'project-1', + conversationId: 'conversation-on-peer', + memberId: 'member-peer', + seq: 777, + note: 'From a teammate', + filePath: 'index.html', + elementId: 'hero-title', + selector: '[data-od-id="hero-title"]', + label: 'h1.hero-title', + text: 'Hero', + htmlHint: '

', + position: { x: 0, y: 0, width: 10, height: 10 }, + status: 'open', + createdAt: 1000, + updatedAt: 1000, + }; + expect(mergeSyncedPreviewComment(db, 'project-1', 'conversation-1', wire)).toBe(true); + const merged = getPreviewComment(db, 'project-1', 'conversation-1', 'comment-from-peer'); + expect(merged?.pinSeq).toBe(777); + + // An in-place EDIT merge (same id, newer updatedAt) must not touch pin_seq. + expect( + mergeSyncedPreviewComment(db, 'project-1', 'conversation-1', { + ...wire, + seq: 900, + note: 'Edited note', + updatedAt: 2000, + }), + ).toBe(true); + const edited = getPreviewComment(db, 'project-1', 'conversation-1', 'comment-from-peer'); + expect(edited?.pinSeq).toBe(777); + expect(edited?.note).toBe('Edited note'); + }); +}); + +describe('pin_seq concurrency — two devices, no collision after confirmation (recvq5BVsolIxi)', () => { + it('two devices creating a comment in the same poll window get colliding provisional numbers but distinct confirmed ones', () => { + // Device A creates its own new comment on a team-shared project. It + // computes pin_seq from ITS OWN local rows only (there are none yet), so + // it lands on 1 — a provisional guess, unconfirmed. + const deviceA = newDeviceDb('a'); + extraTempDirs.push(deviceA.dir); + const commentA = upsertPreviewComment( + deviceA.db, + 'project-1', + 'conversation-1', + { target: target({ elementId: 'from-a' }), note: 'From device A' }, + { pinPendingCloudConfirm: true }, + ); + expect(commentA?.pinSeq).toBe(1); + + // Device B — a SEPARATE local sqlite file, standing in for a second + // daemon on a second machine — independently creates its OWN new comment + // within the same ~5s poll window, before either side's collab-cloud + // sync has caught up. Opening it closes device A's handle (the daemon + // process model is one active db at a time); the file on disk keeps + // device A's committed row. + const deviceB = newDeviceDb('b'); + extraTempDirs.push(deviceB.dir); + const commentB = upsertPreviewComment( + deviceB.db, + 'project-1', + 'conversation-1', + { target: target({ elementId: 'from-b' }), note: 'From device B' }, + { pinPendingCloudConfirm: true }, + ); + // THE RACE: computed independently, with no visibility into the other + // device's row, both land on the same provisional number. + expect(commentB?.pinSeq).toBe(1); + expect(commentB?.pinSeq).toBe(commentA?.pinSeq); + + // The collab-cloud push for device B's comment resolves first (order is + // arbitrary — the mechanism does not depend on which side wins the + // network race) with its globally-serialized, project-wide seq. + expect(confirmPreviewCommentPinSeq(deviceB.db, 'project-1', commentB!.id, 502)).toBe(true); + const confirmedB = getPreviewComment(deviceB.db, 'project-1', 'conversation-1', commentB!.id); + expect(confirmedB?.pinSeq).toBe(502); + + // Device A's own push resolves too, with a DIFFERENT cloud-assigned seq. + const deviceAAgain = openDatabase(deviceA.dir, { dataDir: deviceA.dir }); + expect(confirmPreviewCommentPinSeq(deviceAAgain, 'project-1', commentA!.id, 501)).toBe(true); + const confirmedA = getPreviewComment(deviceAAgain, 'project-1', 'conversation-1', commentA!.id); + expect(confirmedA?.pinSeq).toBe(501); + + // No collision once both sides are confirmed — the whole point of the fix. + expect(confirmedA?.pinSeq).not.toBe(confirmedB?.pinSeq); + + // And the system converges: when device A later PULLS device B's comment + // through the collab cloud, it adopts the exact same confirmed seq device + // B settled on (777 → wire seq path already covered above; here 502), + // landing distinct from its own comment's number too. + const pulled: CollabCloudComment = { + id: commentB!.id, + projectId: 'project-1', + conversationId: 'conversation-on-b', + memberId: 'member-b', + seq: 502, + note: commentB!.note, + filePath: commentB!.filePath, + elementId: commentB!.elementId, + selector: commentB!.selector, + label: commentB!.label, + text: commentB!.text, + htmlHint: commentB!.htmlHint, + position: commentB!.position, + status: commentB!.status, + createdAt: commentB!.createdAt, + updatedAt: commentB!.updatedAt, + }; + expect(mergeSyncedPreviewComment(deviceAAgain, 'project-1', 'conversation-1', pulled)).toBe(true); + const mergedOnA = getPreviewComment(deviceAAgain, 'project-1', 'conversation-1', commentB!.id); + expect(mergedOnA?.pinSeq).toBe(502); + expect(mergedOnA?.pinSeq).not.toBe(confirmedA?.pinSeq); + }); +}); diff --git a/apps/daemon/tests/connection-test.test.ts b/apps/daemon/tests/connection-test.test.ts index 30ba5b86413..c686bde1027 100644 --- a/apps/daemon/tests/connection-test.test.ts +++ b/apps/daemon/tests/connection-test.test.ts @@ -3818,11 +3818,8 @@ process.stdin.on('end', () => { }); it('surfaces OpenCode provider connectivity errors captured before timeout (#4999)', async () => { - const oldTimeout = process.env.OD_CONNECTION_TEST_AGENT_TIMEOUT_MS; - process.env.OD_CONNECTION_TEST_AGENT_TIMEOUT_MS = '1500'; - try { - await withFakeOpenCode( - ` + await withFakeOpenCode( + ` const args = process.argv.slice(2); if (args[0] === 'models') { console.log('ollama/qwen3.5-9b'); @@ -3832,28 +3829,21 @@ console.error('Cannot connect to API: Unable to connect. Is the computer able to console.log('UNRELATED_STDOUT_TAIL_MARKER'); setInterval(() => {}, 1000); `, - async () => { - const result = await testAgentConnection({ - agentId: 'opencode', - model: 'ollama/qwen3.5-9b', - }); + async () => { + const result = await testAgentConnection({ + agentId: 'opencode', + model: 'ollama/qwen3.5-9b', + }); - expect(result.ok).toBe(false); - expect(result.kind).toBe('upstream_unavailable'); - expect(result.detail).toContain('OpenCode reported a provider connectivity failure'); - expect(result.detail).toContain('Cannot connect to API'); - expect(result.detail).not.toContain('UNRELATED_STDOUT_TAIL_MARKER'); - expect(result.diagnostics?.phase).toBe('connection_smoke_test'); - expect(result.diagnostics?.stderrTail).toContain('Cannot connect to API'); - }, - ); - } finally { - if (oldTimeout === undefined) { - delete process.env.OD_CONNECTION_TEST_AGENT_TIMEOUT_MS; - } else { - process.env.OD_CONNECTION_TEST_AGENT_TIMEOUT_MS = oldTimeout; - } - } + expect(result.ok).toBe(false); + expect(result.kind).toBe('upstream_unavailable'); + expect(result.detail).toContain('OpenCode reported a provider connectivity failure'); + expect(result.detail).toContain('Cannot connect to API'); + expect(result.detail).not.toContain('UNRELATED_STDOUT_TAIL_MARKER'); + expect(result.diagnostics?.phase).toBe('connection_smoke_test'); + expect(result.diagnostics?.stderrTail).toContain('Cannot connect to API'); + }, + ); }); it.each([ @@ -3870,11 +3860,8 @@ setInterval(() => {}, 1000); ])( 'surfaces OpenCode provider connectivity errors from %s before timeout (#4999)', async (_name, stderrLine, expectedDetail) => { - const oldTimeout = process.env.OD_CONNECTION_TEST_AGENT_TIMEOUT_MS; - process.env.OD_CONNECTION_TEST_AGENT_TIMEOUT_MS = '1500'; - try { - await withFakeOpenCode( - ` + await withFakeOpenCode( + ` const args = process.argv.slice(2); if (args[0] === 'models') { console.log('ollama/qwen3.5-9b'); @@ -3883,25 +3870,18 @@ if (args[0] === 'models') { console.error(${JSON.stringify(stderrLine)}); setInterval(() => {}, 1000); `, - async () => { - const result = await testAgentConnection({ - agentId: 'opencode', - model: 'ollama/qwen3.5-9b', - }); + async () => { + const result = await testAgentConnection({ + agentId: 'opencode', + model: 'ollama/qwen3.5-9b', + }); - expect(result.ok).toBe(false); - expect(result.kind).toBe('upstream_unavailable'); - expect(result.detail).toContain(expectedDetail); - expect(result.diagnostics?.phase).toBe('connection_smoke_test'); - }, - ); - } finally { - if (oldTimeout === undefined) { - delete process.env.OD_CONNECTION_TEST_AGENT_TIMEOUT_MS; - } else { - process.env.OD_CONNECTION_TEST_AGENT_TIMEOUT_MS = oldTimeout; - } - } + expect(result.ok).toBe(false); + expect(result.kind).toBe('upstream_unavailable'); + expect(result.detail).toContain(expectedDetail); + expect(result.diagnostics?.phase).toBe('connection_smoke_test'); + }, + ); }, ); diff --git a/apps/daemon/tests/craft.test.ts b/apps/daemon/tests/craft.test.ts index f55bfccee75..7ce19b63271 100644 --- a/apps/daemon/tests/craft.test.ts +++ b/apps/daemon/tests/craft.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { loadCraftSections } from '../src/craft.js'; +import { loadCraftSections, resolveCraftRequirements } from '../src/craft.js'; let craftDir: string; @@ -69,3 +69,66 @@ describe('loadCraftSections', () => { expect(r.sections).toEqual(['typography']); }); }); + +describe('resolveCraftRequirements', () => { + it('adds typography for a deck project even when no skill requested craft', () => { + expect(resolveCraftRequirements({ + metadataKind: 'deck', + skillModes: [], + skillRequires: [], + designSystemApplies: [], + designSystemExemptions: [], + })).toEqual(['typography']); + }); + + it('adds typography for a freeform request whose user-authored text signals a deck', () => { + expect(resolveCraftRequirements({ + metadataKind: 'other', + skillModes: [], + freeformDeckSignal: true, + skillRequires: [], + designSystemApplies: [], + designSystemExemptions: [], + })).toEqual(['typography']); + }); + + it('does not add deck craft to unrelated freeform requests', () => { + expect(resolveCraftRequirements({ + metadataKind: 'other', + skillModes: [], + freeformDeckSignal: false, + skillRequires: ['color'], + designSystemApplies: [], + designSystemExemptions: [], + })).toEqual(['color']); + }); + + it('preserves declarations, dedupes them, and lets design systems exempt defaults', () => { + expect(resolveCraftRequirements({ + metadataKind: 'deck', + skillModes: ['deck'], + skillRequires: ['color', 'typography'], + designSystemApplies: ['color', 'anti-ai-slop'], + designSystemExemptions: [], + })).toEqual(['color', 'typography', 'anti-ai-slop']); + + expect(resolveCraftRequirements({ + metadataKind: 'deck', + skillModes: [], + skillRequires: [], + designSystemApplies: [], + designSystemExemptions: ['typography'], + })).toEqual([]); + }); + + it('keeps web-clone runs craft-free for source fidelity', () => { + expect(resolveCraftRequirements({ + isWebCloneRun: true, + metadataKind: 'deck', + skillModes: ['deck'], + skillRequires: ['typography'], + designSystemApplies: ['color'], + designSystemExemptions: [], + })).toEqual([]); + }); +}); diff --git a/apps/daemon/tests/db-workspace-resources.test.ts b/apps/daemon/tests/db-workspace-resources.test.ts new file mode 100644 index 00000000000..72c77b0578d --- /dev/null +++ b/apps/daemon/tests/db-workspace-resources.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + closeDatabase, + deleteWorkspaceResource, + deleteWorkspaceResourceByResourceId, + ensureWorkspaceResource, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + listTeamWorkspaceResourceWorkspaceIds, + listWorkspaceResources, + openDatabase, + updateWorkspaceResource, +} from '../src/db.js'; + +describe('workspace_resources persistence', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(path.join(os.tmpdir(), 'od-workspace-resources-')); + }); + + afterEach(() => { + closeDatabase(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + function seed() { + return openDatabase(tempDir, { dataDir: tempDir }); + } + + it('returns undefined for a resource that was never bound', () => { + const db = seed(); + expect(getWorkspaceResourceByResourceId(db, 'plugin', 'plugin-a')).toBeUndefined(); + expect(getWorkspaceResource(db, 'plugin', 'ws-1', 'plugin-a')).toBeUndefined(); + expect(listWorkspaceResources(db, 'plugin', 'ws-1')).toEqual([]); + }); + + it('binds a resource and round-trips every envelope field', () => { + const db = seed(); + const now = Date.now(); + const bound = ensureWorkspaceResource(db, 'plugin', 'ws-1', 'plugin-a', { + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: 'member-a', + updatedByWorkspaceMemberId: 'member-a', + resourceHubResourceId: 'hub-123', + syncState: 'local_only', + version: 3, + createdAt: now, + updatedAt: now, + }); + expect(bound).toMatchObject({ + resourceType: 'plugin', + resourceId: 'plugin-a', + workspaceId: 'ws-1', + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: 'member-a', + updatedByWorkspaceMemberId: 'member-a', + resourceHubResourceId: 'hub-123', + syncState: 'local_only', + version: 3, + }); + + const read = getWorkspaceResource(db, 'plugin', 'ws-1', 'plugin-a'); + expect(read).toMatchObject({ resourceId: 'plugin-a', workspaceId: 'ws-1' }); + + const readByResourceId = getWorkspaceResourceByResourceId(db, 'plugin', 'plugin-a'); + expect(readByResourceId).toMatchObject({ resourceId: 'plugin-a', workspaceId: 'ws-1' }); + }); + + // Mirrors `ensureWorkspaceProject`'s idempotency contract (db.ts doc + // comment above it): a resource already bound is returned as-is rather + // than bound a second time — the `(resource_type, resource_id)` primary + // key physically enforces this, so a naive second INSERT would throw + // instead of silently duplicating. + it('is idempotent across repeated ensure calls, even naming a different workspace', () => { + const db = seed(); + const first = ensureWorkspaceResource(db, 'plugin', 'ws-1', 'plugin-a', { + createdByWorkspaceMemberId: 'member-a', + }); + const second = ensureWorkspaceResource(db, 'plugin', 'ws-2', 'plugin-a', { + createdByWorkspaceMemberId: 'member-b', + }); + expect(first?.workspaceId).toBe('ws-1'); + expect(second?.workspaceId).toBe('ws-1'); + expect(listWorkspaceResources(db, 'plugin', 'ws-1')).toHaveLength(1); + expect(listWorkspaceResources(db, 'plugin', 'ws-2')).toHaveLength(0); + }); + + it('keeps two resource types with the same resource id independent', () => { + const db = seed(); + ensureWorkspaceResource(db, 'plugin', 'ws-1', 'shared-id', { visibility: 'personal' }); + ensureWorkspaceResource(db, 'skill', 'ws-2', 'shared-id', { visibility: 'team' }); + expect(getWorkspaceResourceByResourceId(db, 'plugin', 'shared-id')).toMatchObject({ workspaceId: 'ws-1' }); + expect(getWorkspaceResourceByResourceId(db, 'skill', 'shared-id')).toMatchObject({ workspaceId: 'ws-2' }); + }); + + it('updates the mutable fields without disturbing the binding key', () => { + const db = seed(); + ensureWorkspaceResource(db, 'plugin', 'ws-1', 'plugin-a', { + visibility: 'personal', + createdByWorkspaceMemberId: 'member-a', + }); + const updated = updateWorkspaceResource(db, 'plugin', 'ws-1', 'plugin-a', { + visibility: 'team', + resourceState: 'frozen', + updatedByWorkspaceMemberId: 'member-owner', + }); + expect(updated).toMatchObject({ + visibility: 'team', + resourceState: 'frozen', + updatedByWorkspaceMemberId: 'member-owner', + createdByWorkspaceMemberId: 'member-a', + }); + }); + + it('returns null updating a resource that has no binding row', () => { + const db = seed(); + expect(updateWorkspaceResource(db, 'plugin', 'ws-1', 'plugin-missing', { visibility: 'team' })).toBeNull(); + }); + + it('lists only the resources bound to the requested workspace, most recently updated first', () => { + const db = seed(); + ensureWorkspaceResource(db, 'plugin', 'ws-1', 'plugin-a', { updatedAt: 1_000 }); + ensureWorkspaceResource(db, 'plugin', 'ws-1', 'plugin-b', { updatedAt: 2_000 }); + ensureWorkspaceResource(db, 'plugin', 'ws-2', 'plugin-c', { updatedAt: 3_000 }); + const rows = listWorkspaceResources(db, 'plugin', 'ws-1'); + expect(rows.map((r) => r.resourceId)).toEqual(['plugin-b', 'plugin-a']); + }); + + it('lists each persisted live Team resource Workspace once for background reconciliation', () => { + const db = seed(); + ensureWorkspaceResource(db, 'plugin', 'ws-b', 'plugin-a', { + visibility: 'team', + resourceState: 'active', + }); + ensureWorkspaceResource(db, 'skill', 'ws-a', 'skill-a', { + visibility: 'team', + resourceState: 'active', + }); + ensureWorkspaceResource(db, 'design_system', 'ws-a', 'system-a', { + visibility: 'team', + resourceState: 'deleted', + }); + ensureWorkspaceResource(db, 'plugin', 'ws-personal', 'plugin-personal', { + visibility: 'personal', + resourceState: 'active', + }); + + expect(listTeamWorkspaceResourceWorkspaceIds(db)).toEqual(['ws-a', 'ws-b']); + }); + + it('deletes a binding scoped to the workspace it names', () => { + const db = seed(); + ensureWorkspaceResource(db, 'plugin', 'ws-1', 'plugin-a', {}); + deleteWorkspaceResource(db, 'plugin', 'ws-other', 'plugin-a'); + expect(getWorkspaceResourceByResourceId(db, 'plugin', 'plugin-a')).toBeDefined(); + deleteWorkspaceResource(db, 'plugin', 'ws-1', 'plugin-a'); + expect(getWorkspaceResourceByResourceId(db, 'plugin', 'plugin-a')).toBeUndefined(); + }); + + // Uninstall must call this regardless of which workspace the binding + // actually lives in — see installer.ts's uninstallPlugin, which has no + // caller-supplied workspaceId to scope a two-key delete with. + it('deletes a binding by resource id alone, regardless of its bound workspace', () => { + const db = seed(); + ensureWorkspaceResource(db, 'plugin', 'ws-1', 'plugin-a', {}); + deleteWorkspaceResourceByResourceId(db, 'plugin', 'plugin-a'); + expect(getWorkspaceResourceByResourceId(db, 'plugin', 'plugin-a')).toBeUndefined(); + }); +}); diff --git a/apps/daemon/tests/delete-cancels-active-runs.test.ts b/apps/daemon/tests/delete-cancels-active-runs.test.ts index a2e92c8cb26..a545bacd9c8 100644 --- a/apps/daemon/tests/delete-cancels-active-runs.test.ts +++ b/apps/daemon/tests/delete-cancels-active-runs.test.ts @@ -19,10 +19,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { closeDatabase, + countWorkspaceProjectRefs, deleteConversation, deleteProject as dbDeleteProject, + deleteWorkspaceProject, + ensureWorkspaceProject, getConversation, getProject, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + listWorkspaceProjects, + rebindWorkspaceProject, + updateWorkspaceProject, insertConversation, insertProject, listConversations, @@ -123,7 +131,20 @@ async function mountProjectApp( updateProject, dbDeleteProject, removeProjectDir: vi.fn(async () => {}), + stageProjectDirsForDelete: vi.fn(async () => {}), validateLinkedDirs: vi.fn(() => ({ dirs: [], error: null })), + // Real db-backed workspace-project lookups: the delete route's + // workspace mutation gate dereferences these, and a bare project with + // no workspace row must flow through the same legacy-allow path the + // production server takes. + ensureWorkspaceProject, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + listWorkspaceProjects, + updateWorkspaceProject, + rebindWorkspaceProject, + deleteWorkspaceProject, + countWorkspaceProjectRefs, }, projectFiles: { ensureProject: noop, diff --git a/apps/daemon/tests/design-systems-cli-help.test.ts b/apps/daemon/tests/design-systems-cli-help.test.ts index 43033d80e9e..8373a140816 100644 --- a/apps/daemon/tests/design-systems-cli-help.test.ts +++ b/apps/daemon/tests/design-systems-cli-help.test.ts @@ -28,5 +28,7 @@ describe('od design-systems help surface', () => { expect(DESIGN_SYSTEMS_USAGE).toContain('import-github'); expect(DESIGN_SYSTEMS_USAGE).toContain('import-shadcn'); expect(DESIGN_SYSTEMS_USAGE).toContain('rebuild-token-contract'); + expect(DESIGN_SYSTEMS_USAGE).toContain('--workspace '); + expect(DESIGN_SYSTEMS_USAGE).toContain('--workspace-member '); }); }); diff --git a/apps/daemon/tests/design-systems-workspace-cli.test.ts b/apps/daemon/tests/design-systems-workspace-cli.test.ts new file mode 100644 index 00000000000..4b944a19a8b --- /dev/null +++ b/apps/daemon/tests/design-systems-workspace-cli.test.ts @@ -0,0 +1,175 @@ +import { execFile } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import http from 'node:http'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve as pathResolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { afterEach, describe, expect, it } from 'vitest'; + +const execFileP = promisify(execFile); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DAEMON_ROOT = pathResolve(__dirname, '..'); +const REPO_ROOT = pathResolve(__dirname, '../../..'); +const CLI_SRC = pathResolve(__dirname, '../src/cli.ts'); +const TSX_CLI = pathResolve(REPO_ROOT, 'node_modules/tsx/dist/cli.mjs'); + +interface CapturedRequest { + method: string; + url: string; + headers: http.IncomingHttpHeaders; +} + +let server: http.Server | null = null; +let tempRoot = ''; +let baseUrl = ''; +let requests: CapturedRequest[] = []; + +afterEach(async () => { + if (server) { + const toClose = server; + server = null; + await new Promise((resolve, reject) => { + toClose.close((error) => (error ? reject(error) : resolve())); + }); + } + if (tempRoot) rmSync(tempRoot, { recursive: true, force: true }); + tempRoot = ''; + baseUrl = ''; + requests = []; +}); + +async function startStub(): Promise { + tempRoot = mkdtempSync(join(tmpdir(), 'od-design-systems-cli-')); + requests = []; + server = http.createServer((req, res) => { + requests.push({ + method: req.method ?? '', + url: req.url ?? '', + headers: req.headers, + }); + if (req.url?.endsWith('/archive')) { + res.statusCode = 200; + res.setHeader('content-type', 'application/zip'); + res.end(Buffer.from('zip')); + return; + } + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + if (req.url === '/api/design-systems') { + res.end(JSON.stringify({ designSystems: [] })); + return; + } + if (req.url?.endsWith('/token-contract/rebuild-jobs')) { + res.end(JSON.stringify({ job: { id: 'job-1' } })); + return; + } + res.end(JSON.stringify({ + designSystem: { id: 'user:brand', title: 'Brand' }, + })); + }); + await new Promise((resolve) => server!.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('stub address unavailable'); + baseUrl = `http://127.0.0.1:${address.port}`; +} + +async function runCli(args: string[]) { + const env = { ...process.env }; + delete env.NODE_OPTIONS; + try { + const { stdout, stderr } = await execFileP( + process.execPath, + [TSX_CLI, CLI_SRC, ...args], + { + cwd: DAEMON_ROOT, + env, + timeout: 15_000, + maxBuffer: 4 * 1024 * 1024, + }, + ); + return { code: 0, stdout, stderr }; + } catch (error) { + const failed = error as { + code?: number; + stdout?: string; + stderr?: string; + }; + return { + code: failed.code ?? 1, + stdout: failed.stdout ?? '', + stderr: failed.stderr ?? '', + }; + } +} + +describe('od design-systems exact workspace transport', () => { + it.each([ + ['list', ['list', '--json']], + ['show', ['show', 'user:brand', '--json']], + ['download', ['download', 'user:brand', '--out', 'brand.zip', '--json']], + ['import-local', ['import-local', '.', '--json']], + ['import-github', ['import-github', 'https://github.com/acme/brand', '--json']], + ['import-shadcn', ['import-shadcn', 'shadcn/ui/theme-zinc', '--json']], + ['rebuild-token-contract', ['rebuild-token-contract', 'user:brand', '--json']], + ['rename', ['rename', 'user:brand', '--title', 'Renamed Brand', '--json']], + ])('sends exact workspace headers for %s', async (_label, subcommand) => { + await startStub(); + const resolvedSubcommand = + _label === 'download' + ? subcommand.map((arg) => (arg === 'brand.zip' ? join(tempRoot, 'brand.zip') : arg)) + : subcommand; + const result = await runCli([ + 'design-systems', + ...resolvedSubcommand, + '--workspace', + 'workspace-a', + '--workspace-member', + 'member-a', + '--daemon-url', + baseUrl, + ]); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(requests).toHaveLength(1); + expect(requests[0]!.headers).toMatchObject({ + 'x-od-workspace-id': 'workspace-a', + 'x-od-workspace-member-id': 'member-a', + }); + }); + + it('rejects an incomplete workspace pair before making a request', async () => { + await startStub(); + const result = await runCli([ + 'design-systems', + 'show', + 'user:brand', + '--workspace', + 'workspace-a', + '--daemon-url', + baseUrl, + ]); + + expect(result.code).not.toBe(0); + expect(result.stderr).toContain('--workspace-member'); + expect(requests).toHaveLength(0); + }); + + it('keeps headerless legacy calls available when both flags are absent', async () => { + await startStub(); + const result = await runCli([ + 'design-systems', + 'show', + 'user:brand', + '--json', + '--daemon-url', + baseUrl, + ]); + + expect(result.code).toBe(0); + expect(requests).toHaveLength(1); + expect(requests[0]!.headers['x-od-workspace-id']).toBeUndefined(); + expect(requests[0]!.headers['x-od-workspace-member-id']).toBeUndefined(); + }); +}); diff --git a/apps/daemon/tests/design-systems/asset-sync.test.ts b/apps/daemon/tests/design-systems/asset-sync.test.ts new file mode 100644 index 00000000000..67533f50a06 --- /dev/null +++ b/apps/daemon/tests/design-systems/asset-sync.test.ts @@ -0,0 +1,331 @@ +// Logo/asset desync fix (spec 04 §9.3, recvqb1t4FrckM): the canonical design +// system directory (USER_DESIGN_SYSTEMS_DIR/) is the only place +// team-resource-share, the download archive, and the showcase ever read +// from — but until this fix, a real asset an agent regenerated (e.g. +// assets/logo.svg) only ever landed in the workspace project's editing +// mirror and never got copied back. These specs pin the two layers that fix +// it: +// +// 1. `syncUserDesignSystemAssetsFromFiles` (design-systems/index.ts) — the +// pure write: copies real bytes into canonical, drops the +// `.od-generated.json` fingerprint for the overwritten path so the +// generator never reclaims it, and flips `artifactMode` to +// 'agent-managed' the first time anything real syncs. +// 2. `createDesignSystemServerServices().syncUserDesignSystemAssetsFromWorkspace` +// — the orchestration: locates the design system's workspace project +// the same way `ensureUserDesignSystemWorkspaceProject` does, and +// copies whatever real files sit under that project's `assets/` +// directory. + +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + createUserDesignSystem, + LEGACY_DESIGN_SYSTEM_ARTIFACTS, + linkUserDesignSystemProject, + listDesignSystems, + listUserDesignSystemFiles, + readDesignSystem, + readDesignSystemPackageInfo, + readDesignSystemStaticFile, + readUserDesignSystemFile, + syncUserDesignSystemAssetsFromFiles, + updateUserDesignSystem, +} from '../../src/design-systems/index.js'; +import { createDesignSystemServerServices } from '../../src/design-systems/server-services.js'; +import { + closeDatabase, + getProject, + insertProject, + openDatabase, + updateProject, +} from '../../src/db.js'; +import { + isSafeId, + listFiles, + readProjectFile, + resolveProjectDir, + writeProjectFile, +} from '../../src/projects.js'; + +describe('syncUserDesignSystemAssetsFromFiles', () => { + const DIR_ID = 'acme-brand'; + const DS_ID = `user:${DIR_ID}`; + let root = ''; + + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'od-ds-asset-sync-')); + }); + + afterEach(async () => { + if (root) await rm(root, { recursive: true, force: true }); + }); + + it('copies a real asset into canonical, drops its generated fingerprint, and switches artifactMode to agent-managed', async () => { + const created = await createUserDesignSystem(root, { + title: 'Acme Brand', + body: '# Acme Brand\n\n> Category: SaaS\n> Surface: web\n\nBrand body copy.', + }); + expect(created.id).toBe(DS_ID); + + const dir = path.join(root, DIR_ID); + const placeholderLogo = await readFile(path.join(dir, 'assets', 'logo.svg'), 'utf8'); + expect(placeholderLogo).toContain('; + expect(manifestBefore['assets/logo.svg']).toBeTruthy(); + + const metaBefore = JSON.parse(await readFile(path.join(dir, 'metadata.json'), 'utf8')) as { + artifactMode?: string; + }; + expect(metaBefore.artifactMode ?? 'generated').not.toBe('agent-managed'); + + const realLogo = Buffer.from(''); + const result = await syncUserDesignSystemAssetsFromFiles(root, DS_ID, [ + { path: 'assets/logo.svg', content: realLogo }, + ]); + expect(result.synced).toEqual(['assets/logo.svg']); + + const syncedContent = await readFile(path.join(dir, 'assets', 'logo.svg')); + expect(syncedContent.equals(realLogo)).toBe(true); + + const manifestAfter = JSON.parse( + await readFile(path.join(dir, '.od-generated.json'), 'utf8'), + ) as Record; + expect(manifestAfter['assets/logo.svg']).toBeUndefined(); + + const metaAfter = JSON.parse(await readFile(path.join(dir, 'metadata.json'), 'utf8')) as { + artifactMode?: string; + }; + expect(metaAfter.artifactMode).toBe('agent-managed'); + }); + + it('keeps a synced asset intact even if a later update forces artifactMode back to generated (the fingerprint protects it, not just the artifactMode gate)', async () => { + await createUserDesignSystem(root, { + title: 'Acme Brand', + body: '# Acme Brand\n\nBrand body copy.', + }); + const dir = path.join(root, DIR_ID); + const realLogo = Buffer.from(''); + await syncUserDesignSystemAssetsFromFiles(root, DS_ID, [ + { path: 'assets/logo.svg', content: realLogo }, + ]); + + // Mirrors exactly what a PATCH /api/design-systems/:id body-sync call + // does — updateUserDesignSystem calls writeGeneratedDesignSystemFiles + // internally whenever the resolved artifactMode isn't 'agent-managed'. + // Forcing it back to 'generated' here proves the manifest fingerprint, + // not the artifactMode short-circuit, is what protects the real asset. + await updateUserDesignSystem(root, DS_ID, { + body: '# Acme Brand\n\nUpdated brand body copy.', + artifactMode: 'generated', + }); + + const afterRegen = await readFile(path.join(dir, 'assets', 'logo.svg')); + expect(afterRegen.equals(realLogo)).toBe(true); + }); + + it('ignores files outside assets/ and no-ops when nothing under assets/ is given', async () => { + await createUserDesignSystem(root, { title: 'Acme Brand', body: '# Acme Brand\n\nBrand body copy.' }); + const result = await syncUserDesignSystemAssetsFromFiles(root, DS_ID, [ + { path: 'DESIGN.md', content: Buffer.from('should never land here') }, + ]); + expect(result.synced).toEqual([]); + + const dir = path.join(root, DIR_ID); + const meta = JSON.parse(await readFile(path.join(dir, 'metadata.json'), 'utf8')) as { + artifactMode?: string; + }; + expect(meta.artifactMode ?? 'generated').not.toBe('agent-managed'); + const designMd = await readFile(path.join(dir, 'DESIGN.md'), 'utf8'); + expect(designMd).not.toBe('should never land here'); + }); + + it('no-ops for an unknown design system id', async () => { + const result = await syncUserDesignSystemAssetsFromFiles(root, 'user:does-not-exist', [ + { path: 'assets/logo.svg', content: Buffer.from('x') }, + ]); + expect(result.synced).toEqual([]); + }); +}); + +describe('createDesignSystemServerServices().syncUserDesignSystemAssetsFromWorkspace', () => { + let workRoot = ''; + let userDesignSystemsDir = ''; + let projectsDir = ''; + let db: ReturnType; + let services: ReturnType; + + beforeEach(async () => { + workRoot = await mkdtemp(path.join(tmpdir(), 'od-ds-workspace-sync-')); + userDesignSystemsDir = path.join(workRoot, 'design-systems'); + projectsDir = path.join(workRoot, 'projects'); + await mkdir(userDesignSystemsDir, { recursive: true }); + await mkdir(projectsDir, { recursive: true }); + db = openDatabase(workRoot, { dataDir: workRoot }); + services = createDesignSystemServerServices({ + roots: { SKILL_ROOTS: [], DESIGN_TEMPLATE_ROOTS: [], ALL_SKILL_LIKE_ROOTS: [] }, + paths: { + PROJECTS_DIR: projectsDir, + DESIGN_SYSTEMS_DIR: path.join(workRoot, 'built-in-design-systems'), + USER_DESIGN_SYSTEMS_DIR: userDesignSystemsDir, + }, + skills: { + listSkills: async () => [], + findSkillById: () => undefined, + }, + designSystems: { + listDesignSystems, + readDesignSystem, + readDesignSystemPackageInfo, + readDesignSystemStaticFile, + listUserDesignSystemFiles, + readUserDesignSystemFile, + linkUserDesignSystemProject, + syncUserDesignSystemAssetsFromFiles, + LEGACY_DESIGN_SYSTEM_ARTIFACTS, + // The real design-systems/index.ts types (readonly `as const` array, + // `DesignSystemSource` literal union) are strictly narrower than the + // DI factory's own locally declared (looser) option/array shapes — + // real callers (server.ts) never trip this because their broader + // import graph resolves the module's types under a single + // resolution mode; this isolated test file resolves it under both + // an import- and require-flavored instantiation, so TS sees two + // nominally distinct `DesignSystemListOptions`. Widen with a cast + // rather than fighting the module-resolution quirk here. + } as unknown as Parameters[0]['designSystems'], + projects: { + getProject, + insertProject, + updateProject, + readProjectFile, + writeProjectFile, + listFiles, + resolveProjectDir, + isSafeId, + }, + }); + }); + + afterEach(async () => { + closeDatabase(); + if (workRoot) await rm(workRoot, { recursive: true, force: true }); + }); + + it('locates the workspace project via the ds- naming convention and copies its real assets into canonical', async () => { + const created = await createUserDesignSystem(userDesignSystemsDir, { + title: 'Acme Brand', + body: '# Acme Brand\n\nBrand body copy.', + }); + const dirId = created.id.replace(/^user:/, ''); + const projectId = `ds-${dirId}`; + insertProject(db, { + id: projectId, + name: 'Acme Brand', + designSystemId: created.id, + metadata: { importedFrom: 'design-system' }, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + const projectAssetsDir = path.join(projectsDir, projectId, 'assets'); + await mkdir(projectAssetsDir, { recursive: true }); + const realLogo = Buffer.from(''); + await writeFile(path.join(projectAssetsDir, 'logo.svg'), realLogo); + // A non-asset project file must not get pulled into canonical by this sync. + await writeFile(path.join(projectsDir, projectId, 'DESIGN.md'), '# stale copy', 'utf8'); + + const outcome = await services.syncUserDesignSystemAssetsFromWorkspace(db, created.id); + expect(outcome.ok).toBe(true); + if (outcome.ok) expect(outcome.synced).toEqual(['assets/logo.svg']); + + const canonicalLogo = await readFile(path.join(userDesignSystemsDir, dirId, 'assets', 'logo.svg')); + expect(canonicalLogo.equals(realLogo)).toBe(true); + + const meta = JSON.parse( + await readFile(path.join(userDesignSystemsDir, dirId, 'metadata.json'), 'utf8'), + ) as { artifactMode?: string }; + expect(meta.artifactMode).toBe('agent-managed'); + }); + + it('prepares the canonical share directory from workspace assets before publishing', async () => { + const created = await createUserDesignSystem(userDesignSystemsDir, { + title: 'Share Ready Brand', + body: '# Share Ready Brand\n\nBrand body copy.', + }); + const dirId = created.id.replace(/^user:/, ''); + const projectId = `ds-${dirId}`; + insertProject(db, { + id: projectId, + name: 'Share Ready Brand', + designSystemId: created.id, + metadata: { importedFrom: 'design-system' }, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + const projectAssetsDir = path.join(projectsDir, projectId, 'assets'); + await mkdir(projectAssetsDir, { recursive: true }); + const latestWorkspaceLogo = Buffer.from(''); + await writeFile(path.join(projectAssetsDir, 'logo.svg'), latestWorkspaceLogo); + + const resolveShareDir = ( + services as typeof services & { + resolveUserDesignSystemShareDirectory( + dbHandle: typeof db, + id: string, + ): Promise; + } + ).resolveUserDesignSystemShareDirectory; + const shareDir = await resolveShareDir(db, created.id); + + expect(shareDir).toBe(path.join(userDesignSystemsDir, dirId)); + const publishedLogo = await readFile(path.join(shareDir, 'assets', 'logo.svg')); + expect(publishedLogo.equals(latestWorkspaceLogo)).toBe(true); + }); + + it('fails closed instead of returning a stale canonical share directory when workspace sync is unavailable', async () => { + const created = await createUserDesignSystem(userDesignSystemsDir, { + title: 'Unbound Brand', + body: '# Unbound Brand\n\nBrand body copy.', + }); + const dirId = created.id.replace(/^user:/, ''); + const staleCanonicalLogo = await readFile( + path.join(userDesignSystemsDir, dirId, 'assets', 'logo.svg'), + ); + + const resolveShareDir = ( + services as typeof services & { + resolveUserDesignSystemShareDirectory( + dbHandle: typeof db, + id: string, + ): Promise; + } + ).resolveUserDesignSystemShareDirectory; + + await expect(resolveShareDir(db, created.id)).rejects.toThrow( + 'design_system_share_asset_sync_failed:no-workspace-project', + ); + await expect( + readFile(path.join(userDesignSystemsDir, dirId, 'assets', 'logo.svg')), + ).resolves.toEqual(staleCanonicalLogo); + }); + + it('reports no-workspace-project when the design system has no bound project row yet', async () => { + const created = await createUserDesignSystem(userDesignSystemsDir, { + title: 'No Workspace Yet', + body: '# No Workspace Yet\n\nBody copy.', + }); + + const outcome = await services.syncUserDesignSystemAssetsFromWorkspace(db, created.id); + expect(outcome).toEqual({ ok: false, reason: 'no-workspace-project' }); + }); + + it('reports not-found for an unknown design system id', async () => { + const outcome = await services.syncUserDesignSystemAssetsFromWorkspace(db, 'user:missing'); + expect(outcome).toEqual({ ok: false, reason: 'not-found' }); + }); +}); diff --git a/apps/daemon/tests/design-systems/design-system-family-workspace-authority.test.ts b/apps/daemon/tests/design-systems/design-system-family-workspace-authority.test.ts new file mode 100644 index 00000000000..13b29c4c19c --- /dev/null +++ b/apps/daemon/tests/design-systems/design-system-family-workspace-authority.test.ts @@ -0,0 +1,313 @@ +import express from 'express'; +import type http from 'node:http'; +import { mkdtempSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + closeDatabase, + ensureWorkspaceResource, + getWorkspaceResource, + getWorkspaceResourceByResourceId, + openDatabase, +} from '../../src/db.js'; +import { workspaceContextFromDirectoryItem } from '../../src/collab/vela-workspace-context.js'; +import { registerDesignSystemRoutes } from '../../src/routes/design-systems.js'; + +const DESIGN_SYSTEM_ID = 'user:workspace-a-system'; +const WORKSPACE_ID = 'workspace-a'; +const MEMBER_ID = 'member-a'; + +let server: http.Server | null = null; +let tempDir: string | null = null; + +afterEach(async () => { + if (server) { + await new Promise((resolve) => server?.close(() => resolve())); + server = null; + } + closeDatabase(); + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } +}); + +function listen(app: express.Express): Promise { + return new Promise((resolve) => { + server = app.listen(0, '127.0.0.1', () => { + const address = server?.address() as { port: number }; + resolve(`http://127.0.0.1:${address.port}`); + }); + }); +} + +function paths(root: string) { + return { + CRAFT_DIR: path.join(root, 'craft'), + USER_DESIGN_SYSTEMS_DIR: path.join(root, 'design-systems'), + } as never; +} + +function exactHeaders(): Record { + return { + 'x-od-workspace-id': WORKSPACE_ID, + 'x-od-workspace-member-id': MEMBER_ID, + }; +} + +async function startAuthorityServer() { + tempDir = mkdtempSync(path.join(os.tmpdir(), 'od-ds-family-authority-')); + const db = openDatabase(tempDir, { dataDir: tempDir }); + ensureWorkspaceResource( + db, + 'design_system', + WORKSPACE_ID, + DESIGN_SYSTEM_ID, + { + visibility: 'team', + resourceState: 'active', + createdByWorkspaceMemberId: MEMBER_ID, + }, + ); + const calls = { + archive: vi.fn(async () => ({ + buffer: Buffer.from('zip'), + baseName: 'workspace-a', + title: 'Workspace A', + })), + files: vi.fn(async () => []), + revisions: vi.fn(async () => []), + static: vi.fn(async (_id: string, filePath: string) => ({ + bytes: Buffer.from( + filePath === 'system/kit.html' + ? '' + : 'body', + ), + contentType: filePath === 'system/kit.html' ? 'text/html' : 'text/plain', + updatedAt: 'Wed, 30 Jul 2026 00:00:00 GMT', + })), + update: vi.fn(async () => null), + }; + const jobs = new Map(); + const verifyWorkspaceRequestAuthority = vi.fn(async (req: any) => { + const workspaceId = req.get('x-od-workspace-id')?.trim() ?? ''; + const workspaceMemberId = req.get('x-od-workspace-member-id')?.trim() ?? ''; + if (!workspaceId || !workspaceMemberId) { + return { + ok: false as const, + status: 400 as const, + code: 'WORKSPACE_CONTEXT_REQUIRED', + message: 'exact workspace identity required', + }; + } + if (workspaceId !== WORKSPACE_ID || workspaceMemberId !== MEMBER_ID) { + return { + ok: false as const, + status: 403 as const, + code: 'WORKSPACE_ACCESS_DENIED', + message: 'workspace identity mismatch', + }; + } + return { + ok: true as const, + context: workspaceContextFromDirectoryItem({ + workspaceId, + workspaceName: 'Workspace A', + workspaceType: 'team', + workspaceMemberId, + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + }), + }; + }); + const summary = { + id: DESIGN_SYSTEM_ID, + title: 'Workspace A', + category: 'Custom', + summary: 'Workspace scoped', + swatches: [], + surface: 'web' as const, + body: '# Workspace A', + source: 'user' as const, + status: 'draft' as const, + isEditable: true, + }; + const app = express(); + app.use(express.json()); + registerDesignSystemRoutes(app, { + db, + paths: paths(tempDir), + projectFiles: {} as never, + projectStore: {} as never, + verifyWorkspaceRequestAuthority, + workspaceResources: { + getWorkspaceResource, + getWorkspaceResourceByResourceId, + }, + designSystems: { + buildUserDesignSystemArchive: calls.archive, + canMutateUserDesignSystem: async () => true, + createUserDesignSystem: async () => summary, + deleteUserDesignSystem: async () => true, + ensureUserDesignSystemWorkspaceProject: async () => ({ + project: { id: 'project-a' }, + files: [], + }) as never, + listAllDesignSystems: async () => [summary], + listUserDesignSystemFiles: calls.files, + listUserDesignSystemRevisions: calls.revisions, + prepareDesignTokenContractRebuild: async () => ({ + decision: { available: false }, + }) as never, + readAvailableDesignSystem: async () => summary.body, + readAvailableDesignSystemPackageInfo: async () => null, + readAvailableDesignSystemStaticFile: calls.static, + readDesignSystemWorkspaceTextFile: async () => null, + readUserDesignSystemFile: async () => ({ + path: 'DESIGN.md', + body: summary.body, + }) as never, + renderDesignSystemPreview: () => 'preview', + renderDesignSystemShowcase: () => 'showcase', + syncUserDesignSystemAssetsFromWorkspace: async () => ({ + ok: true, + synced: [], + }), + unshareTeamDesignSystemIfShared: async () => false, + updateUserDesignSystem: calls.update, + updateUserDesignSystemRevisionStatus: async () => null, + }, + generationJobs: { + get: (id) => jobs.get(id) ?? null, + rebuildTokenContract: () => ({}) as never, + revise: () => ({}) as never, + start: () => { + const job = { + id: 'job-a', + status: 'queued', + progress: 0, + steps: [], + createdAt: '2026-07-30T00:00:00.000Z', + updatedAt: '2026-07-30T00:00:00.000Z', + }; + jobs.set(job.id, job); + return job as never; + }, + }, + }); + return { + baseUrl: await listen(app), + calls, + verifyWorkspaceRequestAuthority, + }; +} + +describe('Design System route family exact Workspace authority', () => { + it('rejects every bound read before touching its backing store', async () => { + const { baseUrl, calls } = await startAuthorityServer(); + const paths = [ + `/api/design-systems/${encodeURIComponent(DESIGN_SYSTEM_ID)}`, + `/api/design-systems/${encodeURIComponent(DESIGN_SYSTEM_ID)}/revisions`, + `/api/design-systems/${encodeURIComponent(DESIGN_SYSTEM_ID)}/preview`, + `/api/design-systems/${encodeURIComponent(DESIGN_SYSTEM_ID)}/showcase`, + `/api/design-systems/${encodeURIComponent(DESIGN_SYSTEM_ID)}/static?path=tokens.css`, + `/api/design-systems/${encodeURIComponent(DESIGN_SYSTEM_ID)}/files`, + `/api/design-systems/${encodeURIComponent(DESIGN_SYSTEM_ID)}/file?path=DESIGN.md`, + `/api/design-systems/${encodeURIComponent(DESIGN_SYSTEM_ID)}/archive`, + ]; + + for (const requestPath of paths) { + const response = await fetch(`${baseUrl}${requestPath}`); + expect(response.status, requestPath).toBe(400); + } + + expect(calls.archive).not.toHaveBeenCalled(); + expect(calls.files).not.toHaveBeenCalled(); + expect(calls.revisions).not.toHaveBeenCalled(); + expect(calls.static).not.toHaveBeenCalled(); + }); + + it('accepts exact query scope for browser-owned showcase and static requests', async () => { + const { baseUrl, calls, verifyWorkspaceRequestAuthority } = + await startAuthorityServer(); + const scope = + `workspaceId=${WORKSPACE_ID}&workspaceMemberId=${MEMBER_ID}`; + const showcase = await fetch( + `${baseUrl}/api/design-systems/${encodeURIComponent(DESIGN_SYSTEM_ID)}/showcase?${scope}`, + ); + const staticFile = await fetch( + `${baseUrl}/api/design-systems/${encodeURIComponent(DESIGN_SYSTEM_ID)}/static?path=tokens.css&${scope}`, + ); + + expect(showcase.status).toBe(200); + expect(staticFile.status).toBe(200); + expect(await showcase.text()).toContain( + `path=assets%2Flogo.png&workspaceId=${WORKSPACE_ID}&workspaceMemberId=${MEMBER_ID}`, + ); + expect(calls.static).toHaveBeenCalledWith( + DESIGN_SYSTEM_ID, + 'system/kit.html', + { workspaceId: WORKSPACE_ID }, + ); + expect(calls.static).toHaveBeenCalledWith( + DESIGN_SYSTEM_ID, + 'tokens.css', + { workspaceId: WORKSPACE_ID }, + ); + expect(verifyWorkspaceRequestAuthority).toHaveBeenCalledTimes(2); + }); + + it('rejects bound mutations before their first side effect', async () => { + const { baseUrl, calls } = await startAuthorityServer(); + const requests = [ + { method: 'POST', path: 'workspace', body: {} }, + { method: 'POST', path: 'revision-jobs', body: { feedback: 'change' } }, + { method: 'POST', path: 'token-contract/rebuild-jobs', body: {} }, + { method: 'PATCH', path: 'revisions/r1', body: { status: 'accepted' } }, + { method: 'PATCH', path: '', body: { title: 'Changed' } }, + { method: 'POST', path: 'sync-assets', body: {} }, + { method: 'DELETE', path: '', body: undefined }, + ]; + + for (const request of requests) { + const suffix = request.path ? `/${request.path}` : ''; + const response = await fetch( + `${baseUrl}/api/design-systems/${encodeURIComponent(DESIGN_SYSTEM_ID)}${suffix}`, + { + method: request.method, + headers: request.body ? { 'content-type': 'application/json' } : {}, + ...(request.body ? { body: JSON.stringify(request.body) } : {}), + }, + ); + expect(response.status, `${request.method} ${suffix}`).toBe(400); + } + + expect(calls.update).not.toHaveBeenCalled(); + }); + + it('pins generation job reads to the exact creating Workspace/member pair', async () => { + const { baseUrl } = await startAuthorityServer(); + const started = await fetch(`${baseUrl}/api/design-systems/generation-jobs`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...exactHeaders(), + }, + body: JSON.stringify({ title: 'Workspace A' }), + }); + expect(started.status).toBe(202); + + const missing = await fetch( + `${baseUrl}/api/design-systems/generation-jobs/job-a`, + ); + expect(missing.status).toBe(403); + + const exact = await fetch( + `${baseUrl}/api/design-systems/generation-jobs/job-a`, + { headers: exactHeaders() }, + ); + expect(exact.status).toBe(200); + }); +}); diff --git a/apps/daemon/tests/design-systems/explicit-workspace-scope-routes.test.ts b/apps/daemon/tests/design-systems/explicit-workspace-scope-routes.test.ts new file mode 100644 index 00000000000..30b3f85f3b9 --- /dev/null +++ b/apps/daemon/tests/design-systems/explicit-workspace-scope-routes.test.ts @@ -0,0 +1,280 @@ +import express from 'express'; +import type http from 'node:http'; +import { mkdtempSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { closeDatabase, openDatabase } from '../../src/db.js'; +import { registerDesignSystemRoutes } from '../../src/routes/design-systems.js'; +import { registerStaticResourceRoutes } from '../../src/routes/static-resource.js'; +import type { DesignSystemSummary } from '../../src/design-systems/index.js'; + +let server: http.Server | null = null; +let tempDir: string | null = null; + +afterEach(async () => { + if (server) { + await new Promise((resolve) => server?.close(() => resolve())); + server = null; + } + closeDatabase(); + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } +}); + +function listen(app: express.Express): Promise { + return new Promise((resolve) => { + server = app.listen(0, '127.0.0.1', () => { + const address = server?.address() as { port: number }; + resolve(`http://127.0.0.1:${address.port}`); + }); + }); +} + +function workspaceHeaders(): Record { + return { + 'x-od-workspace-id': 'workspace-a', + 'x-od-workspace-member-id': 'member-a', + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'owner', + 'x-od-workspace-member-status': 'active', + 'x-od-workspace-lifecycle-state': 'active', + }; +} + +function scopeError(status: 400 | 403 | 503) { + const code = status === 400 + ? 'WORKSPACE_CONTEXT_REQUIRED' + : status === 403 + ? 'WORKSPACE_ACCESS_DENIED' + : 'WORKSPACE_AUTHORITY_UNAVAILABLE'; + return Object.assign(new Error(code), { + status, + code, + ...(status === 503 ? { retryable: true } : {}), + }); +} + +const summary: DesignSystemSummary = { + id: 'user:workspace-a-system', + title: 'Workspace A', + category: 'Custom', + summary: 'Workspace-scoped test system.', + swatches: [], + surface: 'web', + body: '# Workspace A', + source: 'user', + status: 'draft', + isEditable: true, +}; + +function commonPaths(root: string) { + return { + ARTIFACTS_DIR: path.join(root, 'artifacts'), + BRANDS_DIR: path.join(root, 'brands'), + BUNDLED_PETS_DIR: path.join(root, 'pets'), + CRAFT_DIR: path.join(root, 'craft'), + DESIGN_SYSTEMS_DIR: path.join(root, 'design-systems'), + DESIGN_TEMPLATES_DIR: path.join(root, 'design-templates'), + LIBRARY_DIR: path.join(root, 'library'), + OD_BIN: path.join(root, 'od'), + PROJECT_ROOT: root, + PROJECTS_DIR: path.join(root, 'projects'), + PROMPT_TEMPLATES_DIR: path.join(root, 'prompt-templates'), + RUNTIME_DATA_DIR: path.join(root, 'data'), + RUNTIME_DATA_DIR_CANONICAL: path.join(root, 'data'), + SKILLS_DIR: path.join(root, 'skills'), + USER_DESIGN_SYSTEMS_DIR: path.join(root, 'user-design-systems'), + USER_DESIGN_TEMPLATES_DIR: path.join(root, 'user-design-templates'), + USER_SKILLS_DIR: path.join(root, 'user-skills'), + }; +} + +async function startListRoute(input: { + resolveWorkspaceScope: (req?: express.Request) => Promise; + listAllDesignSystems: any; +}) { + tempDir = mkdtempSync(path.join(os.tmpdir(), 'od-ds-explicit-list-')); + const app = express(); + registerStaticResourceRoutes(app, { + db: {} as never, + http: { + createSseResponse: () => undefined, + getPublicBaseUrl: () => '', + isLocalSameOrigin: () => true, + requireLocalDaemonRequest: (_req: unknown, _res: unknown, next: () => void) => next(), + resolvedPortRef: { current: 0 }, + sendApiError: () => undefined, + sendLiveArtifactRouteError: () => undefined, + sendMulterError: () => undefined, + }, + paths: commonPaths(tempDir), + resources: { + listAllDesignSystems: input.listAllDesignSystems, + resolveWorkspaceScope: input.resolveWorkspaceScope, + listAllSkills: async () => [], + listAllDesignTemplates: async () => [], + listAllSkillLikeEntries: async () => [], + mimeFor: () => 'application/octet-stream', + }, + }); + return listen(app); +} + +function registerCreateRoute( + app: express.Express, + createUserDesignSystem: ( + root: string, + input: unknown, + req?: express.Request, + ) => Promise, +) { + tempDir = mkdtempSync(path.join(os.tmpdir(), 'od-ds-explicit-create-')); + const db = openDatabase(tempDir, { dataDir: tempDir }); + registerDesignSystemRoutes(app, { + db, + paths: commonPaths(tempDir), + projectFiles: {} as never, + projectStore: {} as never, + verifyWorkspaceRequestAuthority: async () => { + throw new Error('unbound fixture must not verify Workspace authority'); + }, + workspaceResources: { + getWorkspaceResource: () => undefined, + getWorkspaceResourceByResourceId: () => undefined, + }, + designSystems: { + buildUserDesignSystemArchive: async () => null, + canMutateUserDesignSystem: async () => true, + createUserDesignSystem, + deleteUserDesignSystem: async () => false, + ensureUserDesignSystemWorkspaceProject: async () => null, + listAllDesignSystems: async () => [], + listUserDesignSystemFiles: async () => null, + listUserDesignSystemRevisions: async () => null, + prepareDesignTokenContractRebuild: async () => ({ decision: { available: false } }) as never, + readAvailableDesignSystem: async () => null, + readAvailableDesignSystemPackageInfo: async () => null, + readAvailableDesignSystemStaticFile: async () => null, + readDesignSystemWorkspaceTextFile: async () => null, + readUserDesignSystemFile: async () => null, + renderDesignSystemPreview: () => '', + renderDesignSystemShowcase: () => '', + syncUserDesignSystemAssetsFromWorkspace: async () => ({ ok: false, reason: 'not-found' }), + unshareTeamDesignSystemIfShared: async () => false, + updateUserDesignSystem: async () => null, + updateUserDesignSystemRevisionStatus: async () => null, + }, + generationJobs: { + get: () => null, + rebuildTokenContract: () => ({}) as never, + revise: () => ({}) as never, + start: () => ({}) as never, + }, + }); +} + +describe('design-system explicit Workspace request scope', () => { + it('passes the list request into scope resolution and lists only that Workspace', async () => { + const listAllDesignSystems = vi.fn(async (options?: { workspaceId?: string | null }) => + options?.workspaceId === 'workspace-a' ? [summary] : []); + const baseUrl = await startListRoute({ + resolveWorkspaceScope: async (req) => + req?.get('x-od-workspace-id') === 'workspace-a' ? 'workspace-a' : null, + listAllDesignSystems, + }); + + const response = await fetch(`${baseUrl}/api/design-systems`, { + headers: workspaceHeaders(), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + designSystems: [expect.objectContaining({ id: summary.id })], + }); + expect(listAllDesignSystems).toHaveBeenCalledWith({ workspaceId: 'workspace-a' }); + }); + + it.each([400, 403, 503] as const)( + 'preserves list scope resolution status %s and performs no catalog read', + async (status) => { + const listAllDesignSystems = vi.fn(async () => [summary]); + const baseUrl = await startListRoute({ + resolveWorkspaceScope: async () => { + throw scopeError(status); + }, + listAllDesignSystems, + }); + + const response = await fetch(`${baseUrl}/api/design-systems`, { + headers: workspaceHeaders(), + }); + + expect(response.status).toBe(status); + expect(listAllDesignSystems).not.toHaveBeenCalled(); + expect(await response.json()).toMatchObject({ + error: scopeError(status).code, + ...(status === 503 ? { retryable: true } : {}), + }); + }, + ); + + it('passes the create request to the scoped creator before any write', async () => { + const create = vi.fn(async () => summary); + const scopedCreate = async ( + _root: string, + _input: unknown, + req?: express.Request, + ): Promise => { + if (req?.get('x-od-workspace-id') !== 'workspace-a') throw scopeError(400); + return create(); + }; + const app = express(); + app.use(express.json()); + registerCreateRoute(app, scopedCreate); + const baseUrl = await listen(app); + + const response = await fetch(`${baseUrl}/api/design-systems`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...workspaceHeaders(), + }, + body: JSON.stringify({ title: 'Workspace A' }), + }); + + expect(response.status).toBe(201); + expect(create).toHaveBeenCalledOnce(); + }); + + it.each([400, 403, 503] as const)( + 'preserves create scope resolution status %s and performs no write', + async (status) => { + const create = vi.fn(async () => summary); + const app = express(); + app.use(express.json()); + registerCreateRoute(app, async () => { + throw scopeError(status); + }); + const baseUrl = await listen(app); + + const response = await fetch(`${baseUrl}/api/design-systems`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...workspaceHeaders(), + }, + body: JSON.stringify({ title: 'Workspace A' }), + }); + + expect(response.status).toBe(status); + expect(create).not.toHaveBeenCalled(); + expect(await response.json()).toMatchObject({ + error: scopeError(status).code, + ...(status === 503 ? { retryable: true } : {}), + }); + }, + ); +}); diff --git a/apps/daemon/tests/design-systems/index.test.ts b/apps/daemon/tests/design-systems/index.test.ts index 3e5f8eee912..feec5f1d30c 100644 --- a/apps/daemon/tests/design-systems/index.test.ts +++ b/apps/daemon/tests/design-systems/index.test.ts @@ -7,6 +7,7 @@ import { createUserDesignSystem, createUserDesignSystemRevision, deleteUserDesignSystem, + isTeamSyncedUserDesignSystem, linkUserDesignSystemProject, listDesignSystems, listUserDesignSystemFiles, @@ -600,4 +601,30 @@ describe('design systems registry', () => { readDesignSystemStaticFile(root, created.id, '.od-generated.json', { idPrefix: 'user:' }), ).resolves.toBeNull(); }); + + // recvqb6mfyqXLD: `isTeamSyncedUserDesignSystem` is the signal routes use to + // decide whether a `user:`-prefixed system needs the team-share permission + // check before PATCH/DELETE — it must read the exact flag + // `markTeamSynced` (server.ts) writes onto a pulled system's metadata.json. + describe('isTeamSyncedUserDesignSystem', () => { + it('is false for a system the caller authored themselves', async () => { + const created = await createUserDesignSystem(root, { title: 'My Own System' }); + await expect(isTeamSyncedUserDesignSystem(root, created.id)).resolves.toBe(false); + }); + + it('is true once metadata.json carries the teamSynced flag', async () => { + const created = await createUserDesignSystem(root, { title: 'Synced From Teammate' }); + const dirId = created.id.slice('user:'.length); + const metadataPath = path.join(root, dirId, 'metadata.json'); + const metadata = JSON.parse(await readFile(metadataPath, 'utf8')) as Record; + await writeFile(metadataPath, JSON.stringify({ ...metadata, teamSynced: true }, null, 2), 'utf8'); + + await expect(isTeamSyncedUserDesignSystem(root, created.id)).resolves.toBe(true); + }); + + it('is false for an unknown or malformed id', async () => { + await expect(isTeamSyncedUserDesignSystem(root, 'user:does-not-exist')).resolves.toBe(false); + await expect(isTeamSyncedUserDesignSystem(root, 'not-a-user-id')).resolves.toBe(false); + }); + }); }); diff --git a/apps/daemon/tests/design-systems/rename-args.test.ts b/apps/daemon/tests/design-systems/rename-args.test.ts index 11b0f3dca76..cea7395340c 100644 --- a/apps/daemon/tests/design-systems/rename-args.test.ts +++ b/apps/daemon/tests/design-systems/rename-args.test.ts @@ -3,6 +3,20 @@ import { describe, expect, it } from 'vitest'; import { parseDesignSystemRenameArgs } from '../../src/design-systems/rename-args.js'; describe('parseDesignSystemRenameArgs', () => { + it('does not treat exact workspace flag values as the id or title', () => { + expect(parseDesignSystemRenameArgs([ + 'user:brand', + '--title', + 'Renamed Brand', + '--workspace', + 'workspace-a', + '--workspace-member', + 'member-a', + ])).toEqual({ + id: 'user:brand', + title: 'Renamed Brand', + }); + }); it('reads the id positional and the title from --title', () => { expect(parseDesignSystemRenameArgs(['user:acme', '--title', 'Acme v2'])).toEqual({ id: 'user:acme', diff --git a/apps/daemon/tests/design-systems/team-resource-consumer-scope.test.ts b/apps/daemon/tests/design-systems/team-resource-consumer-scope.test.ts new file mode 100644 index 00000000000..6b07770a2b5 --- /dev/null +++ b/apps/daemon/tests/design-systems/team-resource-consumer-scope.test.ts @@ -0,0 +1,161 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { closeDatabase, openDatabase } from '../../src/db.js'; +import * as designSystems from '../../src/design-systems/index.js'; +import { createDesignSystemServerServices } from '../../src/design-systems/server-services.js'; +import * as skills from '../../src/skills.js'; +import { materializeWorkspaceScopedTeamResource } from '../../src/collab/team-resource-materialization.js'; + +const roots: string[] = []; + +afterEach(async () => { + closeDatabase(); + await Promise.all(roots.splice(0).map((root) => + rm(root, { recursive: true, force: true }), + )); +}); + +async function createFixture() { + const root = await mkdtemp(path.join(os.tmpdir(), 'od-team-resource-consumer-')); + roots.push(root); + const userSkills = path.join(root, 'skills'); + const userDesignSystems = path.join(root, 'design-systems'); + const builtInSkills = path.join(root, 'built-in-skills'); + const builtInDesignSystems = path.join(root, 'built-in-design-systems'); + await Promise.all([ + mkdir(userSkills, { recursive: true }), + mkdir(userDesignSystems, { recursive: true }), + mkdir(builtInSkills, { recursive: true }), + mkdir(builtInDesignSystems, { recursive: true }), + ]); + const db = openDatabase(root, { dataDir: root }); + const services = createDesignSystemServerServices({ + getDb: () => db, + roots: { + SKILL_ROOTS: [userSkills, builtInSkills], + DESIGN_TEMPLATE_ROOTS: [], + ALL_SKILL_LIKE_ROOTS: [], + }, + paths: { + PROJECTS_DIR: path.join(root, 'projects'), + DESIGN_SYSTEMS_DIR: builtInDesignSystems, + USER_DESIGN_SYSTEMS_DIR: userDesignSystems, + }, + skills: { + listSkills: skills.listSkills as never, + findSkillById: skills.findSkillById as never, + }, + designSystems: designSystems as never, + projects: {} as never, + }); + return { root, userSkills, userDesignSystems, services }; +} + +async function writeSkill(dir: string, content: string) { + await writeFile( + path.join(dir, 'SKILL.md'), + `---\nname: same-skill\ndescription: ${content}\n---\n\n${content}\n`, + ); +} + +async function writeDesignSystem(dir: string, workspaceId: string, content: string) { + await writeFile(path.join(dir, 'DESIGN.md'), `# Same design system\n\n${content}\n`); + await writeFile( + path.join(dir, 'metadata.json'), + `${JSON.stringify({ workspaceId, teamSynced: true })}\n`, + ); +} + +describe('Team resource consumers use explicit Workspace scope', () => { + it('reads A and B copies of identical skill/design-system ids without changing legacy Personal reads', async () => { + const fixture = await createFixture(); + const personalSkillDir = path.join(fixture.userSkills, 'same-skill'); + const personalDesignSystemDir = path.join(fixture.userDesignSystems, 'same-design-system'); + await Promise.all([ + mkdir(personalSkillDir, { recursive: true }), + mkdir(personalDesignSystemDir, { recursive: true }), + ]); + await writeSkill(personalSkillDir, 'personal-skill'); + await writeDesignSystem(personalDesignSystemDir, 'personal-workspace', 'personal-design-system'); + + for (const [workspaceId, suffix] of [ + ['workspace-a', 'a'], + ['workspace-b', 'b'], + ] as const) { + await materializeWorkspaceScopedTeamResource({ + kindRoot: fixture.userSkills, + storageName: 'same-skill', + identity: { + kind: 'skill', + workspaceId, + resourceId: 'same-skill', + hubResourceId: `skill-${workspaceId}-same-skill`, + }, + pullInto: (dir) => writeSkill(dir, `team-skill-${suffix}`), + verifyWorkspaceScope: async () => true, + verifyStillShared: async () => true, + }); + await materializeWorkspaceScopedTeamResource({ + kindRoot: fixture.userDesignSystems, + storageName: 'same-design-system', + identity: { + kind: 'design_system', + workspaceId, + resourceId: 'user:same-design-system', + hubResourceId: `ds-${workspaceId}-same-design-system`, + }, + pullInto: (dir) => + writeDesignSystem(dir, workspaceId, `team-design-system-${suffix}`), + verifyWorkspaceScope: async () => true, + verifyStillShared: async () => true, + }); + } + + const [skillsA, skillsB, legacySkills] = await Promise.all([ + fixture.services.listAllSkills({ workspaceId: 'workspace-a' }), + fixture.services.listAllSkills({ workspaceId: 'workspace-b' }), + fixture.services.listAllSkills(), + ]); + expect(skills.findSkillById(skillsA, 'same-skill')?.body).toContain('team-skill-a'); + expect(skills.findSkillById(skillsB, 'same-skill')?.body).toContain('team-skill-b'); + expect(skills.findSkillById(legacySkills, 'same-skill')?.body).toContain('personal-skill'); + await expect( + fixture.services.validateProjectSkillId('same-skill', { + workspaceId: 'workspace-a', + }), + ).resolves.toEqual({ ok: true, id: 'same-skill' }); + await expect( + fixture.services.validateProjectSkillId('same-skill', { + workspaceId: 'workspace-b', + }), + ).resolves.toEqual({ ok: true, id: 'same-skill' }); + + await expect( + fixture.services.readAvailableDesignSystem('user:same-design-system', { + workspaceId: 'workspace-a', + }), + ).resolves.toContain('team-design-system-a'); + await expect( + fixture.services.readAvailableDesignSystem('user:same-design-system', { + workspaceId: 'workspace-b', + }), + ).resolves.toContain('team-design-system-b'); + await expect( + fixture.services.readAvailableDesignSystem('user:same-design-system'), + ).resolves.toContain('personal-design-system'); + await expect( + fixture.services.validateProjectDesignSystemId( + 'user:same-design-system', + { workspaceId: 'workspace-a' }, + ), + ).resolves.toEqual({ ok: true, id: 'user:same-design-system' }); + await expect( + fixture.services.validateProjectDesignSystemId( + 'user:same-design-system', + { workspaceId: 'workspace-b' }, + ), + ).resolves.toEqual({ ok: true, id: 'user:same-design-system' }); + }); +}); diff --git a/apps/daemon/tests/design-systems/workspace-owned-create.test.ts b/apps/daemon/tests/design-systems/workspace-owned-create.test.ts new file mode 100644 index 00000000000..1a2274e8646 --- /dev/null +++ b/apps/daemon/tests/design-systems/workspace-owned-create.test.ts @@ -0,0 +1,72 @@ +import { access, mkdtemp, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createWorkspaceOwnedDesignSystem } from '../../src/design-systems/workspace-owned-create.js'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe('createWorkspaceOwnedDesignSystem', () => { + it('removes the just-created directory when the Workspace envelope write fails', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'od-workspace-owned-ds-')); + roots.push(root); + const ensureWorkspaceResource = vi.fn(() => { + throw new Error('injected workspace_resources failure'); + }); + + await expect( + createWorkspaceOwnedDesignSystem( + root, + { title: 'Rollback fixture', artifactMode: 'agent-managed' }, + { + workspaceId: 'ws-rollback', + appUserId: 'user-rollback', + workspaceMemberId: 'member-rollback', + workspaceType: 'team', + workspaceTypeAsserted: 'team', + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + canShareProjects: true, + canWriteSyncedFiles: true, + }, + { ensureWorkspaceResource }, + ), + ).rejects.toThrow('injected workspace_resources failure'); + + expect(ensureWorkspaceResource).toHaveBeenCalledWith( + 'design_system', + 'ws-rollback', + expect.stringMatching(/^user:/), + { + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: 'member-rollback', + updatedByWorkspaceMemberId: 'member-rollback', + }, + ); + expect(await readdir(root)).toEqual([]); + }); + + it('preserves headerless local creation without a Workspace envelope', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'od-local-owned-ds-')); + roots.push(root); + const ensureWorkspaceResource = vi.fn(); + + const created = await createWorkspaceOwnedDesignSystem( + root, + { title: 'Local fixture', artifactMode: 'agent-managed' }, + null, + { ensureWorkspaceResource }, + ); + + expect(ensureWorkspaceResource).not.toHaveBeenCalled(); + await expect(access(path.join(root, created.id.slice('user:'.length), 'metadata.json'))) + .resolves.toBeUndefined(); + }); +}); diff --git a/apps/daemon/tests/design-systems/workspace-resource-backfill.test.ts b/apps/daemon/tests/design-systems/workspace-resource-backfill.test.ts new file mode 100644 index 00000000000..03f06221082 --- /dev/null +++ b/apps/daemon/tests/design-systems/workspace-resource-backfill.test.ts @@ -0,0 +1,137 @@ +// One-time startup backfill (spec 9.2, section 9.2): every user design +// system claimed by a workspace BEFORE the `workspace_resources` envelope +// double-write shipped (its `metadata.json` carries a `workspaceId`, but the +// generic table has no matching row) must get one backfilled — see +// `backfillDesignSystemWorkspaceResources`'s own doc comment in +// design-systems/index.ts, and `server.ts`'s startup call site right after +// `reconcileImpossibleTeamShares`. +// +// Two invariants matter here, independent of `workspace-scope.test.ts` +// (which pins the READ-side filter and must stay untouched by this change): +// 1. correctness — the backfilled row lands with the right workspaceId and +// the right visibility (personal vs team, from `metadata.teamSynced`). +// 2. idempotency — running the backfill twice (every daemon restart) must +// not error and must not duplicate or clobber an existing row. + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { backfillDesignSystemWorkspaceResources } from '../../src/design-systems/index.js'; +import { + closeDatabase, + ensureWorkspaceResource, + getWorkspaceResourceByResourceId, + listWorkspaceResources, + openDatabase, +} from '../../src/db.js'; + +const WORKSPACE_A = 'vp44mftzknedrrqgy05oqpv9'; +const WORKSPACE_B = 'jg63to8cbic0kzbczbu95a4g'; + +let root: string; +let dataDir: string; +let db: ReturnType; + +beforeEach(() => { + root = mkdtempSync(path.join(tmpdir(), 'od-ds-backfill-root-')); + dataDir = mkdtempSync(path.join(tmpdir(), 'od-ds-backfill-db-')); + db = openDatabase(dataDir, { dataDir }); +}); + +afterEach(() => { + closeDatabase(); + rmSync(root, { recursive: true, force: true }); + rmSync(dataDir, { recursive: true, force: true }); +}); + +/** Write a design system directly on disk with the given metadata, mirroring + * `workspace-scope.test.ts`'s seeding helper (pre-double-write state). */ +function seedSystem(dirId: string, metadata: Record): void { + const dir = path.join(root, dirId); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, 'DESIGN.md'), `# ${dirId}\n\nA seeded system.\n`, 'utf8'); + writeFileSync(path.join(dir, 'metadata.json'), `${JSON.stringify(metadata, null, 2)}\n`, 'utf8'); +} + +describe('backfillDesignSystemWorkspaceResources', () => { + it('binds a pre-existing personal-claimed system that has no workspace_resources row yet', async () => { + seedSystem('legacy-claimed', { title: 'Legacy Claimed', workspaceId: WORKSPACE_A }); + + const backfilled = await backfillDesignSystemWorkspaceResources(db, root); + + expect(backfilled).toBe(1); + const row = getWorkspaceResourceByResourceId(db, 'design_system', 'user:legacy-claimed'); + expect(row?.workspaceId).toBe(WORKSPACE_A); + expect(row?.visibility).toBe('personal'); + }); + + it('backfills a team-synced system as visibility: team', async () => { + seedSystem('legacy-team', { title: 'Legacy Team', workspaceId: WORKSPACE_B, teamSynced: true }); + + const backfilled = await backfillDesignSystemWorkspaceResources(db, root); + + expect(backfilled).toBe(1); + const row = getWorkspaceResourceByResourceId(db, 'design_system', 'user:legacy-team'); + expect(row?.workspaceId).toBe(WORKSPACE_B); + expect(row?.visibility).toBe('team'); + }); + + it('skips an unclaimed (no workspaceId) system', async () => { + seedSystem('unclaimed', { title: 'Unclaimed' }); + + const backfilled = await backfillDesignSystemWorkspaceResources(db, root); + + expect(backfilled).toBe(0); + expect(getWorkspaceResourceByResourceId(db, 'design_system', 'user:unclaimed')).toBeUndefined(); + }); + + it('never overwrites a row that already exists', async () => { + seedSystem('already-bound', { title: 'Already Bound', workspaceId: WORKSPACE_A }); + // A binding already on file (e.g. the live double-write path already ran + // for it) is authoritative; the backfill must treat it as done rather + // than re-deriving anything from metadata.json. + ensureWorkspaceResource(db, 'design_system', WORKSPACE_B, 'user:already-bound', { + visibility: 'team', + }); + + const backfilled = await backfillDesignSystemWorkspaceResources(db, root); + + expect(backfilled).toBe(0); + const row = getWorkspaceResourceByResourceId(db, 'design_system', 'user:already-bound'); + expect(row?.workspaceId).toBe(WORKSPACE_B); + expect(row?.visibility).toBe('team'); + }); + + it('is idempotent: running it twice does not duplicate rows or error', async () => { + seedSystem('idempotent-check', { title: 'Idempotent', workspaceId: WORKSPACE_A }); + + const first = await backfillDesignSystemWorkspaceResources(db, root); + const second = await backfillDesignSystemWorkspaceResources(db, root); + + expect(first).toBe(1); + expect(second).toBe(0); + const rows = listWorkspaceResources(db, 'design_system', WORKSPACE_A); + expect(rows.filter((r) => r.resourceId === 'user:idempotent-check')).toHaveLength(1); + }); + + it('backfills every claimed system in one pass, leaving unclaimed ones alone', async () => { + seedSystem('from-a', { title: 'From A', workspaceId: WORKSPACE_A }); + seedSystem('from-b', { title: 'From B', workspaceId: WORKSPACE_B, teamSynced: true }); + seedSystem('legacy', { title: 'Legacy' }); + + const backfilled = await backfillDesignSystemWorkspaceResources(db, root); + + expect(backfilled).toBe(2); + expect(getWorkspaceResourceByResourceId(db, 'design_system', 'user:from-a')?.visibility).toBe('personal'); + expect(getWorkspaceResourceByResourceId(db, 'design_system', 'user:from-b')?.visibility).toBe('team'); + expect(getWorkspaceResourceByResourceId(db, 'design_system', 'user:legacy')).toBeUndefined(); + }); + + it('returns 0 without throwing when the root directory does not exist yet', async () => { + const missingRoot = path.join(root, 'does-not-exist'); + await expect(backfillDesignSystemWorkspaceResources(db, missingRoot)).resolves.toBe(0); + }); +}); diff --git a/apps/daemon/tests/design-systems/workspace-scope-context-switch.test.ts b/apps/daemon/tests/design-systems/workspace-scope-context-switch.test.ts new file mode 100644 index 00000000000..0a2f4bf426e --- /dev/null +++ b/apps/daemon/tests/design-systems/workspace-scope-context-switch.test.ts @@ -0,0 +1,244 @@ +// Design-system catalog/create are data-plane operations. They must resolve +// their Workspace from the request identity, not from the daemon's mutable +// active/current Workspace. Otherwise two tabs can cross: an A request that +// lands after a B switch is listed/stamped as B. + +import type http from 'node:http'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { startServer } from '../../src/server.js'; + +type StartedServer = { + url: string; + server: http.Server; + shutdown?: () => Promise | void; +}; + +const CONTEXT_WS1 = { + workspaceMemberId: 'member-switch', + workspaceId: 'ws-switch-one', + workspaceType: 'team', + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', +}; + +const CONTEXT_WS2 = { + workspaceMemberId: 'member-switch', + workspaceId: 'ws-switch-two', + workspaceType: 'personal', + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', +}; + +// The dev/demo seam (`workspaceContext.set`), NOT `PUT /api/workspace/active`: +// this is deliberately the same shape as a Vela-Web-driven switch — the +// daemon's notion of "current" context changes, but no local pin is written. +async function setContext(baseUrl: string, context: unknown): Promise { + const resp = await fetch(`${baseUrl}/api/workspace/context`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(context), + }); + expect(resp.ok).toBe(true); +} + +function workspaceHeaders(context: typeof CONTEXT_WS1 | typeof CONTEXT_WS2): Record { + return { + 'x-od-workspace-id': context.workspaceId, + 'x-od-workspace-member-id': context.workspaceMemberId, + 'x-od-workspace-type': context.workspaceType, + 'x-od-workspace-role': context.role, + 'x-od-workspace-member-status': context.memberStatus, + 'x-od-workspace-lifecycle-state': context.lifecycleState, + }; +} + +describe('GET/POST /api/design-systems — explicit request scope is isolated from daemon current Workspace', () => { + let server: http.Server; + let baseUrl: string; + let shutdown: (() => Promise | void) | undefined; + + beforeAll(async () => { + const started = (await startServer({ port: 0, returnServer: true })) as StartedServer; + baseUrl = started.url; + server = started.server; + shutdown = started.shutdown; + }); + + afterAll(async () => { + await Promise.resolve(shutdown?.()); + await new Promise((resolve) => server.close(() => resolve())); + }); + + it('keeps the completely headerless signed-out/local lane unbound', async () => { + const title = `local unbound ${Date.now()}`; + const createdResponse = await fetch(`${baseUrl}/api/design-systems`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ title }), + }); + expect(createdResponse.status).toBe(201); + const created = (await createdResponse.json()) as { + id: string; + workspaceId?: string; + }; + expect(created.workspaceId).toBeUndefined(); + + const listedResponse = await fetch(`${baseUrl}/api/design-systems`); + expect(listedResponse.status).toBe(200); + const listed = (await listedResponse.json()) as { + designSystems: Array<{ id: string }>; + }; + expect(listed.designSystems.some((item) => item.id === created.id)).toBe(true); + }); + + it('rejects a half-specified Workspace identity instead of treating it as local', async () => { + const response = await fetch(`${baseUrl}/api/design-systems`, { + headers: { 'x-od-workspace-id': 'ws-switch-one' }, + }); + expect(response.status).toBe(400); + }); + + it('keeps an A request on A after a legacy context write without creating ambient authority', async () => { + await setContext(baseUrl, CONTEXT_WS2); + + const createResp1 = await fetch(`${baseUrl}/api/design-systems`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...workspaceHeaders(CONTEXT_WS1), + }, + body: JSON.stringify({ title: `ws1 system ${Date.now()}` }), + }); + expect(createResp1.status).toBe(201); + const createdInWs1 = (await createResp1.json()) as { id: string; workspaceId?: string }; + expect(createdInWs1.workspaceId).toBe('ws-switch-one'); + + const workspaceResp = await fetch( + `${baseUrl}/api/design-systems/${encodeURIComponent(createdInWs1.id)}/workspace`, + { + method: 'POST', + headers: workspaceHeaders(CONTEXT_WS1), + }, + ); + expect(workspaceResp.status).toBe(201); + const workspaceBody = await workspaceResp.json() as { + project: { id: string }; + }; + const projectsResp = await fetch( + `${baseUrl}/api/workspaces/${CONTEXT_WS1.workspaceId}/projects?view=all`, + { headers: workspaceHeaders(CONTEXT_WS1) }, + ); + expect(projectsResp.status).toBe(200); + const projectsBody = await projectsResp.json() as { + projects: Array<{ + id: string; + createdByWorkspaceMemberId?: string | null; + }>; + }; + expect( + projectsBody.projects.find((project) => project.id === workspaceBody.project.id), + ).toMatchObject({ + id: workspaceBody.project.id, + createdByWorkspaceMemberId: CONTEXT_WS1.workspaceMemberId, + }); + + // The compatibility write no longer creates daemon-global data-plane + // authority. Each tab's following request must remain self-contained. + const ctxResp = await fetch(`${baseUrl}/api/workspace/context`); + const ctxBody = (await ctxResp.json()) as { context: { workspaceId: string } | null }; + expect(ctxBody.context?.workspaceId).toBeUndefined(); + + const listResp = await fetch(`${baseUrl}/api/design-systems`, { + headers: workspaceHeaders(CONTEXT_WS1), + }); + const listBody = (await listResp.json()) as { + designSystems: Array<{ id: string; workspaceId?: string }>; + }; + expect(listBody.designSystems.some((d) => d.id === createdInWs1.id)).toBe(true); + + const createResp2 = await fetch(`${baseUrl}/api/design-systems`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...workspaceHeaders(CONTEXT_WS2), + }, + body: JSON.stringify({ title: `ws2 system ${Date.now()}` }), + }); + expect(createResp2.status).toBe(201); + const createdInWs2 = (await createResp2.json()) as { id: string; workspaceId?: string }; + expect(createdInWs2.workspaceId).toBe('ws-switch-two'); + + const listWs1Resp = await fetch(`${baseUrl}/api/design-systems`, { + headers: workspaceHeaders(CONTEXT_WS1), + }); + const listWs1Body = (await listWs1Resp.json()) as { designSystems: Array<{ id: string }> }; + expect(listWs1Body.designSystems.some((d) => d.id === createdInWs1.id)).toBe(true); + expect(listWs1Body.designSystems.some((d) => d.id === createdInWs2.id)).toBe(false); + }); +}); + +describe('resolveDesignSystemWorkspaceScope — stale local pins are never data-plane authority', () => { + // This function's session-liveness gate (`collab.workspaceContext.lastKnown()`) + // is untouched by the TTL-cache fix above — this suite exists to prove + // removing that cache did not also disturb the gate. A fresh server + // instance is used (rather than reusing the suite above) so its + // `activeWorkspace` pin-file reader starts with an empty in-memory cache and + // performs its first disk read AFTER the stale pin below is written — + // mirroring a real daemon restart finding a leftover pin file on disk. + let server: http.Server; + let baseUrl: string; + let shutdown: (() => Promise | void) | undefined; + + beforeAll(async () => { + // A stale local pin exactly like a real leftover from a previous identity + // — `velaLogout` never clears this file (only a CONFIRMED member-removal + // does; see `resolvePinnedWorkspace` in vela-workspace-context.ts). + const dataDir = process.env.OD_DATA_DIR!; + writeFileSync( + path.join(dataDir, 'workspace-selection.json'), + `${JSON.stringify({ workspaceId: 'ws-stale-pin' }, null, 2)}\n`, + ); + // A design system claimed by the pinned workspace, seeded directly on + // disk so this suite is independent of the other describe block's state. + const dsDir = path.join(dataDir, 'design-systems', 'pinned-claim'); + mkdirSync(dsDir, { recursive: true }); + writeFileSync(path.join(dsDir, 'DESIGN.md'), '# Pinned claim\n\nSeeded directly on disk.\n'); + writeFileSync( + path.join(dsDir, 'metadata.json'), + `${JSON.stringify({ title: 'Pinned claim', workspaceId: 'ws-stale-pin' }, null, 2)}\n`, + ); + + const started = (await startServer({ port: 0, returnServer: true })) as StartedServer; + baseUrl = started.url; + server = started.server; + shutdown = started.shutdown; + }); + + afterAll(async () => { + await Promise.resolve(shutdown?.()); + await new Promise((resolve) => server.close(() => resolve())); + }); + + it('ignores a stale pin when an explicit request names another Workspace', async () => { + const resp = await fetch(`${baseUrl}/api/design-systems`, { + headers: workspaceHeaders(CONTEXT_WS2), + }); + const body = (await resp.json()) as { designSystems: Array<{ id: string }> }; + expect(body.designSystems.some((d) => d.id === 'user:pinned-claim')).toBe(false); + }); + + it('shows the claim only when the explicit request names the pinned Workspace', async () => { + const resp = await fetch(`${baseUrl}/api/design-systems`, { + headers: workspaceHeaders({ + ...CONTEXT_WS1, + workspaceId: 'ws-stale-pin', + }), + }); + const body = (await resp.json()) as { designSystems: Array<{ id: string }> }; + expect(body.designSystems.some((d) => d.id === 'user:pinned-claim')).toBe(true); + }); +}); diff --git a/apps/daemon/tests/design-systems/workspace-scope.test.ts b/apps/daemon/tests/design-systems/workspace-scope.test.ts new file mode 100644 index 00000000000..f68941360ef --- /dev/null +++ b/apps/daemon/tests/design-systems/workspace-scope.test.ts @@ -0,0 +1,145 @@ +// Workspace scoping for the user design-system library (#145). +// +// Acceptance report: a design system authored in one workspace also showed up +// in a brand-new second workspace. User design systems all live in ONE flat +// directory under the daemon data root — there is no per-workspace store — so +// the only thing that can separate them is the `workspaceId` claim written into +// each system's `metadata.json` plus the filter this file pins. +// +// The rule is deliberately asymmetric, and both halves matter: +// • a system CLAIMED by another workspace is hidden (the reported bug), and +// • an UNCLAIMED system stays visible everywhere. +// Unclaimed is exactly what every system written before this change looks like, +// so hiding those would empty an upgrading user's library — a worse regression +// than the leak being fixed. +// +// spec 04 §10 addendum: "no scope" itself now splits into two distinct +// signals a caller can send, not one. `workspaceId` OMITTED entirely means an +// internal caller never asked to be scoped (id resolution, self-lookup after +// a write) and must keep seeing everything. `workspaceId: null` or `''` means +// a caller DID ask to be scoped — `GET /api/design-systems` with no verified +// session — and must now see ONLY unclaimed systems, not everything, or a +// signed-out reader could still list every workspace's claimed systems. + +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { createUserDesignSystem, listDesignSystems } from '../../src/design-systems/index.js'; + +const WORKSPACE_A = 'vp44mftzknedrrqgy05oqpv9'; +const WORKSPACE_B = 'jg63to8cbic0kzbczbu95a4g'; + +function freshRoot(): string { + return mkdtempSync(path.join(tmpdir(), 'od-ds-workspace-scope-')); +} + +/** Write a design system directly on disk with the given metadata. */ +function seedSystem(root: string, dirId: string, metadata: Record): void { + const dir = path.join(root, dirId); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, 'DESIGN.md'), `# ${dirId}\n\nA seeded system.\n`, 'utf8'); + writeFileSync(path.join(dir, 'metadata.json'), `${JSON.stringify(metadata, null, 2)}\n`, 'utf8'); +} + +function listIds(systems: Array<{ id: string }>): string[] { + return systems.map((system) => system.id).sort(); +} + +describe('design-system workspace scoping', () => { + it('hides a system claimed by another workspace', async () => { + const root = freshRoot(); + seedSystem(root, 'from-a', { title: 'From A', workspaceId: WORKSPACE_A }); + seedSystem(root, 'from-b', { title: 'From B', workspaceId: WORKSPACE_B }); + + const fromB = await listDesignSystems(root, { workspaceId: WORKSPACE_B }); + + expect(listIds(fromB)).toEqual(['from-b']); + }); + + it('keeps an unclaimed (pre-#145) system visible from every workspace', async () => { + const root = freshRoot(); + seedSystem(root, 'legacy', { title: 'Legacy' }); + seedSystem(root, 'from-a', { title: 'From A', workspaceId: WORKSPACE_A }); + + const fromA = await listDesignSystems(root, { workspaceId: WORKSPACE_A }); + const fromB = await listDesignSystems(root, { workspaceId: WORKSPACE_B }); + + expect(listIds(fromA)).toEqual(['from-a', 'legacy']); + expect(listIds(fromB)).toEqual(['legacy']); + }); + + it('lists everything when the `workspaceId` option is OMITTED (the unscoped-catalog contract)', async () => { + // Callers that resolve a system BY ID — project validation, install/import + // lookups, and `createUserDesignSystem`/`updateUserDesignSystem`/ + // `linkUserDesignSystemProject` re-reading the system they just wrote — + // must keep seeing the whole catalog regardless of which workspace + // happens to be active. This is the ONE "no scope" case that must stay + // permissive: those call sites pass no `workspaceId` key at all, and + // `designSystemVisibleFromWorkspace` treats an OMITTED (`undefined`) + // scope as "never asked to be scoped" — see spec 04 §10 fix #2's + // undefined-vs-null split. Getting this wrong would make + // `listDesignSystems(...).find(...)` fail to find a system right after + // writing it, whenever that system happens to be workspace-claimed. + const root = freshRoot(); + seedSystem(root, 'from-a', { title: 'From A', workspaceId: WORKSPACE_A }); + seedSystem(root, 'from-b', { title: 'From B', workspaceId: WORKSPACE_B }); + + expect(listIds(await listDesignSystems(root))).toEqual(['from-a', 'from-b']); + }); + + it('hides claimed systems when the caller passes an explicit empty workspace scope (spec 04 §10)', async () => { + // `GET /api/design-systems` ALWAYS passes a `workspaceId` key — `null` + // whenever there is no verified session — which is a DIFFERENT signal + // from the omitted-key case above: this caller DID ask to be scoped, it + // just has no identity to offer. Before this fix, `!scopeId` alone + // resolved to "visible", which meant a signed-out reader (or a plain + // `curl` with no headers) could still list every claimed system in every + // workspace — "no scope" silently meant "trust everything" + // (recvqbeDjAsejl / recvqbklNGDqYY). Only a genuinely UNCLAIMED system + // stays visible to this caller. + const root = freshRoot(); + seedSystem(root, 'from-a', { title: 'From A', workspaceId: WORKSPACE_A }); + seedSystem(root, 'from-b', { title: 'From B', workspaceId: WORKSPACE_B }); + seedSystem(root, 'legacy', { title: 'Legacy' }); + + expect(listIds(await listDesignSystems(root, { workspaceId: '' }))).toEqual(['legacy']); + expect(listIds(await listDesignSystems(root, { workspaceId: null }))).toEqual(['legacy']); + }); + + it('claims a newly created system for the authoring workspace', async () => { + const root = freshRoot(); + const created = await createUserDesignSystem(root, { + title: 'Authored in A', + workspaceId: WORKSPACE_A, + }); + + const fromA = await listDesignSystems(root, { idPrefix: 'user:', workspaceId: WORKSPACE_A }); + const fromB = await listDesignSystems(root, { idPrefix: 'user:', workspaceId: WORKSPACE_B }); + + expect(fromA.map((system) => system.id)).toContain(created.id); + expect(fromB).toEqual([]); + }); + + it('leaves a system unclaimed when no workspace is active', async () => { + // Signed out / single-player: there are no workspaces to isolate, so the + // system must stay visible rather than becoming unreachable later. + const root = freshRoot(); + const created = await createUserDesignSystem(root, { title: 'Local only' }); + + const scoped = await listDesignSystems(root, { idPrefix: 'user:', workspaceId: WORKSPACE_A }); + + expect(scoped.map((system) => system.id)).toContain(created.id); + }); + + it('ignores a malformed workspace claim instead of trusting it', async () => { + const root = freshRoot(); + seedSystem(root, 'garbled', { title: 'Garbled', workspaceId: '../../etc/passwd' }); + + const scoped = await listDesignSystems(root, { workspaceId: WORKSPACE_A }); + + expect(listIds(scoped)).toEqual(['garbled']); + }); +}); diff --git a/apps/daemon/tests/fixtures/fake-vela.mjs b/apps/daemon/tests/fixtures/fake-vela.mjs index 5dc6a9f6766..d847a233d21 100755 --- a/apps/daemon/tests/fixtures/fake-vela.mjs +++ b/apps/daemon/tests/fixtures/fake-vela.mjs @@ -42,6 +42,11 @@ * FAKE_VELA_SESSION_NEW_ERROR – when set, session/new returns a JSON-RPC error * FAKE_VELA_SET_MODEL_ERROR – when set, session/set_model returns a JSON-RPC error * FAKE_VELA_PROMPT_ERROR – when set, session/prompt returns a JSON-RPC error + * FAKE_VELA_PROMPT_ERROR_ON_LOAD – when set, session/prompt errors only after session/load + * FAKE_VELA_STALL_AFTER_PROMPT – when set to '1', session/prompt never completes + * and emits non-substantive heartbeat updates + * FAKE_VELA_PROMPT_RESULT_DELAY_MS – delay the terminal session/prompt result + * after streaming substantive output * FAKE_VELA_MODELS – newline-separated `vela models` stdout * FAKE_VELA_MODEL_PRESET_JSON – JSON stdout for `model preset --format json` * FAKE_VELA_MODEL_LIST_JSON – JSON stdout for `model list --all --format json` @@ -72,6 +77,12 @@ const THOUGHT_TEXT = env.FAKE_VELA_THOUGHT || ''; const SESSION_NEW_ERROR = env.FAKE_VELA_SESSION_NEW_ERROR || ''; const SET_MODEL_ERROR = env.FAKE_VELA_SET_MODEL_ERROR || ''; const PROMPT_ERROR = env.FAKE_VELA_PROMPT_ERROR || ''; +const PROMPT_ERROR_ON_LOAD = env.FAKE_VELA_PROMPT_ERROR_ON_LOAD || ''; +const STALL_AFTER_PROMPT = env.FAKE_VELA_STALL_AFTER_PROMPT === '1'; +const TEXT_BEFORE_STALL = env.FAKE_VELA_TEXT_BEFORE_STALL === '1'; +const PROMPT_RESULT_DELAY_MS = Number(env.FAKE_VELA_PROMPT_RESULT_DELAY_MS) || 0; +const OMIT_PROMPT_USAGE = env.FAKE_VELA_OMIT_PROMPT_USAGE === '1'; +const STAY_ALIVE_AFTER_PROMPT_MS = Number(env.FAKE_VELA_STAY_ALIVE_AFTER_PROMPT_MS) || 0; const AVAILABLE_MODELS = [ { modelId: 'openai/gpt-5.4-mini', name: 'gpt-5.4-mini' }, { modelId: 'anthropic/claude-3.7-sonnet', name: 'claude-3.7-sonnet' }, @@ -282,8 +293,9 @@ function handleMessage(msg) { }); return; } - if (PROMPT_ERROR) { - writeError(id, PROMPT_ERROR, -32602); + const promptError = PROMPT_ERROR || (didLoad ? PROMPT_ERROR_ON_LOAD : ''); + if (promptError) { + writeError(id, promptError, -32602); return; } const sessionId = typeof params?.sessionId === 'string' ? params.sessionId : SESSION_ID; @@ -291,11 +303,35 @@ function handleMessage(msg) { writeError(id, 'session/set_model must be called before session/prompt', -32602); return; } + if (STALL_AFTER_PROMPT) { + if (TEXT_BEFORE_STALL) emitSessionUpdates(sessionId); + // Keep both the ACP stage watchdog and the outer chat inactivity + // watchdog fed without producing text, thinking, tools, artifacts, or + // a terminal prompt result. This models a provider bridge that stays + // transport-alive forever while never returning a first model output. + setInterval(() => { + writeNotification('session/update', { + sessionId, + update: { sessionUpdate: 'heartbeat' }, + }); + }, 20); + return; + } emitSessionUpdates(sessionId); - writeResult(id, { - stopReason: 'end_turn', - usage: { inputTokens: 12, outputTokens: 7, totalTokens: 19 }, - }); + const finishPrompt = () => { + writeResult(id, { + stopReason: 'end_turn', + ...(OMIT_PROMPT_USAGE + ? {} + : { usage: { inputTokens: 12, outputTokens: 7, totalTokens: 19 } }), + }); + if (STAY_ALIVE_AFTER_PROMPT_MS > 0) setTimeout(() => {}, STAY_ALIVE_AFTER_PROMPT_MS); + }; + if (PROMPT_RESULT_DELAY_MS > 0) { + setTimeout(finishPrompt, PROMPT_RESULT_DELAY_MS); + } else { + finishPrompt(); + } return; } case 'session/cancel': @@ -448,11 +484,12 @@ function loginAndExit() { writeFileSync(env.FAKE_VELA_ENV_DUMP_PATH, JSON.stringify(env, null, 2), 'utf8'); } const profile = (env.VELA_PROFILE || 'prod').trim() || 'prod'; - const allowed = new Set(['prod', 'test', 'local']); + const allowed = new Set(['prod', 'test', 'feature-test', 'local']); if (!allowed.has(profile)) { - stderr.write(`[fake-vela] unknown profile ${profile}; defaulting to prod\n`); + stderr.write(`[fake-vela] unknown profile ${profile}; expected prod, test, feature-test, or local\n`); + exit(1); } - const profileName = allowed.has(profile) ? profile : 'prod'; + const profileName = profile; const delayMs = Number(env.FAKE_VELA_LOGIN_DELAY_MS) || 0; const userEmail = env.FAKE_VELA_LOGIN_USER_EMAIL || 'fake-user@example.com'; const userPlan = env.FAKE_VELA_LOGIN_USER_PLAN || 'free'; diff --git a/apps/daemon/tests/folder-import-route.test.ts b/apps/daemon/tests/folder-import-route.test.ts index 86ec1eb205e..d68ff1606a3 100644 --- a/apps/daemon/tests/folder-import-route.test.ts +++ b/apps/daemon/tests/folder-import-route.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, rmSync, symlinkSync } from 'node:fs'; import { chmod, mkdir, readFile, realpath, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; +import JSZip from 'jszip'; import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; import { @@ -52,6 +53,22 @@ describe('POST /api/import/folder', () => { }); } + function workspaceHeaders( + workspaceId: string, + workspaceMemberId: string, + ): Record { + return { + 'x-od-workspace-id': workspaceId, + 'x-od-workspace-type': 'team', + 'x-od-workspace-member-id': workspaceMemberId, + 'x-od-workspace-role': 'member', + 'x-od-workspace-lifecycle-state': 'active', + 'x-od-workspace-member-status': 'active', + 'x-od-workspace-can-share-projects': 'true', + 'x-od-workspace-can-write-synced-files': 'true', + }; + } + async function withSandboxMode(run: () => Promise): Promise { const previous = process.env.OD_SANDBOX_MODE; process.env.OD_SANDBOX_MODE = '1'; @@ -105,6 +122,113 @@ describe('POST /api/import/folder', () => { expect(typeof tabs.updatedAt).toBe('number'); }); + it('atomically binds a folder import to the exact request workspace and not workspace B', async () => { + const folder = makeFolder(); + await writeFile(path.join(folder, 'index.html'), ''); + const headersA = workspaceHeaders('workspace-folder-a', 'member-folder-a'); + + const resp = await importFolder({ baseDir: folder }, headersA); + expect(resp.status).toBe(200); + const body = (await resp.json()) as { project: { id: string } }; + + const detail = await fetch( + `${baseUrl}/api/projects/${body.project.id}`, + { headers: headersA }, + ); + expect(detail.status).toBe(200); + await expect(detail.json()).resolves.toMatchObject({ + project: { + id: body.project.id, + workspaceId: 'workspace-folder-a', + }, + }); + + const workspaceA = await fetch( + `${baseUrl}/api/workspaces/workspace-folder-a/projects?view=drafts`, + { headers: headersA }, + ); + expect(workspaceA.status).toBe(200); + const projectsA = (await workspaceA.json()) as { + projects: Array<{ project: { id: string } }>; + }; + expect(projectsA.projects.map((item) => item.project.id)).toContain(body.project.id); + + const headersB = workspaceHeaders('workspace-folder-b', 'member-folder-b'); + const workspaceB = await fetch( + `${baseUrl}/api/workspaces/workspace-folder-b/projects?view=drafts`, + { headers: headersB }, + ); + expect(workspaceB.status).toBe(200); + const projectsB = (await workspaceB.json()) as { + projects: Array<{ project: { id: string } }>; + }; + expect(projectsB.projects.map((item) => item.project.id)).not.toContain(body.project.id); + }); + + it('validates an imported project skill inside the exact request workspace before inserting rows', async () => { + const folder = makeFolder(); + await writeFile(path.join(folder, 'index.html'), ''); + const headers = workspaceHeaders('workspace-folder-skill', 'member-folder-skill'); + const beforeResponse = await fetch( + `${baseUrl}/api/workspaces/workspace-folder-skill/projects?view=drafts`, + { headers }, + ); + const before = (await beforeResponse.json()) as { + projects: Array<{ project: { id: string } }>; + }; + + const response = await importFolder( + { baseDir: folder, skillId: 'skill-that-does-not-exist' }, + headers, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'SKILL_NOT_FOUND' }, + }); + const afterResponse = await fetch( + `${baseUrl}/api/workspaces/workspace-folder-skill/projects?view=drafts`, + { headers }, + ); + const after = (await afterResponse.json()) as { + projects: Array<{ project: { id: string } }>; + }; + expect(after.projects).toEqual(before.projects); + }); + + it('atomically binds a Claude Design import to the exact request workspace', async () => { + const zip = new JSZip(); + zip.file('index.html', 'Claude import'); + const archive = await zip.generateAsync({ type: 'uint8array' }); + const form = new FormData(); + form.append( + 'file', + new Blob([archive], { type: 'application/zip' }), + 'claude-workspace.zip', + ); + const headers = workspaceHeaders('workspace-claude-a', 'member-claude-a'); + + const resp = await fetch(`${baseUrl}/api/import/claude-design`, { + method: 'POST', + headers, + body: form, + }); + expect(resp.status).toBe(200); + const body = (await resp.json()) as { project: { id: string } }; + + const detail = await fetch( + `${baseUrl}/api/projects/${body.project.id}`, + { headers }, + ); + expect(detail.status).toBe(200); + await expect(detail.json()).resolves.toMatchObject({ + project: { + id: body.project.id, + workspaceId: 'workspace-claude-a', + }, + }); + }); + it('rejects folder imports in sandbox mode', async () => { await withSandboxMode(async () => { const folder = makeFolder(); @@ -363,6 +487,56 @@ describe('POST /api/import/folder', () => { expect(body.error?.message).toMatch(/unsupported field: source_reference/i); }); + it('requires the exact explicit Workspace member before replacing a bound project working directory', async () => { + const originalFolder = makeFolder(); + await writeFile(path.join(originalFolder, 'index.html'), 'original'); + const ownerHeaders = workspaceHeaders('workspace-working-dir', 'member-working-dir-owner'); + const importResp = await importFolder({ baseDir: originalFolder }, ownerHeaders); + expect(importResp.status).toBe(200); + const { project } = (await importResp.json()) as { + project: { id: string; metadata?: { baseDir?: string } }; + }; + + const unauthorizedFolder = makeFolder(); + await writeFile(path.join(unauthorizedFolder, 'index.html'), 'denied'); + const deniedResp = await fetch(`${baseUrl}/api/projects/${project.id}/working-dir`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...workspaceHeaders('workspace-working-dir', 'member-working-dir-teammate'), + }, + body: JSON.stringify({ baseDir: unauthorizedFolder }), + }); + expect(deniedResp.status).toBe(403); + await expect(deniedResp.json()).resolves.toMatchObject({ + error: { code: 'WORKSPACE_PROJECT_PERMISSION_DENIED' }, + }); + + const afterDenied = await fetch( + `${baseUrl}/api/projects/${project.id}`, + { headers: ownerHeaders }, + ); + expect(afterDenied.status).toBe(200); + await expect(afterDenied.json()).resolves.toMatchObject({ + project: { + id: project.id, + metadata: { baseDir: project.metadata?.baseDir }, + }, + }); + + const ownerFolder = makeFolder(); + await writeFile(path.join(ownerFolder, 'index.html'), 'owner'); + const allowedResp = await fetch(`${baseUrl}/api/projects/${project.id}/working-dir`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...ownerHeaders }, + body: JSON.stringify({ baseDir: ownerFolder }), + }); + expect(allowedResp.status).toBe(200); + await expect(allowedResp.json()).resolves.toMatchObject({ + project: { id: project.id, metadata: { baseDir: await realpath(ownerFolder) } }, + }); + }); + it('clears scratch provenance when replacing a working directory without new provenance', async () => { const scratchFolder = makeFolder(); await writeFile(path.join(scratchFolder, 'index.html'), ''); diff --git a/apps/daemon/tests/handoff-cli.test.ts b/apps/daemon/tests/handoff-cli.test.ts index 8d92bb04c7f..e0cc8428f8f 100644 --- a/apps/daemon/tests/handoff-cli.test.ts +++ b/apps/daemon/tests/handoff-cli.test.ts @@ -79,6 +79,27 @@ describe('od project handoff CLI', () => { expect(stdout.join('')).toContain('## Context'); }); + it('sends exact workspace identity for a bound project handoff', async () => { + const result = await runProjectHandoff([ + 'proj-1', + '--conversation', 'conv-9', + '--api-key', 'sk-test', + '--model', 'claude-opus-4-7', + '--workspace', 'workspace-a', + '--workspace-member', 'member-a', + '--daemon-url', DAEMON, + '--json', + ]); + + expect(result.exitCode).toBe(0); + const headers = new Headers( + (fetchMock.mock.calls[0]![1] as RequestInit).headers, + ); + expect(headers.get('x-od-workspace-id')).toBe('workspace-a'); + expect(headers.get('x-od-workspace-member-id')).toBe('member-a'); + expect(JSON.parse(stdout.join(''))).toEqual(HANDOFF_RESPONSE); + }); + it('emits the full response as JSON under --json', async () => { const result = await runProjectHandoff([ 'proj-1', diff --git a/apps/daemon/tests/headless-runs.test.ts b/apps/daemon/tests/headless-runs.test.ts index a0d7b7836ba..010e803b5fa 100644 --- a/apps/daemon/tests/headless-runs.test.ts +++ b/apps/daemon/tests/headless-runs.test.ts @@ -191,6 +191,60 @@ describe('POST /api/runs headless fallbacks', () => { expect(run2Body.assistantMessageId).toBe(clientId); }); + it('keeps a client-pinned user message before its assistant when the user PUT arrives late', async () => { + started = await startTestServer(); + const { projectId, conversationId } = await createProject( + started.url, + 'Client message pin ordering', + ); + const userMessageId = `user-${randomUUID()}`; + const assistantMessageId = `assistant-${randomUUID()}`; + const prompt = `ordered user turn ${randomUUID()}`; + const createdAt = Date.now(); + + const runResponse = await fetch(`${started.url}/api/runs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agentId: `missing-agent-${randomUUID()}`, + projectId, + conversationId, + userMessageId, + assistantMessageId, + message: prompt, + currentPrompt: prompt, + }), + }); + expect(runResponse.status).toBe(202); + + const lateUserPut = await fetch( + `${started.url}/api/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(conversationId)}/messages/${encodeURIComponent(userMessageId)}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: userMessageId, + role: 'user', + content: prompt, + createdAt, + }), + }, + ); + expect(lateUserPut.status).toBe(200); + + const messagesResponse = await fetch( + `${started.url}/api/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(conversationId)}/messages`, + ); + expect(messagesResponse.status).toBe(200); + const messagesBody = await messagesResponse.json() as { + messages: Array<{ id: string; role: string }>; + }; + expect(messagesBody.messages.map(({ id, role }) => ({ id, role }))).toEqual([ + { id: userMessageId, role: 'user' }, + { id: assistantMessageId, role: 'assistant' }, + ]); + }); + it('seeds only currentPrompt when message is a full ChatRequest transcript', async () => { started = await startTestServer(); const { projectId, conversationId } = await createProject( diff --git a/apps/daemon/tests/host-tools-open-in-route.test.ts b/apps/daemon/tests/host-tools-open-in-route.test.ts index 419662bf4b6..955f48abc94 100644 --- a/apps/daemon/tests/host-tools-open-in-route.test.ts +++ b/apps/daemon/tests/host-tools-open-in-route.test.ts @@ -68,6 +68,7 @@ beforeAll(async () => { getProject: (_db: unknown, id: string) => id === 'p1' ? { id, metadata: { baseDir: PROJECT_DIR } } : null, }, + authorizeProjectRequest: async () => true, projectFiles: { resolveProjectDir: () => PROJECT_DIR }, } as unknown as RegisterHostToolsRoutesDeps); server = app.listen(0); diff --git a/apps/daemon/tests/integrations/vela-command.test.ts b/apps/daemon/tests/integrations/vela-command.test.ts new file mode 100644 index 00000000000..bf6bedadb2c --- /dev/null +++ b/apps/daemon/tests/integrations/vela-command.test.ts @@ -0,0 +1,489 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + execFileMock, + listProcessSnapshotsMock, + stopProcessesMock, +} = vi.hoisted(() => ({ + execFileMock: vi.fn(), + listProcessSnapshotsMock: vi.fn(), + stopProcessesMock: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ execFile: execFileMock })); +vi.mock('@open-design/platform', async (importOriginal) => ({ + ...await importOriginal(), + listProcessSnapshots: listProcessSnapshotsMock, + stopProcesses: stopProcessesMock, +})); + +import { runVelaCommand } from '../../src/integrations/vela-command.js'; +import { runVelaResourceCommand } from '../../src/collab/vela-cli-resource-adapter.js'; + +function stoppedResult( + matchedPids: number[], + forcedPids: number[] = [], +) { + return { + alreadyStopped: false, + forcedPids, + matchedPids, + remainingPids: [], + stoppedPids: matchedPids, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe('runVelaCommand', () => { + beforeEach(() => { + execFileMock.mockReset(); + listProcessSnapshotsMock.mockReset(); + stopProcessesMock.mockReset(); + listProcessSnapshotsMock.mockResolvedValue([]); + stopProcessesMock.mockResolvedValue(stoppedResult([4321])); + execFileMock.mockImplementation( + ( + _command: string, + _args: string[], + _options: unknown, + callback: (error: Error | null, stdout: string) => void, + ) => { + callback(null, '{"ok":true}\n'); + return { pid: 4321 }; + }, + ); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('uses the configured AMR binary and feature-test login profile', async () => { + const stdout = await runVelaCommand(['resource', 'head', 'project-1'], { + env: { + ...process.env, + VELA_BIN: process.execPath, + OPEN_DESIGN_AMR_PROFILE: 'feature-test', + OD_DATA_DIR: '', + }, + }); + + expect(stdout).toBe('{"ok":true}\n'); + const [command, args, options] = execFileMock.mock.calls[0] as [ + string, + string[], + { env: NodeJS.ProcessEnv }, + ]; + expect(command).toBe(process.execPath); + expect(args).toEqual(['resource', 'head', 'project-1']); + expect(options.env.VELA_PROFILE).toBe('feature-test'); + expect(options.env.AMR_CLIENT_SOURCE).toBe('open_design'); + }); + + it('delivers successful stderr diagnostics without changing stdout', async () => { + const onStderr = vi.fn(() => { + throw new Error('diagnostic observer failed'); + }); + execFileMock.mockImplementationOnce( + ( + _command: string, + _args: string[], + _options: unknown, + callback: ( + error: Error | null, + stdout: string, + stderr: string, + ) => void, + ) => { + callback( + null, + '{"version":3}\n', + '{"event":"resource_pull_profile","schemaVersion":1}\n', + ); + return { pid: 4321 }; + }, + ); + + await expect( + runVelaCommand(['resource', 'pull', 'project-1'], { + env: { + ...process.env, + VELA_BIN: process.execPath, + OD_DATA_DIR: '', + }, + onStderr, + }), + ).resolves.toBe('{"version":3}\n'); + expect(onStderr).toHaveBeenCalledWith( + '{"event":"resource_pull_profile","schemaVersion":1}\n', + ); + }); + + it('keeps the Settings-backed AMR binary authoritative over inherited VELA_BIN', async () => { + const dataDir = mkdtempSync(path.join(tmpdir(), 'od-vela-command-')); + try { + writeFileSync( + path.join(dataDir, 'app-config.json'), + JSON.stringify({ + agentCliEnv: { amr: { VELA_BIN: process.execPath } }, + }), + ); + + await runVelaCommand(['team-projects', 'list'], { + env: { + ...process.env, + VELA_BIN: '/missing/inherited/vela', + OD_DATA_DIR: dataDir, + }, + }); + + expect(execFileMock.mock.calls[0]?.[0]).toBe(process.execPath); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } + }); + + it('lets explicit per-command configuration override inherited resolution', async () => { + await runVelaCommand(['billing', 'summary'], { + env: { + ...process.env, + VELA_BIN: '/missing/inherited/vela', + OD_DATA_DIR: '', + }, + configuredEnv: { VELA_BIN: process.execPath }, + }); + + expect(execFileMock.mock.calls[0]?.[0]).toBe(process.execPath); + }); + + it('hard-terminates an ignored-SIGTERM process tree before rejecting a deadline', async () => { + vi.useFakeTimers(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + let callback!: (error: Error | null, stdout: string) => void; + execFileMock.mockImplementation( + ( + _command: string, + _args: string[], + _options: unknown, + complete: (error: Error | null, stdout: string) => void, + ) => { + callback = complete; + return { pid: 4321 }; + }, + ); + listProcessSnapshotsMock.mockResolvedValue([ + { command: 'vela', pid: 4321, ppid: 1 }, + { command: 'vela-worker', pid: 4322, ppid: 4321 }, + ]); + const stopped = deferred>(); + stopProcessesMock.mockReturnValue(stopped.promise); + + const command = runVelaCommand(['resource', 'pull', 'project-1'], { + env: { + ...process.env, + VELA_BIN: process.execPath, + OD_DATA_DIR: '', + }, + timeoutMs: 50, + terminationGraceMs: 25, + }); + const rejected = vi.fn(); + void command.catch(rejected); + + await vi.advanceTimersByTimeAsync(50); + expect(stopProcessesMock).toHaveBeenCalledWith([4322, 4321], { + termGraceMs: 25, + killGraceMs: 25, + }); + callback(new Error('terminated'), ''); + await Promise.resolve(); + expect(rejected).not.toHaveBeenCalled(); + + stopped.resolve(stoppedResult([4322, 4321], [4322, 4321])); + await expect(command).rejects.toMatchObject({ + code: 'ETIMEDOUT', + name: 'TimeoutError', + }); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining( + 'phase=completed reason=timeout timeoutMs=50 childPid=4321 forced=2 remaining=0', + ), + ); + }); + + it('settles only once when callback, abort, and timeout race', async () => { + vi.useFakeTimers(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const controller = new AbortController(); + let callback!: (error: Error | null, stdout: string) => void; + execFileMock.mockImplementation( + ( + _command: string, + _args: string[], + _options: unknown, + complete: (error: Error | null, stdout: string) => void, + ) => { + callback = complete; + return { pid: 4321 }; + }, + ); + const stopped = deferred>(); + stopProcessesMock.mockReturnValue(stopped.promise); + + const command = runVelaCommand(['resource', 'pull', 'project-1'], { + env: { + ...process.env, + VELA_BIN: process.execPath, + OD_DATA_DIR: '', + }, + timeoutMs: 50, + signal: controller.signal, + }); + const fulfilled = vi.fn(); + const rejected = vi.fn(); + void command.then(fulfilled, rejected); + + await vi.advanceTimersByTimeAsync(50); + controller.abort(new Error('late abort')); + callback(null, '{"version":2}\n'); + await Promise.resolve(); + expect(stopProcessesMock).toHaveBeenCalledTimes(1); + expect(fulfilled).not.toHaveBeenCalled(); + expect(rejected).not.toHaveBeenCalled(); + + stopped.resolve(stoppedResult([4321])); + await expect(command).rejects.toMatchObject({ + code: 'ETIMEDOUT', + name: 'TimeoutError', + }); + expect(fulfilled).not.toHaveBeenCalled(); + expect(rejected).toHaveBeenCalledTimes(1); + }); + + it('aborts before spawn without launching a child', async () => { + const controller = new AbortController(); + controller.abort(new Error('cancelled')); + + await expect(runVelaCommand(['resource', 'pull', 'project-1'], { + env: { + ...process.env, + VELA_BIN: process.execPath, + OD_DATA_DIR: '', + }, + signal: controller.signal, + })).rejects.toMatchObject({ + code: 'ABORT_ERR', + name: 'AbortError', + }); + expect(execFileMock).not.toHaveBeenCalled(); + }); + + it('waits for confirmed termination when an active command is aborted', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const controller = new AbortController(); + let callback!: (error: Error | null, stdout: string) => void; + execFileMock.mockImplementation( + ( + _command: string, + _args: string[], + _options: unknown, + complete: (error: Error | null, stdout: string) => void, + ) => { + callback = complete; + return { pid: 4321 }; + }, + ); + const stopped = deferred>(); + stopProcessesMock.mockReturnValue(stopped.promise); + const command = runVelaCommand(['resource', 'pull', 'project-1'], { + env: { + ...process.env, + VELA_BIN: process.execPath, + OD_DATA_DIR: '', + }, + signal: controller.signal, + }); + const rejected = vi.fn(); + void command.catch(rejected); + + controller.abort(new Error('cancelled')); + callback(null, '{"version":2}\n'); + await Promise.resolve(); + expect(rejected).not.toHaveBeenCalled(); + + stopped.resolve(stoppedResult([4321])); + await expect(command).rejects.toMatchObject({ + code: 'ABORT_ERR', + name: 'AbortError', + }); + }); + + it('bounds production resource pulls at 30s but leaves head commands unbounded', async () => { + vi.useFakeTimers(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.stubEnv('VELA_BIN', process.execPath); + vi.stubEnv('OD_DATA_DIR', ''); + let callback!: (error: Error | null, stdout: string) => void; + execFileMock.mockImplementation( + ( + _command: string, + _args: string[], + _options: unknown, + complete: (error: Error | null, stdout: string) => void, + ) => { + callback = complete; + return { pid: 4321 }; + }, + ); + + const head = runVelaResourceCommand([ + 'head', + 'resource-1', + '--ref', + 'published', + '--json', + ], 'workspace-1'); + await vi.advanceTimersByTimeAsync(60_000); + expect(stopProcessesMock).not.toHaveBeenCalled(); + callback(null, '{"version":1}\n'); + await expect(head).resolves.toBe('{"version":1}\n'); + + const pull = runVelaResourceCommand([ + 'pull', + 'project', + 'resource-1', + '/tmp/project-1', + '--ref', + 'published', + '--json', + ], 'workspace-1'); + const pullRejection = expect(pull).rejects.toMatchObject({ + code: 'ETIMEDOUT', + name: 'TimeoutError', + }); + await vi.advanceTimersByTimeAsync(29_999); + expect(stopProcessesMock).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(stopProcessesMock).toHaveBeenCalledTimes(1); + await pullRejection; + + const options = execFileMock.mock.calls[0]?.[2] as { + timeout?: number; + signal?: AbortSignal; + }; + expect(options.timeout).toBeUndefined(); + expect(options.signal).toBeUndefined(); + }); + + it('enables Vela pull profiling only behind the OD opt-in', async () => { + vi.stubEnv('OD_COLLAB_PULL_PROFILE', '1'); + vi.stubEnv('VELA_BIN', process.execPath); + vi.stubEnv('OD_DATA_DIR', ''); + const info = vi.spyOn(console, 'info').mockImplementation(() => {}); + execFileMock.mockImplementationOnce( + ( + _command: string, + _args: string[], + _options: unknown, + callback: ( + error: Error | null, + stdout: string, + stderr: string, + ) => void, + ) => { + callback( + null, + '{"version":3}\n', + `${JSON.stringify({ + event: 'resource_pull_profile', + schemaVersion: 1, + startedAt: '2026-07-26T00:00:00.000Z', + finishedAt: '2026-07-26T00:00:02.500Z', + success: true, + kind: 'project', + resourceId: 'project-content-project-1', + ref: 'published', + totalMs: 2500, + phases: [], + })}\n`, + ); + return { pid: 4321 }; + }, + ); + + await expect( + runVelaResourceCommand([ + 'pull', + 'project', + 'project-content-project-1', + '/tmp/project-1', + '--ref', + 'published', + '--json', + ], 'workspace-1'), + ).resolves.toBe('{"version":3}\n'); + + const options = execFileMock.mock.calls[0]?.[2] as { + env: NodeJS.ProcessEnv; + }; + expect(options.env.VELA_RESOURCE_PULL_PROFILE).toBe('1'); + expect(info).toHaveBeenCalledWith( + expect.stringContaining('"phase":"vela-child-done"'), + ); + }); + + it('does not force Vela profiling or log stderr by default', async () => { + vi.stubEnv('OD_COLLAB_PULL_PROFILE', ''); + vi.stubEnv('VELA_RESOURCE_PULL_PROFILE', ''); + vi.stubEnv('VELA_BIN', process.execPath); + vi.stubEnv('OD_DATA_DIR', ''); + const info = vi.spyOn(console, 'info').mockImplementation(() => {}); + execFileMock.mockImplementationOnce( + ( + _command: string, + _args: string[], + _options: unknown, + callback: ( + error: Error | null, + stdout: string, + stderr: string, + ) => void, + ) => { + callback( + null, + '{"version":3}\n', + '{"event":"resource_pull_profile","schemaVersion":1}\n', + ); + return { pid: 4321 }; + }, + ); + + await runVelaResourceCommand([ + 'pull', + 'project', + 'project-content-project-1', + '/tmp/project-1', + '--ref', + 'published', + '--json', + ], 'workspace-1'); + + const options = execFileMock.mock.calls[0]?.[2] as { + env: NodeJS.ProcessEnv; + }; + expect(options.env.VELA_RESOURCE_PULL_PROFILE).not.toBe('1'); + expect(info).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/daemon/tests/integrations/vela-errors.test.ts b/apps/daemon/tests/integrations/vela-errors.test.ts index 70c445efc1e..47eb8b82ddb 100644 --- a/apps/daemon/tests/integrations/vela-errors.test.ts +++ b/apps/daemon/tests/integrations/vela-errors.test.ts @@ -9,6 +9,18 @@ import { } from '../../src/integrations/vela-errors.js'; describe('AMR account failure classification', () => { + // The recharge link the daemon hands to the client is a real destination a + // user clicks. Product retired the console's wallet page — balance and manual + // top-up report on its dashboard now (vela #1055) — so pin the literal here: + // every other assertion in this file references the constant symbolically and + // would keep passing while pointing users at a surface the product no longer + // navigates to. + it('points the recharge link at the console dashboard, not a wallet page', () => { + expect(DEFAULT_AMR_RECHARGE_URL).toBe( + 'https://open-design.ai/amr/dashboard?source=open_design', + ); + }); + it('classifies insufficient_balance JSON-RPC failures as rechargeable AMR balance errors', () => { const failure = classifyAmrAccountFailure( 'JSON-RPC error -32000: {"code":"insufficient_balance","message":"insufficient balance"}', diff --git a/apps/daemon/tests/integrations/vela-wallet.test.ts b/apps/daemon/tests/integrations/vela-wallet.test.ts new file mode 100644 index 00000000000..6400f1cc230 --- /dev/null +++ b/apps/daemon/tests/integrations/vela-wallet.test.ts @@ -0,0 +1,108 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createVelaWalletSnapshotReader } from '../../src/integrations/vela-wallet.js'; + +let originalHome: string | undefined; +let originalProfile: string | undefined; +let testHome: string; + +function seedWalletLogin(): void { + const configFile = path.join(testHome, '.amr', 'config.json'); + mkdirSync(path.dirname(configFile), { recursive: true }); + writeFileSync( + configFile, + JSON.stringify({ + profiles: { + local: { + apiUrl: 'https://wallet.example.test', + controlKey: 'ck-wallet-unit', + runtimeKey: 'rt-wallet-unit', + user: { + id: 'wallet-unit-user', + email: 'wallet-unit@example.com', + plan: 'plus', + }, + }, + }, + }), + 'utf8', + ); +} + +beforeEach(() => { + originalHome = process.env.HOME; + originalProfile = process.env.OPEN_DESIGN_AMR_PROFILE; + testHome = mkdtempSync(path.join(tmpdir(), 'od-vela-wallet-')); + process.env.HOME = testHome; + process.env.OPEN_DESIGN_AMR_PROFILE = 'local'; + seedWalletLogin(); +}); + +afterEach(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalProfile === undefined) delete process.env.OPEN_DESIGN_AMR_PROFILE; + else process.env.OPEN_DESIGN_AMR_PROFILE = originalProfile; + rmSync(testHome, { recursive: true, force: true }); +}); + +describe('createVelaWalletSnapshotReader balance validation', () => { + it.each([ + { label: 'missing', balanceUsd: undefined }, + { label: 'numeric', balanceUsd: 20 }, + { label: 'negative', balanceUsd: '-1.00' }, + { label: 'NaN', balanceUsd: 'NaN' }, + { label: 'infinite', balanceUsd: 'Infinity' }, + { label: 'exponent', balanceUsd: '1e2' }, + ])('rejects a $label balance when there is no valid cached snapshot', async ({ balanceUsd }) => { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify(balanceUsd === undefined ? {} : { balanceUsd }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + const reader = createVelaWalletSnapshotReader({ fetch: fetchMock as typeof fetch }); + + await expect(reader.read()).resolves.toMatchObject({ + status: 'unavailable', + balanceUsd: null, + source: 'unavailable', + stale: false, + error: { code: 'upstream' }, + }); + }); + + it('serves the last valid snapshot as stale when a refresh returns an invalid balance', async () => { + let responseBody: unknown = { balanceUsd: '20.00' }; + const fetchMock = vi.fn(async () => + new Response(JSON.stringify(responseBody), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + const reader = createVelaWalletSnapshotReader({ + fetch: fetchMock as typeof fetch, + ttlMs: 60_000, + }); + + await expect(reader.read()).resolves.toMatchObject({ + status: 'available', + balanceUsd: '20.00', + source: 'vela_api', + stale: false, + }); + responseBody = { balanceUsd: '-1.00' }; + + await expect(reader.read({ refresh: true })).resolves.toMatchObject({ + status: 'available', + balanceUsd: '20.00', + source: 'daemon_cache', + stale: true, + error: { code: 'upstream' }, + }); + }); +}); diff --git a/apps/daemon/tests/integrations/vela.routes.test.ts b/apps/daemon/tests/integrations/vela.routes.test.ts index 4b8a99c0f87..0c115c2643f 100644 --- a/apps/daemon/tests/integrations/vela.routes.test.ts +++ b/apps/daemon/tests/integrations/vela.routes.test.ts @@ -213,44 +213,57 @@ beforeEach(() => { process.env.VELA_PROFILE = 'prod'; }); -afterEach(() => { - if (originalHome === undefined) delete process.env.HOME; - else process.env.HOME = originalHome; - delete process.env.OPEN_DESIGN_AMR_PROFILE; - delete process.env.VELA_PROFILE; - delete process.env.FAKE_VELA_LOGIN_DELAY_MS; - delete process.env.FAKE_VELA_LOGIN_FAIL; - delete process.env.FAKE_VELA_LOGIN_FAIL_WITHOUT_API_URL; - delete process.env.FAKE_VELA_LOGIN_FAIL_WITHOUT_API_URL_DELAY_MS; - delete process.env.FAKE_VELA_LOGIN_EXIT_ZERO_WITHOUT_API_URL_DELAY_MS; - delete process.env.OD_AMR_LOGIN_ACTIVATION_GRACE_MS; - delete process.env.FAKE_VELA_LOGIN_USER_EMAIL; - delete process.env.FAKE_VELA_LOGIN_USER_PLAN; - delete process.env.FAKE_VELA_BILLING_TIER; - delete process.env.FAKE_VELA_BILLING_BALANCE_USD; - delete process.env.FAKE_VELA_BILLING_LOG; - delete process.env.FAKE_VELA_BILLING_DELAY_MS; - delete process.env.FAKE_VELA_BILLING_UNKNOWN_COMMAND; - delete process.env.FAKE_VELA_MODEL_LIST_JSON; - delete process.env.FAKE_VELA_MODEL_PRESET_JSON; - delete process.env.FAKE_VELA_ENV_DUMP_PATH; - delete process.env.FAKE_VELA_LOGIN_INVOCATION_LOG; - delete process.env.FAKE_VELA_LOGIN_ACTIVATION_AFTER_PARENT_EXIT_MS; - delete process.env.FAKE_VELA_LOGIN_PARENT_EXIT_DELAY_MS; - delete process.env.FAKE_VELA_LOGIN_ACTIVATION_THEN_EXIT_DELAY_MS; - delete process.env.FAKE_VELA_LOGIN_ACTIVATION_THEN_EXIT_CODE; - delete process.env.OD_PUBLIC_BASE_URL; - delete process.env.VELA_RUNTIME_KEY; - delete process.env.VELA_LINK_URL; - delete process.env.OPEN_DESIGN_AMR_ANALYTICS_URL; - delete process.env.OPEN_DESIGN_AMR_ANALYTICS_ENV; - delete process.env.OD_AMR_WALLET_FETCH_TIMEOUT_MS; - rmSync(tmpHome, { - recursive: true, - force: true, - maxRetries: 5, - retryDelay: 50, - }); +afterEach(async () => { + try { + // `/login` acknowledges after the child reaches its activation boundary, + // not necessarily after the child exits. Do not let that process retain + // this test's HOME/env or trip the next test's in-flight guard. + const status = await getJson<{ loginInFlight: boolean }>( + `${baseUrl}/api/integrations/vela/status`, + ); + if (status.body.loginInFlight) { + await postJson(`${baseUrl}/api/integrations/vela/login/cancel`); + await waitForVelaLoginIdle(); + } + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + delete process.env.OPEN_DESIGN_AMR_PROFILE; + delete process.env.VELA_PROFILE; + delete process.env.FAKE_VELA_LOGIN_DELAY_MS; + delete process.env.FAKE_VELA_LOGIN_FAIL; + delete process.env.FAKE_VELA_LOGIN_FAIL_WITHOUT_API_URL; + delete process.env.FAKE_VELA_LOGIN_FAIL_WITHOUT_API_URL_DELAY_MS; + delete process.env.FAKE_VELA_LOGIN_EXIT_ZERO_WITHOUT_API_URL_DELAY_MS; + delete process.env.OD_AMR_LOGIN_ACTIVATION_GRACE_MS; + delete process.env.FAKE_VELA_LOGIN_USER_EMAIL; + delete process.env.FAKE_VELA_LOGIN_USER_PLAN; + delete process.env.FAKE_VELA_BILLING_TIER; + delete process.env.FAKE_VELA_BILLING_BALANCE_USD; + delete process.env.FAKE_VELA_BILLING_LOG; + delete process.env.FAKE_VELA_BILLING_DELAY_MS; + delete process.env.FAKE_VELA_BILLING_UNKNOWN_COMMAND; + delete process.env.FAKE_VELA_MODEL_LIST_JSON; + delete process.env.FAKE_VELA_MODEL_PRESET_JSON; + delete process.env.FAKE_VELA_ENV_DUMP_PATH; + delete process.env.FAKE_VELA_LOGIN_INVOCATION_LOG; + delete process.env.FAKE_VELA_LOGIN_ACTIVATION_AFTER_PARENT_EXIT_MS; + delete process.env.FAKE_VELA_LOGIN_PARENT_EXIT_DELAY_MS; + delete process.env.FAKE_VELA_LOGIN_ACTIVATION_THEN_EXIT_DELAY_MS; + delete process.env.FAKE_VELA_LOGIN_ACTIVATION_THEN_EXIT_CODE; + delete process.env.OD_PUBLIC_BASE_URL; + delete process.env.VELA_RUNTIME_KEY; + delete process.env.VELA_LINK_URL; + delete process.env.OPEN_DESIGN_AMR_ANALYTICS_URL; + delete process.env.OPEN_DESIGN_AMR_ANALYTICS_ENV; + delete process.env.OD_AMR_WALLET_FETCH_TIMEOUT_MS; + rmSync(tmpHome, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 50, + }); + } }); describe('GET /api/integrations/vela/wallet', () => { @@ -794,10 +807,14 @@ describe('GET /api/integrations/vela/status', () => { expect(body.user?.name).toBe('杨瑾龙'); }); - it('blocks the first signed-in /status on a cold cache and surfaces the fetched plan + balance', async () => { - // Regression: the new account surfaces read /status once and do not - // re-poll, so a cold cache must resolve live billing BEFORE the first - // response — otherwise plan/balance stay hidden until the user refocuses. + it('resolves live billing on a cold cache within the wait budget and surfaces the fetched plan + balance', async () => { + // Regression: several account surfaces read /status once per mount/open + // and do not re-poll on a fixed interval, so a cold cache should still + // resolve live billing before the first response WHEN billing answers + // promptly (the common case — fake-vela here has no delay configured). + // A billing read slower than the wait budget is covered separately by + // "does not block /status on a cold cache when billing is slow, …" below, + // which asserts the response is never held hostage to a slow probe. clearAllVelaLiveAccounts(); process.env.FAKE_VELA_BILLING_TIER = 'plus'; process.env.FAKE_VELA_BILLING_BALANCE_USD = '247.51'; @@ -816,6 +833,48 @@ describe('GET /api/integrations/vela/status', () => { expect(body.account?.balanceUsd).toBe('247.51'); }); + it('does not block /status on a cold cache when billing is slow, and applies the account on a later poll once it resolves', async () => { + // Regression for the "sign out then sign back in" cold-cache path: every + // logout clears the live-account cache (clearAllVelaLiveAccounts), so a + // slow (or hung) `vela billing summary` must not delay the login-status + // check itself — that check is what the avatar/menu/settings surfaces + // need FIRST. The fetch is left running in the background and the next + // /status read (every consumer already re-reads on mount, window + // focus/visibilitychange, or the sign-in event) picks up the resolved + // plan/balance instead. + clearAllVelaLiveAccounts(); + process.env.FAKE_VELA_BILLING_TIER = 'plus'; + process.env.FAKE_VELA_BILLING_BALANCE_USD = '19.99'; + process.env.FAKE_VELA_BILLING_DELAY_MS = '2000'; + seedLogin('local', { + user: { id: 'slow-billing-1', email: 'slow-billing@example.com', plan: undefined }, + }); + + const startedAt = Date.now(); + const first = await getJson<{ + loggedIn: boolean; + account?: { plan?: string; balanceUsd?: string | null }; + }>(`${baseUrl}/api/integrations/vela/status`); + const elapsedMs = Date.now() - startedAt; + + expect(first.body.loggedIn).toBe(true); + // Well under the 2s billing delay — proves /status did not block on it. + expect(elapsedMs).toBeLessThan(1800); + expect(first.body.account).toBeUndefined(); + + // Give the still-running background billing fetch time to resolve and + // populate the live-account cache. + await new Promise((resolve) => setTimeout(resolve, 2300)); + + const second = await getJson<{ + loggedIn: boolean; + account?: { plan?: string; balanceUsd?: string | null }; + }>(`${baseUrl}/api/integrations/vela/status`); + expect(second.body.loggedIn).toBe(true); + expect(second.body.account?.plan).toBe('plus'); + expect(second.body.account?.balanceUsd).toBe('19.99'); + }); + it('normalizes a successful billing summary without a tier to free (upgradeable)', async () => { // membershipTier is omitted for free accounts; a successful read must still // surface a concrete plan so the UI shows it AND keeps the Upgrade CTA. @@ -1928,6 +1987,298 @@ describe('ALL /api/integrations/vela/api-proxy/*', () => { requestSpy.mockRestore(); } }); + + it('preserves a valid Workspace scope while stripping request hop-by-hop headers', async () => { + let forwardedHeaders: Record | undefined; + const requestSpy = vi.spyOn(https, 'request').mockImplementation(((_target, options, callback) => { + const upstream = new PassThrough() as any; + upstream.on('finish', () => { + forwardedHeaders = options?.headers as Record; + const upstreamRes = new PassThrough() as any; + upstreamRes.statusCode = 200; + upstreamRes.headers = { 'content-type': 'application/json' }; + callback?.(upstreamRes); + upstreamRes.end(JSON.stringify({ ok: true })); + }); + upstream.setTimeout = () => upstream; + return upstream; + }) as typeof https.request); + const daemonUrl = new URL(baseUrl); + + try { + const status = await new Promise((resolve, reject) => { + const request = http.request( + { + hostname: daemonUrl.hostname, + port: daemonUrl.port, + method: 'GET', + path: '/api/integrations/vela/api-proxy/api/v1/workspaces/workspace_team-1/billing', + headers: { + 'x-vela-workspace-id': 'workspace_team-1', + connection: 'x-test-hop', + 'keep-alive': 'timeout=5', + 'proxy-authorization': 'Basic test-only', + te: 'trailers', + trailer: 'x-test-checksum', + 'transfer-encoding': 'chunked', + 'x-test-hop': 'drop-me', + }, + }, + (response) => { + response.resume(); + response.once('end', () => resolve(response.statusCode ?? 0)); + }, + ); + request.on('error', reject); + request.end(); + }); + + expect(status).toBe(200); + expect(forwardedHeaders?.['x-vela-workspace-id']).toBe('workspace_team-1'); + expect(forwardedHeaders).not.toHaveProperty('connection'); + expect(forwardedHeaders).not.toHaveProperty('keep-alive'); + expect(forwardedHeaders).not.toHaveProperty('proxy-authorization'); + expect(forwardedHeaders).not.toHaveProperty('te'); + expect(forwardedHeaders).not.toHaveProperty('trailer'); + expect(forwardedHeaders).not.toHaveProperty('transfer-encoding'); + expect(forwardedHeaders).not.toHaveProperty('upgrade'); + expect(forwardedHeaders).not.toHaveProperty('x-test-hop'); + } finally { + requestSpy.mockRestore(); + } + }); + + it('rejects normalized path escapes and malformed Workspace scope before proxying', async () => { + let upstreamRequestCount = 0; + const requestSpy = vi.spyOn(https, 'request').mockImplementation(((_target, _options, callback) => { + upstreamRequestCount += 1; + const upstream = new PassThrough() as any; + upstream.on('finish', () => { + const upstreamRes = new PassThrough() as any; + upstreamRes.statusCode = 200; + upstreamRes.headers = { 'content-type': 'application/json' }; + callback?.(upstreamRes); + upstreamRes.end(JSON.stringify({ unexpectedlyProxied: true })); + }); + upstream.setTimeout = () => upstream; + return upstream; + }) as typeof https.request); + const daemonUrl = new URL(baseUrl); + const rawGet = (pathName: string, headers?: http.OutgoingHttpHeaders) => + new Promise<{ status: number; body: unknown }>((resolve, reject) => { + const request = http.request( + { + hostname: daemonUrl.hostname, + port: daemonUrl.port, + method: 'GET', + path: pathName, + headers, + }, + (response) => { + const chunks: Buffer[] = []; + response.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + response.on('end', () => { + resolve({ + status: response.statusCode ?? 0, + body: JSON.parse(Buffer.concat(chunks).toString('utf8')), + }); + }); + }, + ); + request.on('error', reject); + request.end(); + }); + + try { + const escaped = await rawGet( + '/api/integrations/vela/api-proxy/api/v1/%2e%2e/private', + ); + const invalid = await rawGet( + '/api/integrations/vela/api-proxy/api/v1/wallet/balance', + { 'x-vela-workspace-id': ['workspace/escape'] }, + ); + const duplicate = await rawGet( + '/api/integrations/vela/api-proxy/api/v1/wallet/balance', + { 'x-vela-workspace-id': ['workspace-a', 'workspace-b'] }, + ); + const connectionNominated = await rawGet( + '/api/integrations/vela/api-proxy/api/v1/wallet/balance', + { + connection: 'x-vela-workspace-id', + 'x-vela-workspace-id': 'workspace-team', + }, + ); + + expect(escaped).toEqual({ status: 404, body: { error: 'unknown_amr_api_proxy_path' } }); + expect(invalid).toEqual({ status: 400, body: { error: 'invalid_workspace_id' } }); + expect(duplicate).toEqual({ status: 400, body: { error: 'invalid_workspace_id' } }); + expect(connectionNominated).toEqual({ + status: 400, + body: { error: 'invalid_workspace_id' }, + }); + expect(upstreamRequestCount).toBe(0); + } finally { + requestSpy.mockRestore(); + } + }); + + it('strips upstream hop-by-hop response headers while preserving billing metadata', async () => { + const requestSpy = vi.spyOn(https, 'request').mockImplementation(((_target, _options, callback) => { + const upstream = new PassThrough() as any; + upstream.on('finish', () => { + const upstreamRes = new PassThrough() as any; + upstreamRes.statusCode = 200; + upstreamRes.headers = { + connection: 'x-upstream-hop', + 'x-upstream-hop': 'drop-me', + 'keep-alive': 'timeout=5', + 'proxy-authenticate': 'Basic', + 'proxy-authorization': 'Basic test-only', + te: 'trailers', + trailer: 'x-test-checksum', + 'transfer-encoding': 'chunked', + upgrade: 'websocket', + 'x-request-id': 'billing-request-1', + 'content-type': 'application/json', + }; + callback?.(upstreamRes); + upstreamRes.end(JSON.stringify({ balanceUsd: '120.00' })); + }); + upstream.setTimeout = () => upstream; + return upstream; + }) as typeof https.request); + + try { + const response = await fetch( + `${baseUrl}/api/integrations/vela/api-proxy/api/v1/wallet/balance`, + { headers: { 'x-vela-workspace-id': 'workspace-team' } }, + ); + + expect(response.status).toBe(200); + expect(response.headers.get('x-request-id')).toBe('billing-request-1'); + expect(response.headers.get('connection')).not.toBe('x-upstream-hop'); + expect(response.headers.get('x-upstream-hop')).toBeNull(); + expect(response.headers.get('keep-alive')).not.toBe('timeout=5'); + for (const name of [ + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'upgrade', + ]) { + expect(response.headers.get(name), name).toBeNull(); + } + } finally { + requestSpy.mockRestore(); + } + }); + + it('destroys the upstream request when a streaming Workspace upload is aborted', async () => { + let upstreamRequest: PassThrough | undefined; + let markUpstreamCreated: (() => void) | undefined; + let markUpstreamDestroyed: (() => void) | undefined; + const upstreamCreated = new Promise((resolve) => { + markUpstreamCreated = resolve; + }); + const upstreamDestroyed = new Promise((resolve) => { + markUpstreamDestroyed = resolve; + }); + const requestSpy = vi.spyOn(https, 'request').mockImplementation((() => { + upstreamRequest = new PassThrough(); + upstreamRequest.once('close', () => markUpstreamDestroyed?.()); + (upstreamRequest as any).setTimeout = () => upstreamRequest; + markUpstreamCreated?.(); + return upstreamRequest as any; + }) as typeof https.request); + const daemonUrl = new URL(baseUrl); + + try { + const upload = http.request({ + hostname: daemonUrl.hostname, + port: daemonUrl.port, + method: 'POST', + path: '/api/integrations/vela/api-proxy/api/v1/workspaces/import', + headers: { + 'content-type': 'application/octet-stream', + 'content-length': '1024', + 'x-vela-workspace-id': 'workspace-upload', + }, + }); + upload.on('error', () => {}); + const uploadClosed = new Promise((resolve) => upload.once('close', resolve)); + upload.write(Buffer.alloc(64, 1)); + await upstreamCreated; + upload.destroy(); + await uploadClosed; + await upstreamDestroyed; + + expect(upstreamRequest?.destroyed).toBe(true); + } finally { + upstreamRequest?.destroy(); + requestSpy.mockRestore(); + } + }); + + it('destroys the upstream request when the downstream response closes', async () => { + let upstreamRequest: PassThrough | undefined; + let upstreamResponse: PassThrough | undefined; + let downstreamRequest: http.ClientRequest | undefined; + let markUpstreamDestroyed: (() => void) | undefined; + const upstreamDestroyed = new Promise((resolve) => { + markUpstreamDestroyed = resolve; + }); + let markResponseStarted: (() => void) | undefined; + const responseStarted = new Promise((resolve) => { + markResponseStarted = resolve; + }); + const requestSpy = vi.spyOn(https, 'request').mockImplementation(((_target, _options, callback) => { + upstreamRequest = new PassThrough(); + upstreamRequest.once('close', () => markUpstreamDestroyed?.()); + upstreamRequest.on('finish', () => { + upstreamResponse = new PassThrough(); + (upstreamResponse as any).statusCode = 200; + (upstreamResponse as any).headers = { 'content-type': 'application/json' }; + callback?.(upstreamResponse as any); + upstreamResponse.write('{"balanceUsd":'); + markResponseStarted?.(); + }); + (upstreamRequest as any).setTimeout = () => upstreamRequest; + return upstreamRequest as any; + }) as typeof https.request); + const daemonUrl = new URL(baseUrl); + + try { + const downstreamClosed = new Promise((resolve, reject) => { + downstreamRequest = http.request( + { + hostname: daemonUrl.hostname, + port: daemonUrl.port, + method: 'GET', + path: '/api/integrations/vela/api-proxy/api/v1/wallet/balance', + headers: { 'x-vela-workspace-id': 'workspace-close' }, + }, + (response) => { + response.once('data', () => response.destroy()); + response.once('close', resolve); + }, + ); + downstreamRequest.on('error', reject); + downstreamRequest.end(); + }); + await responseStarted; + await downstreamClosed; + await upstreamDestroyed; + + expect(upstreamRequest?.destroyed).toBe(true); + } finally { + downstreamRequest?.destroy(); + upstreamRequest?.destroy(); + upstreamResponse?.destroy(); + requestSpy.mockRestore(); + } + }); }); describe('ALL /api/integrations/vela/message-center/*', () => { diff --git a/apps/daemon/tests/integrations/vela.test.ts b/apps/daemon/tests/integrations/vela.test.ts index b4bd70b4928..0b1a80b00ae 100644 --- a/apps/daemon/tests/integrations/vela.test.ts +++ b/apps/daemon/tests/integrations/vela.test.ts @@ -20,6 +20,7 @@ import { clearAllVelaLiveAccounts, forgetVelaLogin, peekVelaLiveAccount, + readVelaControlApiContext, readVelaCredentialRevision, readVelaLoginStatus, resolveAmrProfile, @@ -32,6 +33,7 @@ import { } from '../../src/integrations/vela.js'; let originalHome: string | undefined; +let originalAmrHome: string | undefined; let tmpHome: string; const HERE = path.dirname(fileURLToPath(import.meta.url)); const FAKE_VELA = path.resolve(HERE, '..', 'fixtures', 'fake-vela.mjs'); @@ -54,8 +56,10 @@ function writeLegacyVelaConfig(payload: unknown): string { beforeEach(() => { originalHome = process.env.HOME; + originalAmrHome = process.env.AMR_HOME; tmpHome = mkdtempSync(path.join(tmpdir(), 'od-vela-test-')); process.env.HOME = tmpHome; + delete process.env.AMR_HOME; delete process.env.OPEN_DESIGN_AMR_PROFILE; delete process.env.VELA_PROFILE; }); @@ -63,6 +67,8 @@ beforeEach(() => { afterEach(() => { if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; + if (originalAmrHome === undefined) delete process.env.AMR_HOME; + else process.env.AMR_HOME = originalAmrHome; rmSync(tmpHome, { recursive: true, force: true }); }); @@ -76,10 +82,11 @@ describe('resolveAmrProfile', () => { expect(resolveAmrProfile({ OPEN_DESIGN_AMR_PROFILE: 'prod' })).toBe('prod'); expect(resolveAmrProfile({ OPEN_DESIGN_AMR_PROFILE: 'local' })).toBe('local'); expect(resolveAmrProfile({ OPEN_DESIGN_AMR_PROFILE: 'test' })).toBe('test'); + expect(resolveAmrProfile({ OPEN_DESIGN_AMR_PROFILE: 'feature-test' })).toBe('feature-test'); }); - it('ignores lower-priority VELA_PROFILE values', () => { - expect(resolveAmrProfile({ VELA_PROFILE: 'local' })).toBe('prod'); + it('uses VELA_PROFILE when OPEN_DESIGN_AMR_PROFILE is unset', () => { + expect(resolveAmrProfile({ VELA_PROFILE: 'local' })).toBe('local'); expect( resolveAmrProfile({ OPEN_DESIGN_AMR_PROFILE: 'test', @@ -232,6 +239,36 @@ describe('readVelaLoginStatus', () => { expect(JSON.stringify(status)).not.toContain('ck-secret'); }); + it('reads the Vela CLI config from AMR_HOME when set', () => { + const amrHome = path.join(tmpHome, 'custom-amr-home'); + mkdirSync(amrHome, { recursive: true }); + writeFileSync( + path.join(amrHome, 'config.json'), + JSON.stringify({ + profiles: { + local: { + runtimeKey: 'rt-custom', + controlKey: 'vela_ctrl_custom', + apiUrl: 'http://127.0.0.1:18082', + user: { id: 'u-custom', email: 'custom@example.com' }, + }, + }, + }), + 'utf8', + ); + process.env.AMR_HOME = amrHome; + + const status = readVelaLoginStatus({ OPEN_DESIGN_AMR_PROFILE: 'local' }); + expect(status.loggedIn).toBe(true); + expect(status.configPath).toBe(path.join(amrHome, 'config.json')); + expect(status.user?.email).toBe('custom@example.com'); + expect(readVelaControlApiContext({ OPEN_DESIGN_AMR_PROFILE: 'local' })).toMatchObject({ + profile: 'local', + apiUrl: 'http://127.0.0.1:18082', + controlKey: 'vela_ctrl_custom', + }); + }); + it('returns loggedIn=false when the active profile is present but lacks runtimeKey', () => { writeConfig({ profiles: { @@ -448,12 +485,12 @@ describe('spawnVelaLogin', () => { } }); - it('spawns the configured vela binary and writes only the resolved AMR profile', async () => { + it('spawns the configured vela binary and writes/reads only the feature-test AMR profile', async () => { const result = await spawnVelaLogin({ baseEnv: { ...process.env, HOME: tmpHome, - OPEN_DESIGN_AMR_PROFILE: 'test', + OPEN_DESIGN_AMR_PROFILE: 'feature-test', VELA_PROFILE: 'prod', FAKE_VELA_LOGIN_USER_EMAIL: 'spawn-login@example.com', }, @@ -463,7 +500,7 @@ describe('spawnVelaLogin', () => { }); expect(result.pid).toBeGreaterThan(0); - expect(result.profile).toBe('test'); + expect(result.profile).toBe('feature-test'); const file = path.join(tmpHome, '.amr', 'config.json'); for (let i = 0; i < 20; i += 1) { @@ -472,8 +509,15 @@ describe('spawnVelaLogin', () => { } const next = JSON.parse(readFileSync(file, 'utf8')); - expect(next.profiles.test.user.email).toBe('spawn-login@example.com'); + expect(Object.keys(next.profiles)).toEqual(['feature-test']); + expect(next.profiles['feature-test'].user.email).toBe('spawn-login@example.com'); expect(next.profiles.prod).toBeUndefined(); + expect(next.profiles.test).toBeUndefined(); + expect(readVelaLoginStatus({ OPEN_DESIGN_AMR_PROFILE: 'feature-test' })).toMatchObject({ + loggedIn: true, + profile: 'feature-test', + user: { email: 'spawn-login@example.com' }, + }); }); it('spawns login with the Settings-configured AMR profile over daemon env', async () => { diff --git a/apps/daemon/tests/intent-signal-stable-prompt-cache.test.ts b/apps/daemon/tests/intent-signal-stable-prompt-cache.test.ts index 86a72eb55bc..7d36e514a24 100644 --- a/apps/daemon/tests/intent-signal-stable-prompt-cache.test.ts +++ b/apps/daemon/tests/intent-signal-stable-prompt-cache.test.ts @@ -210,10 +210,11 @@ describe('intent signals × stable prompt cache', () => { expect(await runPromptCache(url, conversationId, turn2.id)).toMatchObject({ hit: false, missReason: 'stable-prompt-changed', - // The deck signal is the only thing that moved, so attribution must name - // `intent` alone. A wider list would mean the section map cannot isolate - // a cause; `unattributed` would mean it missed this input entirely. - changedSections: ['intent'], + // The deck signal moved, and on this branch the craft section follows + // the intent signal (deck-specific craft rules swap in with the deck + // intent), so attribution names both real movers. `unattributed` would + // mean the section map missed this input entirely. + changedSections: ['intent', 'craft'], }); // t3 has no deck vocabulary of its own; the conversation latch must hold diff --git a/apps/daemon/tests/invite-continue.test.ts b/apps/daemon/tests/invite-continue.test.ts new file mode 100644 index 00000000000..cab9e3c7e2a --- /dev/null +++ b/apps/daemon/tests/invite-continue.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest'; +import { consumeInviteContinuation } from '../src/collab/invite-continue.js'; + +const SESSION = { profile: 'prod', apiUrl: 'https://vela.example', controlKey: 'ck-1', user: null, configMtimeMs: null }; + +const B_CONTEXT = { + userId: 'auth-user-1', + appUserId: 'app-user-1', + workspaceId: 'ws-team-1', + workspaceType: 'team', + workspaceMemberId: 'wm-1', + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + billingState: 'active', + planId: 'team-pro', + providerMode: 'platform_credits', + seatSummary: { seatLimit: 5, usedSeats: 2, availableSeats: 3, isSeatFull: false }, + permissions: { + canManageMembers: false, + canManageBilling: false, + canInviteMembers: false, + canManageAutoRecharge: false, + canShareProjects: true, + canWriteSyncedFiles: true, + canViewWorkspaceSettings: true, + canManageSharedResources: false, + }, +}; + +const CONSUME_BODY = { + workspaceId: 'ws-team-1', + workspaceMemberId: 'wm-1', + memberId: 'wm-1', + inviteId: 'inv-1', + currentWorkspaceContext: B_CONTEXT, +}; + +function jsonResponse(status: number, body: unknown): Response { + return { ok: status >= 200 && status < 300, status, json: async () => body } as unknown as Response; +} + +describe('consumeInviteContinuation', () => { + it('consumes the nonce with the session bearer and maps the returned context', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(200, CONSUME_BODY)) as unknown as typeof fetch; + const out = await consumeInviteContinuation('nonce-1', { fetch: fetchImpl, readSession: () => SESSION }); + expect(out.ok).toBe(true); + if (out.ok) { + expect(out.context?.workspaceMemberId).toBe('wm-1'); + expect(out.context?.teamId).toBe('ws-team-1'); + expect(out.workspaceMemberId).toBe('wm-1'); + } + const [url, init] = (fetchImpl as unknown as ReturnType).mock.calls[0]!; + expect(String(url)).toBe('https://vela.example/api/v1/workspace-invites/continuations/nonce-1/consume'); + expect((init as RequestInit).method).toBe('POST'); + expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer ck-1' }); + }); + + it('returns no_session without calling B when signed out', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(200, CONSUME_BODY)) as unknown as typeof fetch; + const out = await consumeInviteContinuation('nonce-1', { fetch: fetchImpl, readSession: () => null }); + expect(out).toEqual({ ok: false, status: 401, error: 'no_session' }); + expect((fetchImpl as unknown as ReturnType).mock.calls.length).toBe(0); + }); + + it("maps B's rejections verbatim (403 mismatch, 409 consumed, 410 expired)", async () => { + for (const status of [403, 409, 410]) { + const out = await consumeInviteContinuation('n', { + fetch: (async () => jsonResponse(status, { error: 'x' })) as unknown as typeof fetch, + readSession: () => SESSION, + }); + expect(out).toEqual({ ok: false, status, error: `continuation_${status}` }); + } + }); + + it('degrades to 502 on a transport error and 400 on an empty nonce', async () => { + const broken = await consumeInviteContinuation('n', { + fetch: (async () => { + throw new Error('network down'); + }) as unknown as typeof fetch, + readSession: () => SESSION, + }); + expect(broken).toEqual({ ok: false, status: 502, error: 'continuation_unreachable' }); + + const empty = await consumeInviteContinuation(' ', { readSession: () => SESSION }); + expect(empty).toEqual({ ok: false, status: 400, error: 'missing_nonce' }); + }); +}); diff --git a/apps/daemon/tests/invite-create.test.ts b/apps/daemon/tests/invite-create.test.ts new file mode 100644 index 00000000000..65018814ba2 --- /dev/null +++ b/apps/daemon/tests/invite-create.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createWorkspaceInvite } from '../src/collab/invite-create.js'; + +const SESSION = { + profile: 'prod', + apiUrl: 'https://vela.example', + controlKey: 'ck-1', + user: null, + configMtimeMs: null, +}; + +function jsonResponse(status: number, body: unknown): Response { + return { ok: status >= 200 && status < 300, status, json: async () => body } as unknown as Response; +} + +describe('createWorkspaceInvite', () => { + it('POSTs to B with the session bearer + { email, role, workspaceId } body', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(201, { inviteId: 'inv-9' })) as unknown as typeof fetch; + const out = await createWorkspaceInvite( + { email: ' new@company.com ', role: 'admin', workspaceId: 'ws-team-1' }, + { fetch: fetchImpl, readSession: () => SESSION }, + ); + expect(out).toEqual({ ok: true, inviteId: 'inv-9' }); + + const [url, init] = (fetchImpl as unknown as ReturnType).mock.calls[0]!; + expect(String(url)).toBe('https://vela.example/api/v1/workspaces/ws-team-1/invites'); + const request = init as RequestInit; + expect(request.method).toBe('POST'); + expect(request.headers).toMatchObject({ authorization: 'Bearer ck-1' }); + expect(JSON.parse(String(request.body))).toEqual({ + invitedEmail: 'new@company.com', + role: 'admin', + }); + }); + + it('returns no_session without calling B when signed out', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(201, {})) as unknown as typeof fetch; + const out = await createWorkspaceInvite( + { email: 'new@company.com', role: 'member', workspaceId: 'ws-team-1' }, + { fetch: fetchImpl, readSession: () => null }, + ); + expect(out).toEqual({ ok: false, status: 401, error: 'no_session' }); + expect((fetchImpl as unknown as ReturnType).mock.calls.length).toBe(0); + }); + + it('returns no_workspace without calling B when there is no workspace to scope to', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(201, {})) as unknown as typeof fetch; + const out = await createWorkspaceInvite( + { email: 'new@company.com', role: 'member', workspaceId: ' ' }, + { fetch: fetchImpl, readSession: () => SESSION }, + ); + expect(out).toEqual({ ok: false, status: 409, error: 'no_workspace' }); + expect((fetchImpl as unknown as ReturnType).mock.calls.length).toBe(0); + }); + + it("degrades to a typed create_ when B's endpoint is absent (404) or forbids (403)", async () => { + for (const status of [404, 403]) { + const out = await createWorkspaceInvite( + { email: 'new@company.com', role: 'member', workspaceId: 'ws-team-1' }, + { + fetch: (async () => jsonResponse(status, { error: 'x' })) as unknown as typeof fetch, + readSession: () => SESSION, + }, + ); + expect(out).toEqual({ ok: false, status, error: `create_${status}` }); + } + }); + + it.each([ + ['invite_duplicate', 'active_pending_invite'], + ['already_member', 'already_member'], + ['active_pending_invite', 'active_pending_invite'], + ['workspace_seat_limit_reached', 'workspace_seat_limit_reached'], + [ + 'workspace_subscription_seat_allocation_unavailable', + 'workspace_subscription_seat_allocation_unavailable', + ], + ])('preserves allowlisted B error %s as %s', async (upstreamError, expectedError) => { + const out = await createWorkspaceInvite( + { email: 'new@company.com', role: 'member', workspaceId: 'ws-team-1' }, + { + fetch: (async () => jsonResponse(409, { error: upstreamError })) as unknown as typeof fetch, + readSession: () => SESSION, + }, + ); + + expect(out).toEqual({ ok: false, status: 409, error: expectedError }); + }); + + it.each([ + { error: 'database_constraint_details' }, + { code: 'unrecognized_conflict' }, + { error: { private: 'not-a-string' } }, + ])('does not expose an unknown B error body: %j', async (body) => { + const out = await createWorkspaceInvite( + { email: 'new@company.com', role: 'member', workspaceId: 'ws-team-1' }, + { + fetch: (async () => jsonResponse(409, body)) as unknown as typeof fetch, + readSession: () => SESSION, + }, + ); + + expect(out).toEqual({ ok: false, status: 409, error: 'create_409' }); + }); + + it('keeps a non-JSON 409 generic instead of inventing a duplicate', async () => { + const out = await createWorkspaceInvite( + { email: 'new@company.com', role: 'member', workspaceId: 'ws-team-1' }, + { + fetch: (async () => + ({ + ok: false, + status: 409, + json: async () => { + throw new SyntaxError('not JSON'); + }, + }) as unknown as Response) as unknown as typeof fetch, + readSession: () => SESSION, + }, + ); + + expect(out).toEqual({ ok: false, status: 409, error: 'create_409' }); + }); + + it('degrades to create_unreachable on a transport error, never throwing', async () => { + const out = await createWorkspaceInvite( + { email: 'new@company.com', role: 'member', workspaceId: 'ws-team-1' }, + { + fetch: (async () => { + throw new Error('network down'); + }) as unknown as typeof fetch, + readSession: () => SESSION, + }, + ); + expect(out).toEqual({ ok: false, status: 502, error: 'create_unreachable' }); + }); +}); diff --git a/apps/daemon/tests/langfuse-trace.test.ts b/apps/daemon/tests/langfuse-trace.test.ts index 4cee8fabcf9..066ceb0b289 100644 --- a/apps/daemon/tests/langfuse-trace.test.ts +++ b/apps/daemon/tests/langfuse-trace.test.ts @@ -2576,6 +2576,9 @@ describe('reportRunFeedback', () => { }); it('posts feedback scores to Vela when completed-run telemetry uses Vela', async () => { + // tests/setup.ts defaults OPEN_DESIGN_VELA_TELEMETRY to 'off' so unit + // tests never route through a developer's real Vela profile; this test + // exercises exactly that sink, so opt back in explicitly. vi.stubEnv('OPEN_DESIGN_VELA_TELEMETRY', 'on'); vi.stubEnv('VELA_CONTROL_KEY', 'ck_secret'); vi.stubEnv('VELA_API_URL', 'https://vela.example.test'); @@ -2625,6 +2628,7 @@ describe('reportRunFeedback', () => { }); it('does not fall back anonymously when Vela rejects feedback auth', async () => { + // Same opt-in as above: the setup default keeps the Vela sink off. vi.stubEnv('OPEN_DESIGN_VELA_TELEMETRY', 'on'); vi.stubEnv('VELA_CONTROL_KEY', 'ck_expired'); vi.stubEnv('VELA_API_URL', 'https://vela.example.test'); diff --git a/apps/daemon/tests/mcp-spawn.test.ts b/apps/daemon/tests/mcp-spawn.test.ts index 1e325b43125..7623d8ad271 100644 --- a/apps/daemon/tests/mcp-spawn.test.ts +++ b/apps/daemon/tests/mcp-spawn.test.ts @@ -11,11 +11,13 @@ // matches what the daemon does in production. import type http from 'node:http'; +import Database from 'better-sqlite3'; import { randomUUID } from 'node:crypto'; import { existsSync, promises as fsp, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { delimiter, join } from 'node:path'; import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { insertConversation } from '../src/db.js'; import { startServer } from '../src/server.js'; async function withFakeClaude(run: () => Promise): Promise { @@ -360,24 +362,26 @@ describe('spawn writes external MCP config for Claude Code', () => { it('binds conversation-less runs to the seeded project conversation', async () => { await withFakeClaude(async () => { const { id, conversationId } = await createProject(); - const recentConvRes = await fetch(`${baseUrl}/api/projects/${id}/conversations`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ title: 'Recently active' }), - }); - expect(recentConvRes.ok).toBe(true); - const recentConvBody = (await recentConvRes.json()) as { - conversation: { id: string }; - }; - const recentConversationId = recentConvBody.conversation.id; - await fetch(`${baseUrl}/api/projects/${id}/conversations/${recentConversationId}`, { - method: 'PATCH', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ + if (!process.env.OD_DATA_DIR) { + throw new Error('OD_DATA_DIR is required for seeded conversation tests'); + } + const sqlite = new Database(join(process.env.OD_DATA_DIR, 'app.sqlite')); + const recentConversationId = `0-later-${randomUUID()}`; + try { + const seeded = sqlite + .prepare('SELECT created_at AS createdAt FROM conversations WHERE id = ?') + .get(conversationId) as { createdAt: number } | undefined; + if (!seeded) throw new Error('seeded project conversation missing'); + insertConversation(sqlite as never, { + id: recentConversationId, + projectId: id, title: 'Recently active', + createdAt: seeded.createdAt, updatedAt: Date.now() + 60_000, - }), - }); + }); + } finally { + sqlite.close(); + } const chatRes = await fetch(`${baseUrl}/api/runs`, { method: 'POST', diff --git a/apps/daemon/tests/media/tasks-routes.test.ts b/apps/daemon/tests/media/tasks-routes.test.ts index 976608503c1..d50457f17dc 100644 --- a/apps/daemon/tests/media/tasks-routes.test.ts +++ b/apps/daemon/tests/media/tasks-routes.test.ts @@ -1,21 +1,296 @@ -import type http from 'node:http'; -import { afterEach, describe, expect, it } from 'vitest'; +import http from 'node:http'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { randomUUID } from 'node:crypto'; -import { closeDatabase, insertProject, openDatabase } from '../../src/db.js'; +import { + closeDatabase, + ensureWorkspaceProject, + insertProject, + openDatabase, +} from '../../src/db.js'; import { insertMediaTask, listMediaTasksByProject } from '../../src/media/tasks.js'; import { startServer } from '../../src/server.js'; +import { toolTokenRegistry } from '../../src/tool-tokens.js'; describe('media task route recovery', () => { let server: http.Server | null = null; + let authorityServer: http.Server | null = null; afterEach(async () => { if (server) { await new Promise((resolve) => server?.close(() => resolve())); server = null; } + if (authorityServer) { + await new Promise((resolve) => authorityServer?.close(() => resolve())); + authorityServer = null; + } + vi.unstubAllEnvs(); + toolTokenRegistry.clear(); closeDatabase(); }); + it('accepts only a same-project token explicitly allowed to poll media tasks', async () => { + const dataDir = process.env.OD_DATA_DIR; + const db = openDatabase(process.cwd(), dataDir === undefined ? {} : { dataDir }); + const projectId = `project_${randomUUID()}`; + const taskId = `task_${randomUUID()}`; + const runId = `run_${randomUUID()}`; + const now = Date.now(); + + insertProject(db, { + id: projectId, + name: 'Token-polled Team media project', + createdAt: now, + updatedAt: now, + }); + ensureWorkspaceProject(db, { + projectId, + workspaceId: 'workspace-team', + visibility: 'team', + createdByWorkspaceMemberId: 'member-creator', + }); + insertMediaTask(db, { + id: taskId, + projectId, + status: 'done', + surface: 'image', + model: 'fixture-model', + progress: ['done'], + file: { name: 'generated.png', size: 3 }, + startedAt: now, + endedAt: now, + updatedAt: now, + }); + const token = toolTokenRegistry.mint({ + projectId, + runId, + allowedEndpoints: ['/api/media/tasks/:id/wait'], + allowedOperations: ['media:generate'], + }).token; + + const started = await startServer({ port: 0, returnServer: true }) as { + url: string; + server: http.Server; + }; + server = started.server; + + const response = await fetch( + `${started.url}/api/media/tasks/${encodeURIComponent(taskId)}/wait`, + { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ since: 0, timeoutMs: 0 }), + }, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + status: 'done', + file: { name: 'generated.png' }, + }); + + const endpointDeniedToken = toolTokenRegistry.mint({ + projectId, + runId: `run_${randomUUID()}`, + allowedEndpoints: ['/api/tools/media/generate'], + allowedOperations: ['media:generate'], + }).token; + const endpointDenied = await fetch( + `${started.url}/api/media/tasks/${encodeURIComponent(taskId)}/wait`, + { + method: 'POST', + headers: { + authorization: `Bearer ${endpointDeniedToken}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ since: 0, timeoutMs: 0 }), + }, + ); + expect(endpointDenied.status).toBe(403); + await expect(endpointDenied.json()).resolves.toMatchObject({ + error: { code: 'TOOL_ENDPOINT_DENIED' }, + }); + + const otherProjectToken = toolTokenRegistry.mint({ + projectId: 'different-project', + runId: `run_${randomUUID()}`, + allowedEndpoints: ['/api/media/tasks/:id/wait'], + allowedOperations: ['media:generate'], + }).token; + const crossProject = await fetch( + `${started.url}/api/media/tasks/${encodeURIComponent(taskId)}/wait`, + { + method: 'POST', + headers: { + authorization: `Bearer ${otherProjectToken}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ since: 0, timeoutMs: 0 }), + }, + ); + expect(crossProject.status).toBe(403); + await expect(crossProject.json()).resolves.toMatchObject({ + error: { code: 'FORBIDDEN' }, + }); + }); + + it('fails closed when a media wait request presents an invalid or expired bearer token', async () => { + const dataDir = process.env.OD_DATA_DIR; + const db = openDatabase(process.cwd(), dataDir === undefined ? {} : { dataDir }); + const projectId = `project_${randomUUID()}`; + const taskId = `task_${randomUUID()}`; + const now = Date.now(); + + insertProject(db, { + id: projectId, + name: 'Unbound legacy media project', + createdAt: now, + updatedAt: now, + }); + insertMediaTask(db, { + id: taskId, + projectId, + status: 'done', + surface: 'image', + model: 'fixture-model', + progress: ['done'], + file: { name: 'generated.png', size: 3 }, + startedAt: now, + endedAt: now, + updatedAt: now, + }); + const expiredToken = toolTokenRegistry.mint({ + projectId, + runId: `run_${randomUUID()}`, + allowedEndpoints: ['/api/media/tasks/:id/wait'], + allowedOperations: ['media:generate'], + nowMs: now - 120_000, + ttlMs: 60_000, + }).token; + + const started = await startServer({ port: 0, returnServer: true }) as { + url: string; + server: http.Server; + }; + server = started.server; + + for (const [token, expectedCode] of [ + ['forged-token', 'TOOL_TOKEN_INVALID'], + [expiredToken, 'TOOL_TOKEN_EXPIRED'], + ] as const) { + const response = await fetch( + `${started.url}/api/media/tasks/${encodeURIComponent(taskId)}/wait`, + { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ since: 0, timeoutMs: 0 }), + }, + ); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ + error: { code: expectedCode }, + }); + } + }); + + it('checks fresh tool authority before revealing whether a media task exists', async () => { + const dataDir = process.env.OD_DATA_DIR; + const db = openDatabase(process.cwd(), dataDir === undefined ? {} : { dataDir }); + const projectId = `project_${randomUUID()}`; + const workspaceId = `workspace_${randomUUID()}`; + const now = Date.now(); + + insertProject(db, { + id: projectId, + name: 'Fresh-authority Team media project', + createdAt: now, + updatedAt: now, + }); + ensureWorkspaceProject(db, { + projectId, + workspaceId, + visibility: 'team', + createdByWorkspaceMemberId: 'member-creator', + }); + const token = toolTokenRegistry.mint({ + projectId, + runId: `run_${randomUUID()}`, + allowedEndpoints: ['/api/media/tasks/:id/wait'], + allowedOperations: ['media:generate'], + }).token; + let authorityMode: 'active' | 'outage' | 'removed' = 'removed'; + authorityServer = http.createServer((_req, res) => { + res.setHeader('content-type', 'application/json'); + if (authorityMode === 'outage') { + res.statusCode = 503; + res.end(JSON.stringify({ error: 'authority unavailable' })); + return; + } + res.end(JSON.stringify({ + items: [{ + workspaceId, + workspaceName: 'Fresh authority workspace', + workspaceType: 'team', + workspaceMemberId: 'member-creator', + role: 'owner', + memberStatus: authorityMode === 'removed' ? 'removed' : 'active', + lifecycleState: 'active', + }], + })); + }); + await new Promise((resolve) => { + authorityServer?.listen(0, '127.0.0.1', resolve); + }); + const authorityAddress = authorityServer.address(); + if (!authorityAddress || typeof authorityAddress === 'string') { + throw new Error('authority server did not bind to a TCP port'); + } + vi.stubEnv('OD_WORKSPACE_CONTEXT_SOURCE', 'vela'); + vi.stubEnv('VELA_CONTROL_KEY', 'test-control-key'); + vi.stubEnv('VELA_API_URL', `http://127.0.0.1:${authorityAddress.port}`); + + const started = await startServer({ port: 0, returnServer: true }) as { + url: string; + server: http.Server; + }; + server = started.server; + const waitForMissingTask = () => fetch( + `${started.url}/api/media/tasks/missing-task/wait`, + { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ since: 0, timeoutMs: 0 }), + }, + ); + + const removed = await waitForMissingTask(); + expect(removed.status).toBe(403); + await expect(removed.json()).resolves.toMatchObject({ + error: { code: 'WORKSPACE_PROJECT_PERMISSION_DENIED' }, + }); + + authorityMode = 'outage'; + const unavailable = await waitForMissingTask(); + expect(unavailable.status).toBe(503); + await expect(unavailable.json()).resolves.toMatchObject({ + error: { code: 'WORKSPACE_AUTHORITY_UNAVAILABLE' }, + }); + + authorityMode = 'active'; + const authorized = await waitForMissingTask(); + expect(authorized.status).toBe(404); + }); + it('recovers a pre-restart running task so wait returns interrupted instead of 404', async () => { const dataDir = process.env.OD_DATA_DIR; const db = openDatabase(process.cwd(), dataDir === undefined ? {} : { dataDir }); diff --git a/apps/daemon/tests/orbit.test.ts b/apps/daemon/tests/orbit.test.ts index f87e5de5ecd..6a5edc75541 100644 --- a/apps/daemon/tests/orbit.test.ts +++ b/apps/daemon/tests/orbit.test.ts @@ -161,6 +161,14 @@ describe('OrbitService', () => { const dataDir = await mkdtemp(path.join(os.tmpdir(), 'orbit-test-')); try { const service = new OrbitService(dataDir); + service.configure({ + enabled: false, + time: '08:00', + workspaceScope: { + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }, + }); const captured: { request?: Parameters[0] } = {}; service.setRunHandler(async (request) => { captured.request = request; @@ -185,6 +193,10 @@ describe('OrbitService', () => { expect(captured.request?.systemPrompt).toContain( 'DAILY DIGEST CONNECTOR CURATION IS REQUIRED WHEN SUPPORTED', ); + expect(captured.request?.workspaceScope).toEqual({ + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }); let status = await service.status(); for (let attempt = 0; attempt < 10 && !status.lastRun; attempt += 1) { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -195,6 +207,49 @@ describe('OrbitService', () => { } }); + it('preserves persisted Workspace scope for execution without a membership re-check', async () => { + const dataDir = await mkdtemp(path.join(os.tmpdir(), 'orbit-test-')); + try { + const service = new OrbitService(dataDir); + service.configure({ + enabled: false, + time: '08:00', + workspaceScope: { + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }, + }); + const sideEffects = { projects: 0, agentRuns: 0 }; + service.setRunHandler(async (request) => { + expect(request.workspaceScope).toEqual({ + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }); + sideEffects.projects += 1; + sideEffects.agentRuns += 1; + return { + projectId: 'project-a', + agentRunId: 'agent-run-a', + completion: Promise.resolve({ + agentRunId: 'agent-run-a', + status: 'succeeded', + }), + }; + }); + + await expect(service.start('manual')).resolves.toMatchObject({ + projectId: 'project-a', + agentRunId: 'agent-run-a', + }); + expect(sideEffects).toEqual({ projects: 1, agentRuns: 1 }); + await vi.waitFor(async () => { + expect((await service.status()).lastRun).not.toBeNull(); + }); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }); + it('localizes the template example prompt passed to the run handler for Chinese Orbit runs', async () => { const dataDir = await mkdtemp(path.join(os.tmpdir(), 'orbit-test-')); try { diff --git a/apps/daemon/tests/pdf-export.test.ts b/apps/daemon/tests/pdf-export.test.ts index de9cfe93293..c363404333b 100644 --- a/apps/daemon/tests/pdf-export.test.ts +++ b/apps/daemon/tests/pdf-export.test.ts @@ -71,7 +71,14 @@ describe('POST /api/projects/:id/export/pdf', () => { }) as { server: { close(cb: () => void): void }; url: string }; try { - await fetch(`${started.url}/api/projects/${encodeURIComponent(projectId)}/files`, { + const createResponse = await fetch(`${started.url}/api/projects`, { + body: JSON.stringify({ id: projectId, name: 'PDF route fixture' }), + headers: { 'content-type': 'application/json' }, + method: 'POST', + }); + expect(createResponse.status).toBe(200); + + const writeResponse = await fetch(`${started.url}/api/projects/${encodeURIComponent(projectId)}/files`, { body: JSON.stringify({ content: '
One
', name: 'deck/index.html', @@ -79,6 +86,7 @@ describe('POST /api/projects/:id/export/pdf', () => { headers: { 'content-type': 'application/json' }, method: 'POST', }); + expect(writeResponse.status).toBe(200); const response = await fetch(`${started.url}/api/projects/${encodeURIComponent(projectId)}/export/pdf`, { body: JSON.stringify({ deck: true, fileName: 'deck/index.html', title: 'Seed Deck' }), diff --git a/apps/daemon/tests/plugin-asset-workspace-authority.test.ts b/apps/daemon/tests/plugin-asset-workspace-authority.test.ts new file mode 100644 index 00000000000..a496bcd89cb --- /dev/null +++ b/apps/daemon/tests/plugin-asset-workspace-authority.test.ts @@ -0,0 +1,215 @@ +import express from 'express'; +import type http from 'node:http'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import type { WorkspaceCollabContext } from '@open-design/contracts'; +import { afterEach, describe, expect, it } from 'vitest'; +import { registerPluginAssetRoutes } from '../src/routes/plugins/assets.js'; + +const servers: http.Server[] = []; +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve) => server.close(() => resolve())), + ), + ); + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +function context( + workspaceId: string, + workspaceMemberId: string, +): WorkspaceCollabContext { + return { + workspaceId, + workspaceName: workspaceId, + workspaceType: 'team', + workspaceMemberId, + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + billingState: 'active', + planId: null, + providerMode: 'platform_credits', + seatSummary: { + seatLimit: 5, + usedSeats: 1, + availableSeats: 4, + isSeatFull: false, + }, + permissions: { + canManageMembers: false, + canManageBilling: false, + canInviteMembers: false, + canManageAutoRecharge: false, + canShareProjects: true, + canWriteSyncedFiles: true, + canViewWorkspaceSettings: true, + canManageSharedResources: false, + }, + } as WorkspaceCollabContext; +} + +async function fixture() { + const root = await mkdtemp(path.join(os.tmpdir(), 'od-plugin-asset-scope-')); + roots.push(root); + const plugins = new Map(); + for (const workspaceId of ['workspace-a', 'workspace-b']) { + const dir = path.join(root, workspaceId); + await mkdir(path.join(dir, 'assets'), { recursive: true }); + await writeFile( + path.join(dir, 'preview.html'), + `

${workspaceId}

`, + ); + await writeFile(path.join(dir, 'assets', 'secret.txt'), `${workspaceId}-bytes`); + plugins.set(workspaceId, { + fsPath: dir, + title: `Same plugin ${workspaceId}`, + manifest: { od: { preview: { entry: 'preview.html' } } }, + }); + } + + const app = express(); + registerPluginAssetRoutes(app, { + db: {} as never, + verifyWorkspaceRequestAuthority: async (req: any) => { + const workspaceId = req.get('x-od-workspace-id')?.trim(); + const workspaceMemberId = req.get('x-od-workspace-member-id')?.trim(); + if (workspaceId === 'workspace-removed') { + return { + ok: false, + status: 403, + code: 'WORKSPACE_ACCESS_DENIED', + message: 'removed', + }; + } + if (workspaceId === 'workspace-outage') { + return { + ok: false, + status: 503, + code: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + message: 'outage', + retryable: true, + }; + } + return { + ok: true, + context: context(workspaceId, workspaceMemberId), + }; + }, + getWorkspacePlugin: async (_db, id, workspaceId) => + id === 'same-plugin' && workspaceId ? plugins.get(workspaceId) ?? null : null, + pluginAssetCache: { + get: async () => { + throw new Error('unused'); + }, + }, + AssetCacheError: class extends Error { + status = 502; + constructor(...args: unknown[]) { + super(String(args[0] ?? 'asset cache error')); + } + }, + assetCacheRewriteUrl: (url) => url, + isCacheableExternalUrl: () => false, + assembleExample: (template, slides) => + template.replace('', slides), + }); + const server = app.listen(0, '127.0.0.1'); + servers.push(server); + await new Promise((resolve) => server.once('listening', resolve)); + const address = server.address() as { port: number }; + return `http://127.0.0.1:${address.port}`; +} + +describe('Plugin preview and asset Workspace authority', () => { + it('serves A and B copies of the same id and carries exact scope into nested assets', async () => { + const baseUrl = await fixture(); + const preview = await fetch( + `${baseUrl}/api/plugins/same-plugin/preview?workspaceId=workspace-a&workspaceMemberId=member-a`, + ); + + expect(preview.status).toBe(200); + const html = await preview.text(); + expect(html).toContain('workspace-a'); + expect(html).not.toContain('workspace-b'); + expect(html).toContain( + '/api/plugins/same-plugin/asset/assets/secret.txt?workspaceId=workspace-a&workspaceMemberId=member-a', + ); + + const [assetA, assetB] = await Promise.all([ + fetch( + `${baseUrl}/api/plugins/same-plugin/asset/assets/secret.txt?workspaceId=workspace-a&workspaceMemberId=member-a`, + ), + fetch( + `${baseUrl}/api/plugins/same-plugin/asset/assets/secret.txt?workspaceId=workspace-b&workspaceMemberId=member-b`, + ), + ]); + expect(await assetA.text()).toBe('workspace-a-bytes'); + expect(await assetB.text()).toBe('workspace-b-bytes'); + }); + + it.each([ + [ + 'workspace-removed', + 403, + 'WORKSPACE_ACCESS_DENIED', + ], + [ + 'workspace-outage', + 503, + 'WORKSPACE_AUTHORITY_UNAVAILABLE', + ], + ] as const)( + 'returns authority failure for %s without serving another Workspace bytes', + async (workspaceId, status, code) => { + const baseUrl = await fixture(); + const response = await fetch( + `${baseUrl}/api/plugins/same-plugin/asset/assets/secret.txt?workspaceId=${workspaceId}&workspaceMemberId=member-a`, + ); + + expect(response.status).toBe(status); + expect(await response.json()).toMatchObject({ error: code }); + }, + ); + + it('rejects a partial navigation scope before resolving plugin bytes', async () => { + const baseUrl = await fixture(); + const response = await fetch( + `${baseUrl}/api/plugins/same-plugin/asset/assets/secret.txt?workspaceId=workspace-a`, + ); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + error: 'WORKSPACE_CONTEXT_INCOMPLETE', + }); + }); + + it('rejects conflicting header and navigation scopes', async () => { + const baseUrl = await fixture(); + const response = await fetch( + `${baseUrl}/api/plugins/same-plugin/asset/assets/secret.txt?workspaceId=workspace-b&workspaceMemberId=member-b`, + { + headers: { + 'x-od-workspace-id': 'workspace-a', + 'x-od-workspace-member-id': 'member-a', + }, + }, + ); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + error: 'WORKSPACE_CONTEXT_CONFLICT', + }); + }); +}); diff --git a/apps/daemon/tests/plugins-apply-workspace-retraction.test.ts b/apps/daemon/tests/plugins-apply-workspace-retraction.test.ts new file mode 100644 index 00000000000..0bfd2983a54 --- /dev/null +++ b/apps/daemon/tests/plugins-apply-workspace-retraction.test.ts @@ -0,0 +1,108 @@ +import express from 'express'; +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { registerPluginRoutes } from '../src/routes/plugins/index.js'; + +const servers: Array> = []; + +afterEach(async () => { + await Promise.all( + servers.splice(0).map((server) => new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + })), + ); +}); + +describe('Team plugin apply retraction gate', () => { + it('does not apply a Team plugin retired while registry loading is pending', async () => { + const app = express(); + app.use(express.json()); + let bindingLive = true; + let finishRegistryLoad!: (value: Record) => void; + const registryGate = new Promise>((resolve) => { + finishRegistryLoad = resolve; + }); + let registryLoadStarted!: () => void; + const registryStarted = new Promise((resolve) => { + registryLoadStarted = resolve; + }); + const applyPlugin = vi.fn(() => ({ + result: { capabilitiesGranted: [], appliedPlugin: { capabilitiesGranted: [] } }, + warnings: [], + })); + const middleware: express.RequestHandler = (_req, _res, next) => next(); + + registerPluginRoutes(app, { + db: { + prepare: () => ({ all: () => [], get: () => null, run: () => undefined }), + transaction: (run: () => unknown) => () => run(), + }, + paths: { PROJECTS_DIR: '', PLUGIN_REGISTRY_ROOTS: [], PLUGIN_LOCKFILE_PATH: '' }, + ids: { randomId: () => 'unused' }, + projectStore: {}, + conversations: {}, + verifyWorkspaceRequestAuthority: async () => ({ + ok: true, + context: { workspaceId: 'ws-team' }, + }), + workspaceResources: { + getWorkspaceResource: () => null, + getWorkspaceResourceByResourceId: () => null, + workspaceTeamPluginBindingAllowsRead: () => bindingLive, + }, + plugins: { + getInstalledPlugin: () => null, + getWorkspacePlugin: async () => ({ + id: 'team-plugin', + source: 'team:plugin:ws-team:team-plugin', + }), + listInstalledPlugins: () => [], + applyPlugin, + MissingInputError: class MissingInputError extends Error { + fields: string[] = []; + }, + }, + helpers: { + requireLocalDaemonRequest: middleware, + pluginUpload: { + single: () => middleware, + array: () => middleware, + }, + loadPluginRegistryView: async () => { + registryLoadStarted(); + return registryGate; + }, + buildConnectorProbe: () => ({}), + connectorService: {}, + sendApiError: (res: express.Response, status: number, code: string, message: string) => + res.status(status).json({ error: { code, message } }), + }, + } as unknown as Parameters[1]); + + const server = app.listen(0, '127.0.0.1'); + servers.push(server); + await new Promise((resolve) => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + const responsePromise = fetch(`http://127.0.0.1:${port}/api/plugins/team-plugin/apply`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-od-workspace-id': 'ws-team', + 'x-od-workspace-type': 'team', + 'x-od-workspace-member-id': 'member-team', + 'x-od-workspace-role': 'member', + 'x-od-workspace-lifecycle-state': 'active', + 'x-od-workspace-member-status': 'active', + }, + body: '{}', + }); + await registryStarted; + bindingLive = false; + finishRegistryLoad({}); + + const response = await responsePromise; + expect(response.status).toBe(404); + expect(applyPlugin).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/daemon/tests/plugins-asset-route.test.ts b/apps/daemon/tests/plugins-asset-route.test.ts index 5881d0fbd1d..8c50e232f2c 100644 --- a/apps/daemon/tests/plugins-asset-route.test.ts +++ b/apps/daemon/tests/plugins-asset-route.test.ts @@ -93,6 +93,13 @@ beforeAll(async () => { ); await symlink(outsideDir, path.join(installedSurfacesDir, 'linked-outside'), 'dir'); await symlink(installedInternalDir, path.join(installedSurfacesDir, 'linked-internal'), 'dir'); + const installedRoot = path.join(defaultRegistryRoots().userPluginsRoot, 'asset-plugin'); + await writeFile( + path.join(installedRoot, 'SKILL.md'), + '---\nname: asset-plugin\ndescription: Fixture skill description.\n---\n\n# Asset plugin\n', + ); + await writeFile(path.join(installedRoot, 'notes.markdown'), '# notes\n'); + await writeFile(path.join(installedRoot, 'payload.bin'), 'not a text asset'); void migratePlugins; void upsertInstalledPlugin; void Database; @@ -153,3 +160,33 @@ describe('GET /api/plugins/:id/asset/*', () => { expect(await resp.text()).not.toContain('nested internal secret'); }); }); + +// The plugin detail page reads a suite's "Knowledge skills" descriptions by +// fetching each `SKILL.md` through this route and parsing its frontmatter. +// That client only parses bodies whose media type is in its markdown +// allowlist (`apps/web/src/runtime/plugin-skill-descriptions.ts`); any other +// type has the response body cancelled unread, which silently blanks the +// description line under the skill title. Serving markdown with a markdown +// media type is therefore a contract of this route, not a cosmetic detail. +describe('GET /api/plugins/:id/asset/* markdown media type', () => { + it('serves SKILL.md as text/markdown so the client parses it', async () => { + const resp = await fetch(`${baseUrl}/api/plugins/asset-plugin/asset/SKILL.md`); + expect(resp.status).toBe(200); + expect(resp.headers.get('content-type')).toBe('text/markdown; charset=utf-8'); + expect(await resp.text()).toContain('Fixture skill description.'); + }); + + it('serves a .markdown asset with the same media type', async () => { + const resp = await fetch(`${baseUrl}/api/plugins/asset-plugin/asset/notes.markdown`); + expect(resp.status).toBe(200); + expect(resp.headers.get('content-type')).toBe('text/markdown; charset=utf-8'); + }); + + // Guards the safe default: only known-safe types are named, everything else + // must stay a non-renderable download rather than become inlineable. + it('still falls back to application/octet-stream for unknown extensions', async () => { + const resp = await fetch(`${baseUrl}/api/plugins/asset-plugin/asset/payload.bin`); + expect(resp.status).toBe(200); + expect(resp.headers.get('content-type')).toBe('application/octet-stream'); + }); +}); diff --git a/apps/daemon/tests/plugins-bundled-content-craft.test.ts b/apps/daemon/tests/plugins-bundled-content-craft.test.ts new file mode 100644 index 00000000000..04a9509a7ea --- /dev/null +++ b/apps/daemon/tests/plugins-bundled-content-craft.test.ts @@ -0,0 +1,24 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); + +describe('bundled plugin craft context', () => { + it('pins the Creative Voltage seed-pitch deck to the typography craft rules', async () => { + const manifestPath = path.join( + repoRoot, + 'plugins', + '_official', + 'examples', + 'fs-creative-voltage', + 'open-design.json', + ); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { + od?: { context?: { craft?: string[] } }; + }; + + expect(manifest.od?.context?.craft).toContain('typography'); + }); +}); diff --git a/apps/daemon/tests/plugins-duplicate-project.test.ts b/apps/daemon/tests/plugins-duplicate-project.test.ts index 70cbe2594d0..e84768eac34 100644 --- a/apps/daemon/tests/plugins-duplicate-project.test.ts +++ b/apps/daemon/tests/plugins-duplicate-project.test.ts @@ -4,13 +4,29 @@ import { access, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { InstalledPluginRecord, Project } from '@open-design/contracts'; +import type { + InstalledPluginRecord, + Project, + WorkspaceCollabContext, +} from '@open-design/contracts'; +import { sendApiError } from '../src/http/api-errors.js'; +import { + closeDatabase, + deleteProject, + getConversation, + getProject, + insertConversation, + insertProject, + openDatabase, +} from '../src/db.js'; import { duplicatePluginExampleIntoProject } from '../src/plugins/duplicate-project.js'; +import { removeProjectDir } from '../src/projects.js'; import { registerPluginRoutes } from '../src/routes/plugins/index.js'; const tempRoots: string[] = []; afterEach(async () => { + closeDatabase(); await Promise.all(tempRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); @@ -44,6 +60,43 @@ async function expectMissing(pathname: string): Promise { await expect(access(pathname)).rejects.toMatchObject({ code: 'ENOENT' }); } +async function verifyWorkspaceRequestAuthority(req: express.Request) { + const workspaceId = req.get('x-od-workspace-id')?.trim() ?? ''; + const workspaceMemberId = + req.get('x-od-workspace-member-id')?.trim() ?? ''; + return { + ok: true as const, + context: { + workspaceId, + workspaceName: workspaceId, + workspaceType: 'team', + workspaceMemberId, + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + billingState: 'active', + planId: null, + providerMode: 'platform_credits', + seatSummary: { + seatLimit: 5, + usedSeats: 1, + availableSeats: 4, + isSeatFull: false, + }, + permissions: { + canManageMembers: false, + canManageBilling: false, + canInviteMembers: false, + canManageAutoRecharge: false, + canShareProjects: true, + canWriteSyncedFiles: true, + canViewWorkspaceSettings: true, + canManageSharedResources: false, + }, + } as WorkspaceCollabContext, + }; +} + describe('plugin project duplication', () => { it.skipIf(process.platform === 'win32')( 'rejects duplicates that would skip a required symlinked file', @@ -69,7 +122,209 @@ describe('plugin project duplication', () => { }, ); - it('rolls back project rows and files when conversation creation fails after copying files', async () => { + it('returns a canonical retryable 503 when workspace authority is unavailable', async () => { + const root = await makeTempRoot('od-plugin-duplicate-authority-'); + const projectsRoot = path.join(root, 'projects'); + const plugin = await makePreviewPlugin(root, 'authority-plugin-fixture'); + const randomId = vi.fn(); + const app = express(); + app.use(express.json()); + registerPluginRoutes(app, { + db: { + prepare: () => ({ + all: () => [], + get: () => null, + run: () => undefined, + }), + transaction: (run: () => unknown) => () => run(), + }, + paths: { + PROJECTS_DIR: projectsRoot, + PLUGIN_REGISTRY_ROOTS: [], + PLUGIN_LOCKFILE_PATH: path.join(root, 'plugins.lock'), + }, + ids: { randomId }, + projectStore: { + insertProject: vi.fn(), + getProject: vi.fn(), + ensureWorkspaceProject: vi.fn(), + dbDeleteProject: vi.fn(), + removeProjectDir: vi.fn(), + }, + conversations: { insertConversation: vi.fn() }, + plugins: { + getInstalledPlugin: vi.fn(() => plugin), + listInstalledPlugins: vi.fn(() => []), + }, + verifyWorkspaceRequestAuthority, + fetchProjectCreationWorkspaceDirectory: async () => ({ ok: false, items: [] }), + helpers: { + requireLocalDaemonRequest: ((_req, _res, next) => next()) as express.RequestHandler, + assembleExample: (templateHtml: string) => templateHtml, + applyBakedPreviews: (records: unknown[]) => records, + sendApiError, + }, + } as unknown as Parameters[1]); + const server = await listen(app); + try { + const resp = await fetch( + `${server.url}/api/plugins/${encodeURIComponent(plugin.id)}/duplicate-project`, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-od-workspace-id': 'workspace-authority', + 'x-od-workspace-type': 'team', + 'x-od-workspace-member-id': 'member-authority', + 'x-od-workspace-role': 'member', + 'x-od-workspace-lifecycle-state': 'active', + 'x-od-workspace-member-status': 'active', + 'x-od-workspace-can-share-projects': 'true', + 'x-od-workspace-can-write-synced-files': 'true', + }, + body: JSON.stringify({}), + }, + ); + + expect(resp.status).toBe(503); + await expect(resp.json()).resolves.toEqual({ + error: { + code: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + message: 'workspace membership authority is temporarily unavailable', + retryable: true, + }, + }); + expect(randomId).not.toHaveBeenCalled(); + await expectMissing(projectsRoot); + } finally { + await close(server.server); + } + }); + + it('binds Plugin Remix to the exact request workspace inside the DB transaction', async () => { + const root = await makeTempRoot('od-plugin-duplicate-workspace-'); + const projectsRoot = path.join(root, 'projects'); + const plugin = await makePreviewPlugin(root, 'workspace-plugin-fixture'); + const projectId = 'workspace-plugin-project'; + const project = { + id: projectId, + name: 'Workspace Plugin Fixture', + skillId: null, + designSystemId: null, + pendingPrompt: null, + metadata: { kind: 'prototype' }, + createdAt: 1, + updatedAt: 1, + } as unknown as Project; + const transactionSteps: string[] = []; + const db = { + prepare: () => ({ + all: () => [], + get: () => null, + run: () => undefined, + }), + transaction: (run: () => unknown) => () => { + transactionSteps.push('transaction:start'); + const result = run(); + transactionSteps.push('transaction:commit'); + return result; + }, + }; + const ensureWorkspaceProject = vi.fn((_db: unknown, input: unknown) => { + transactionSteps.push('workspace:bind'); + return input; + }); + const app = express(); + app.use(express.json()); + registerPluginRoutes(app, { + db, + paths: { + PROJECTS_DIR: projectsRoot, + PLUGIN_REGISTRY_ROOTS: [], + PLUGIN_LOCKFILE_PATH: path.join(root, 'plugins.lock'), + }, + ids: { + randomId: vi.fn() + .mockReturnValueOnce(projectId) + .mockReturnValueOnce('workspace-plugin-conversation'), + }, + projectStore: { + insertProject: vi.fn(() => { + transactionSteps.push('project:insert'); + return project; + }), + getProject: vi.fn(() => project), + ensureWorkspaceProject, + dbDeleteProject: vi.fn(), + removeProjectDir: async (rootDir: string, id: string) => { + await rm(path.join(rootDir, id), { recursive: true, force: true }); + }, + }, + conversations: { + insertConversation: vi.fn(() => { + transactionSteps.push('conversation:insert'); + }), + }, + plugins: { + getInstalledPlugin: vi.fn(() => plugin), + listInstalledPlugins: vi.fn(() => []), + }, + verifyWorkspaceRequestAuthority, + helpers: { + requireLocalDaemonRequest: ((_req, _res, next) => next()) as express.RequestHandler, + assembleExample: (templateHtml: string) => templateHtml, + applyBakedPreviews: (records: unknown[]) => records, + sendApiError, + }, + } as unknown as Parameters[1]); + const server = await listen(app); + try { + const resp = await fetch( + `${server.url}/api/plugins/${encodeURIComponent(plugin.id)}/duplicate-project`, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-od-workspace-id': 'workspace-plugin-a', + 'x-od-workspace-type': 'team', + 'x-od-workspace-member-id': 'member-plugin-a', + 'x-od-workspace-role': 'member', + 'x-od-workspace-lifecycle-state': 'active', + 'x-od-workspace-member-status': 'active', + 'x-od-workspace-can-share-projects': 'true', + 'x-od-workspace-can-write-synced-files': 'true', + }, + body: JSON.stringify({ name: 'Workspace Plugin Fixture' }), + }, + ); + expect(resp.status).toBe(201); + expect(ensureWorkspaceProject).toHaveBeenCalledWith( + db, + expect.objectContaining({ + projectId, + workspaceId: 'workspace-plugin-a', + createdByWorkspaceMemberId: 'member-plugin-a', + updatedByWorkspaceMemberId: 'member-plugin-a', + }), + ); + expect(transactionSteps).toEqual([ + 'transaction:start', + 'project:insert', + 'conversation:insert', + 'workspace:bind', + 'transaction:commit', + ]); + } finally { + await close(server.server); + } + }); + + it.each([ + { label: 'when DB compensation succeeds', dbDeleteThrows: false }, + { label: 'even when DB compensation throws', dbDeleteThrows: true }, + ])('rolls back project rows and files when workspace binding fails $label', async ({ + dbDeleteThrows, + }) => { const root = await makeTempRoot('od-plugin-duplicate-route-'); const projectsRoot = path.join(root, 'projects'); const plugin = await makePreviewPlugin(root, 'route-duplicate-fixture'); @@ -95,8 +350,11 @@ describe('plugin project duplication', () => { get: () => null, run: () => undefined, }), + transaction: (run: () => unknown) => () => run(), }; - const dbDeleteProject = vi.fn(); + const dbDeleteProject = vi.fn(() => { + if (dbDeleteThrows) throw new Error('DB compensation failed'); + }); const app = express(); app.use(express.json()); registerPluginRoutes(app, { @@ -114,24 +372,27 @@ describe('plugin project duplication', () => { projectStore: { insertProject: vi.fn(() => project), getProject: vi.fn(() => project), + ensureWorkspaceProject: vi.fn(() => { + throw new Error('workspace binding failed'); + }), dbDeleteProject, removeProjectDir: async (rootDir: string, id: string) => { await rm(path.join(rootDir, id), { recursive: true, force: true }); }, }, conversations: { - insertConversation: vi.fn(() => { - throw new Error('conversation insert failed'); - }), + insertConversation: vi.fn(), }, plugins: { getInstalledPlugin: vi.fn(() => plugin), listInstalledPlugins: vi.fn(() => []), }, + verifyWorkspaceRequestAuthority, helpers: { requireLocalDaemonRequest: ((_req, _res, next) => next()) as express.RequestHandler, assembleExample: (templateHtml: string) => templateHtml, applyBakedPreviews: (records: unknown[]) => records, + sendApiError, }, } as unknown as Parameters[1]); const server = await listen(app); @@ -140,20 +401,105 @@ describe('plugin project duplication', () => { `${server.url}/api/plugins/${encodeURIComponent(plugin.id)}/duplicate-project`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { + 'content-type': 'application/json', + 'x-od-workspace-id': 'workspace-bind-failure', + 'x-od-workspace-type': 'team', + 'x-od-workspace-member-id': 'member-bind-failure', + 'x-od-workspace-role': 'member', + 'x-od-workspace-lifecycle-state': 'active', + 'x-od-workspace-member-status': 'active', + 'x-od-workspace-can-share-projects': 'true', + 'x-od-workspace-can-write-synced-files': 'true', + }, body: JSON.stringify({ name: 'Route Duplicate Fixture' }), }, ); expect(resp.status).toBe(500); - const body = (await resp.json()) as { error?: { code?: string; message?: string } }; - expect(body.error?.code).toBe('plugin-duplicate-failed'); - expect(body.error?.message).toContain('conversation insert failed'); + if (!dbDeleteThrows) { + const body = (await resp.json()) as { error?: { code?: string; message?: string } }; + expect(body.error?.code).toBe('plugin-duplicate-failed'); + expect(body.error?.message).toContain('workspace binding failed'); + } expect(dbDeleteProject).toHaveBeenCalledWith(db, projectId); await expectMissing(path.join(projectsRoot, projectId)); } finally { await close(server.server); } }); + + it('rolls back real SQLite rows and removes managed files when workspace binding fails', async () => { + const root = await makeTempRoot('od-plugin-duplicate-sqlite-'); + const projectsRoot = path.join(root, 'projects'); + const plugin = await makePreviewPlugin(root, 'sqlite-plugin-fixture'); + const projectId = 'sqlite-plugin-project'; + const conversationId = 'sqlite-plugin-conversation'; + const db = openDatabase(root, { dataDir: path.join(root, 'data') }); + const app = express(); + app.use(express.json()); + registerPluginRoutes(app, { + db, + paths: { + PROJECTS_DIR: projectsRoot, + PLUGIN_REGISTRY_ROOTS: [], + PLUGIN_LOCKFILE_PATH: path.join(root, 'plugins.lock'), + }, + ids: { + randomId: vi.fn() + .mockReturnValueOnce(projectId) + .mockReturnValueOnce(conversationId), + }, + projectStore: { + insertProject, + getProject, + ensureWorkspaceProject: vi.fn(() => { + throw new Error('real SQLite workspace binding failed'); + }), + dbDeleteProject: deleteProject, + removeProjectDir, + }, + conversations: { insertConversation }, + plugins: { + getInstalledPlugin: vi.fn(() => plugin), + listInstalledPlugins: vi.fn(() => []), + }, + verifyWorkspaceRequestAuthority, + helpers: { + requireLocalDaemonRequest: ((_req, _res, next) => next()) as express.RequestHandler, + assembleExample: (templateHtml: string) => templateHtml, + applyBakedPreviews: (records: unknown[]) => records, + sendApiError, + }, + } as unknown as Parameters[1]); + const server = await listen(app); + try { + const resp = await fetch( + `${server.url}/api/plugins/${encodeURIComponent(plugin.id)}/duplicate-project`, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-od-workspace-id': 'workspace-sqlite', + 'x-od-workspace-type': 'team', + 'x-od-workspace-member-id': 'member-sqlite', + 'x-od-workspace-role': 'member', + 'x-od-workspace-lifecycle-state': 'active', + 'x-od-workspace-member-status': 'active', + 'x-od-workspace-can-share-projects': 'true', + 'x-od-workspace-can-write-synced-files': 'true', + }, + body: JSON.stringify({ name: 'SQLite Plugin Fixture' }), + }, + ); + + expect(resp.status).toBe(500); + expect(getProject(db, projectId)).toBeNull(); + expect(getConversation(db, conversationId)).toBeNull(); + await expectMissing(path.join(projectsRoot, projectId)); + } finally { + await close(server.server); + } + }); }); async function listen(app: express.Express): Promise<{ server: http.Server; url: string }> { diff --git a/apps/daemon/tests/plugins-genui-spec-enrichment.test.ts b/apps/daemon/tests/plugins-genui-spec-enrichment.test.ts index 362e55f3786..49f9984a3e8 100644 --- a/apps/daemon/tests/plugins-genui-spec-enrichment.test.ts +++ b/apps/daemon/tests/plugins-genui-spec-enrichment.test.ts @@ -12,9 +12,18 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import type { WorkspaceCollabContext } from '@open-design/contracts'; import Database from 'better-sqlite3'; +import express from 'express'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createAuthorizeProjectRequest } from '../src/collab/project-request-authority.js'; +import { + ensureWorkspaceProject, + getWorkspaceProject, + getWorkspaceProjectByProjectId, +} from '../src/db.js'; +import { registerGenuiRoutes } from '../src/routes/genui.js'; import { startServer } from '../src/server.js'; type StartedServer = { server: http.Server; url: string }; @@ -31,6 +40,59 @@ let pluginRoot: string; const cleanupRows: string[] = []; const PLUGIN_ID = `phase2a5-form-${Date.now()}`; +const WORKSPACE_ID = 'workspace-genui-spec'; +const WORKSPACE_MEMBER_ID = 'member-genui-spec'; + +function workspaceContext(): WorkspaceCollabContext { + return { + workspaceId: WORKSPACE_ID, + workspaceName: 'GenUI spec fixture', + workspaceType: 'team', + workspaceMemberId: WORKSPACE_MEMBER_ID, + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + billingState: 'active', + planId: 'team_plus', + providerMode: 'platform_credits', + seatSummary: { + seatLimit: 3, + usedSeats: 1, + availableSeats: 2, + isSeatFull: false, + }, + permissions: { + canManageMembers: true, + canManageBilling: true, + canInviteMembers: true, + canManageAutoRecharge: true, + canShareProjects: true, + canWriteSyncedFiles: true, + canViewWorkspaceSettings: true, + canManageSharedResources: true, + }, + } as WorkspaceCollabContext; +} + +async function listen(app: express.Express): Promise { + const routeServer = http.createServer(app); + await new Promise((resolve, reject) => { + routeServer.once('error', reject); + routeServer.listen(0, '127.0.0.1', () => { + routeServer.off('error', reject); + resolve(); + }); + }); + const address = routeServer.address(); + if (!address || typeof address === 'string') { + routeServer.close(); + throw new Error('expected GenUI route fixture to listen on a TCP port'); + } + return { + server: routeServer, + url: `http://127.0.0.1:${address.port}`, + }; +} beforeEach(async () => { pluginRoot = await mkdtemp(path.join(os.tmpdir(), 'od-genui-spec-')); @@ -156,12 +218,20 @@ describe('GET /api/runs/:runId/genui/:surfaceId enriches with snapshot spec', () const snapshotId = projBody.appliedPluginSnapshotId; expect(typeof snapshotId).toBe('string'); - // Insert a genui_surfaces row directly (no agent runs in the test - // env). The runId is synthetic; the GET endpoint keys off it. + // Insert a genui_surfaces row directly (no agent process runs in the + // fixture), then expose that synthetic run through the same in-memory + // registry contract the production route authorizes before reading rows. const dbPath = path.join(serverRuntimeDataRoot, 'app.sqlite'); const db = new Database(dbPath); const runId = `run-phase2a5-${Date.now()}`; const surfaceRowId = `srf-phase2a5-${Date.now()}`; + ensureWorkspaceProject(db, { + projectId, + workspaceId: WORKSPACE_ID, + visibility: 'team', + resourceState: 'active', + createdByWorkspaceMemberId: WORKSPACE_MEMBER_ID, + }); db.prepare( `INSERT INTO genui_surfaces ( id, project_id, conversation_id, run_id, plugin_snapshot_id, @@ -177,32 +247,89 @@ describe('GET /api/runs/:runId/genui/:surfaceId enriches with snapshot spec', () 'discovery', Date.now(), ); - db.close(); - const resp = await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runId)}/genui/discovery`); - expect(resp.status).toBe(200); - const body = await resp.json() as { - surfaceId: string; - kind: string; - spec: { - id: string; + const routeApp = express(); + routeApp.use(express.json()); + const authorizeProjectRequest = createAuthorizeProjectRequest({ + db, + getWorkspaceProject: (_db, workspaceId, candidateProjectId) => + getWorkspaceProject(db, workspaceId, candidateProjectId), + getWorkspaceProjectByProjectId: (_db, candidateProjectId) => + getWorkspaceProjectByProjectId(db, candidateProjectId), + verifyWorkspaceRequestAuthority: async (req: any) => { + const workspaceId = req.get('x-od-workspace-id')?.trim(); + const workspaceMemberId = req.get('x-od-workspace-member-id')?.trim(); + if ( + workspaceId !== WORKSPACE_ID + || workspaceMemberId !== WORKSPACE_MEMBER_ID + ) { + return { + ok: false, + status: 403, + code: 'WORKSPACE_ACCESS_DENIED', + message: 'workspace identity does not match the GenUI fixture', + }; + } + return { ok: true, context: workspaceContext() }; + }, + sendApiError: (res, status, code, message, details) => + res.status(status).json({ error: { code, message, ...details } }), + }); + registerGenuiRoutes(routeApp, { + db, + design: { + runs: { + get: (candidateRunId) => + candidateRunId === runId ? { projectId } : undefined, + }, + }, + paths: { PROJECTS_DIR: path.join(serverRuntimeDataRoot, 'projects') }, + authorizeProjectRequest, + }); + + let routeServer: http.Server | undefined; + try { + const startedRouteServer = await listen(routeApp); + routeServer = startedRouteServer.server; + const resp = await fetch( + `${startedRouteServer.url}/api/runs/${encodeURIComponent(runId)}/genui/discovery`, + { + headers: { + 'x-od-workspace-id': WORKSPACE_ID, + 'x-od-workspace-member-id': WORKSPACE_MEMBER_ID, + }, + }, + ); + expect(resp.status).toBe(200); + const body = await resp.json() as { + surfaceId: string; kind: string; - schema?: { - type?: string; - required?: string[]; - properties?: Record; + spec: { + id: string; + kind: string; + schema?: { + type?: string; + required?: string[]; + properties?: Record; + }; }; }; - }; - expect(body.surfaceId).toBe('discovery'); - expect(body.kind).toBe('form'); - // The new `spec` field carries the snapshot's surface spec. - expect(body.spec).toBeDefined(); - expect(body.spec.id).toBe('discovery'); - expect(body.spec.kind).toBe('form'); - expect(body.spec.schema?.type).toBe('object'); - expect(body.spec.schema?.required).toEqual(['topic']); - expect(body.spec.schema?.properties?.topic).toBeDefined(); - expect(body.spec.schema?.properties?.audience?.enum).toEqual(['VC pitch', 'general']); + expect(body.surfaceId).toBe('discovery'); + expect(body.kind).toBe('form'); + // The new `spec` field carries the snapshot's surface spec. + expect(body.spec).toBeDefined(); + expect(body.spec.id).toBe('discovery'); + expect(body.spec.kind).toBe('form'); + expect(body.spec.schema?.type).toBe('object'); + expect(body.spec.schema?.required).toEqual(['topic']); + expect(body.spec.schema?.properties?.topic).toBeDefined(); + expect(body.spec.schema?.properties?.audience?.enum).toEqual(['VC pitch', 'general']); + } finally { + await new Promise((resolve) => { + if (!routeServer) return resolve(); + routeServer.close(() => resolve()); + }); + db.close(); + } }); }); diff --git a/apps/daemon/tests/plugins-headless-run.test.ts b/apps/daemon/tests/plugins-headless-run.test.ts index 4442deb32fd..9133f3f208c 100644 --- a/apps/daemon/tests/plugins-headless-run.test.ts +++ b/apps/daemon/tests/plugins-headless-run.test.ts @@ -92,6 +92,19 @@ async function withFakeAgent( } } +async function withHeadlessOpencode(run: () => Promise): Promise { + return await withFakeAgent( + 'opencode', + ` +process.stdin.resume(); +process.stdin.on('end', () => { + console.log(JSON.stringify({ type: 'text', part: { text: 'headless-ok' } })); +}); +`, + run, + ); +} + async function runCli( args: string[], options: { timeout?: number } = {}, @@ -316,56 +329,61 @@ describe('Plan §8 e2e-3 (entry slice) — headless install → project → run' expect(createBody.project.id).toBe(projectId); expect(createBody.appliedPluginSnapshotId).toBeTruthy(); - // 3. Start a run that re-uses the same applied snapshot id. - const runResp = await fetch(`${baseUrl}/api/runs`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - projectId, - pluginId: 'sample-plugin', - appliedPluginSnapshotId: createBody.appliedPluginSnapshotId, - pluginInputs: { topic: 'agentic design' }, - }), + await withHeadlessOpencode(async () => { + // 3. Start a non-AMR run that re-uses the same applied snapshot id. + // This plugin contract test intentionally has no Workspace headers. + const runResp = await fetch(`${baseUrl}/api/runs`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + projectId, + agentId: 'opencode', + pluginId: 'sample-plugin', + appliedPluginSnapshotId: createBody.appliedPluginSnapshotId, + pluginInputs: { topic: 'agentic design' }, + }), + }); + expect(runResp.status).toBe(202); + const runBody = (await runResp.json()) as { + runId: string; + pluginId?: string; + appliedPluginSnapshotId?: string; + }; + expect(runBody.runId).toBeTruthy(); + expect(runBody.pluginId).toBe('sample-plugin'); + expect(runBody.appliedPluginSnapshotId).toBe(createBody.appliedPluginSnapshotId); + + // 4. The headerless run status surfaces the snapshot id so a polling + // client can reach replay without parsing the SSE stream. + const statusResp = await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runBody.runId)}`); + expect(statusResp.status).toBe(200); + const statusBody = (await statusResp.json()) as { + id: string; + projectId: string; + pluginId: string | null; + appliedPluginSnapshotId: string | null; + }; + expect(statusBody.pluginId).toBe('sample-plugin'); + expect(statusBody.appliedPluginSnapshotId).toBe(createBody.appliedPluginSnapshotId); + + // 5. Replay reads the same snapshot row. + const snapResp = await fetch(`${baseUrl}/api/applied-plugins/${encodeURIComponent(createBody.appliedPluginSnapshotId!)}`); + expect(snapResp.status).toBe(200); + const snap = (await snapResp.json()) as { + snapshotId: string; + pluginId: string; + query?: string; + inputs?: Record; + }; + expect(snap.snapshotId).toBe(createBody.appliedPluginSnapshotId); + expect(snap.pluginId).toBe('sample-plugin'); + expect(snap.query).toBe('Generate a {{topic}} brief for {{audience}}.'); + expect(snap.inputs).toEqual({ audience: 'general', topic: 'agentic design' }); + + // Cancel the run before restoring PATH so a deferred start cannot resolve + // the user's real OpenCode binary after the controlled fake disappears. + await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runBody.runId)}/cancel`, { method: 'POST' }); }); - expect(runResp.status).toBe(202); - const runBody = (await runResp.json()) as { - runId: string; - pluginId?: string; - appliedPluginSnapshotId?: string; - }; - expect(runBody.runId).toBeTruthy(); - expect(runBody.pluginId).toBe('sample-plugin'); - expect(runBody.appliedPluginSnapshotId).toBe(createBody.appliedPluginSnapshotId); - - // 4. The run status surfaces the snapshot id so a polling client - // can reach replay without parsing the SSE stream. - const statusResp = await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runBody.runId)}`); - expect(statusResp.status).toBe(200); - const statusBody = (await statusResp.json()) as { - id: string; - projectId: string; - pluginId: string | null; - appliedPluginSnapshotId: string | null; - }; - expect(statusBody.pluginId).toBe('sample-plugin'); - expect(statusBody.appliedPluginSnapshotId).toBe(createBody.appliedPluginSnapshotId); - - // 5. Replay reads the same snapshot row. - const snapResp = await fetch(`${baseUrl}/api/applied-plugins/${encodeURIComponent(createBody.appliedPluginSnapshotId!)}`); - expect(snapResp.status).toBe(200); - const snap = (await snapResp.json()) as { - snapshotId: string; - pluginId: string; - query?: string; - inputs?: Record; - }; - expect(snap.snapshotId).toBe(createBody.appliedPluginSnapshotId); - expect(snap.pluginId).toBe('sample-plugin'); - expect(snap.query).toBe('Generate a {{topic}} brief for {{audience}}.'); - expect(snap.inputs).toEqual({ audience: 'general', topic: 'agentic design' }); - - // Cancel the run so the test cleans up the in-memory child path. - await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runBody.runId)}/cancel`, { method: 'POST' }); }); it('creates share projects for publishing and contributing a user plugin', async () => { @@ -808,67 +826,71 @@ process.stdin.on('end', () => { }; expect(createBody.appliedPluginSnapshotId).toBeTruthy(); - const runResp = await fetch(`${baseUrl}/api/runs`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - projectId, - pluginId: 'pipeline-plugin', - appliedPluginSnapshotId: createBody.appliedPluginSnapshotId, - }), - }); - expect(runResp.status).toBe(202); - const runBody = (await runResp.json()) as { runId: string }; - - // The pipeline emits its first event synchronously inside POST - // /api/runs (firePipelineForRun runs before design.runs.start - // schedules the agent), so by the time we GET /api/runs/:id/events - // the run buffer already contains pipeline_stage_started. - // Wait briefly for the async tail (devloop iteration log) to settle. - await new Promise((r) => setTimeout(r, 30)); - - const statusResp = await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runBody.runId)}`); - const statusBody = (await statusResp.json()) as { id: string }; - expect(statusBody.id).toBe(runBody.runId); - - // Read the run's event buffer through the SSE stream — the - // server pipes every record through res.write, so reading the - // body until 'end' or pipeline_stage_completed surfaces the - // first events. We don't actually wait for end (the run is - // long-running); we just look for the stage-start anchor. - const eventsResp = await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runBody.runId)}/events`, { - headers: { accept: 'text/event-stream' }, - }); - expect(eventsResp.body).toBeTruthy(); - const reader = eventsResp.body!.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let firstStageEvent: string | null = null; - let messageChunkSeen = false; - const start = Date.now(); - while (Date.now() - start < 1500) { - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const blocks = buffer.split('\n\n'); - buffer = blocks.pop() ?? ''; - for (const block of blocks) { - const eventLine = block.split('\n').find((l) => l.startsWith('event: ')); - if (!eventLine) continue; - const event = eventLine.slice('event: '.length); - if (event === 'pipeline_stage_started' && !firstStageEvent && !messageChunkSeen) { - firstStageEvent = event; + await withHeadlessOpencode(async () => { + const runResp = await fetch(`${baseUrl}/api/runs`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + projectId, + agentId: 'opencode', + pluginId: 'pipeline-plugin', + appliedPluginSnapshotId: createBody.appliedPluginSnapshotId, + }), + }); + expect(runResp.status).toBe(202); + const runBody = (await runResp.json()) as { runId: string }; + + // The pipeline emits its first event synchronously inside POST + // /api/runs (firePipelineForRun runs before design.runs.start + // schedules the agent), so by the time we GET /api/runs/:id/events + // the run buffer already contains pipeline_stage_started. + // Wait briefly for the async tail (devloop iteration log) to settle. + await new Promise((r) => setTimeout(r, 30)); + + const statusResp = await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runBody.runId)}`); + const statusBody = (await statusResp.json()) as { id: string }; + expect(statusBody.id).toBe(runBody.runId); + + // Read the run's event buffer through the headerless SSE stream — the + // server pipes every record through res.write, so reading the body until + // 'end' or pipeline_stage_completed surfaces the first events. We don't + // actually wait for end; we just look for the stage-start anchor. + const eventsResp = await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runBody.runId)}/events`, { + headers: { accept: 'text/event-stream' }, + }); + expect(eventsResp.body).toBeTruthy(); + const reader = eventsResp.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let firstStageEvent: string | null = null; + let messageChunkSeen = false; + const start = Date.now(); + while (Date.now() - start < 1500) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const blocks = buffer.split('\n\n'); + buffer = blocks.pop() ?? ''; + for (const block of blocks) { + const eventLine = block.split('\n').find((l) => l.startsWith('event: ')); + if (!eventLine) continue; + const event = eventLine.slice('event: '.length); + if (event === 'pipeline_stage_started' && !firstStageEvent && !messageChunkSeen) { + firstStageEvent = event; + } + if (event === 'message_chunk') messageChunkSeen = true; + if (firstStageEvent || event === 'end') break; } - if (event === 'message_chunk') messageChunkSeen = true; - if (firstStageEvent || event === 'end') break; + if (firstStageEvent) break; } - if (firstStageEvent) break; - } - void reader.cancel().catch(() => undefined); + void reader.cancel().catch(() => undefined); - expect(firstStageEvent).toBe('pipeline_stage_started'); + expect(firstStageEvent).toBe('pipeline_stage_started'); - await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runBody.runId)}/cancel`, { method: 'POST' }); + // Keep the fake binary installed through cancellation for the same + // fire-and-forget startup guarantee as the snapshot-pinning case. + await fetch(`${baseUrl}/api/runs/${encodeURIComponent(runBody.runId)}/cancel`, { method: 'POST' }); + }); await fs.rm(tmpRoot, { recursive: true, force: true }); }); }); diff --git a/apps/daemon/tests/plugins-installer-archive.test.ts b/apps/daemon/tests/plugins-installer-archive.test.ts index 267862b1623..8d87fc1b3f9 100644 --- a/apps/daemon/tests/plugins-installer-archive.test.ts +++ b/apps/daemon/tests/plugins-installer-archive.test.ts @@ -125,6 +125,59 @@ describe('archive installer', () => { expect(row).toEqual({ source_kind: 'github', source: 'github:open-design/sample-plugin' }); }); + it('normalizes a browser GitHub repository URL through the GitHub installer', async () => { + const tarball = await buildFixtureTarball({ rootPrefix: 'sample-plugin-abc123' }); + let urlSeen = ''; + const fetcher: ArchiveFetcher = async (u) => { + urlSeen = u; + return makeFetcher(tarball)(''); + }; + let success = false; + let error: string | undefined; + for await (const ev of installPlugin(db, { + source: 'https://github.com/open-design/sample-plugin/', + roots: { userPluginsRoot: pluginsRoot }, + fetcher, + })) { + if (ev.kind === 'success') success = true; + if (ev.kind === 'error') error = ev.message; + } + if (!success) { + throw new Error(`install failed: ${error}`); + } + + expect(urlSeen).toBe('https://codeload.github.com/open-design/sample-plugin/tar.gz/HEAD'); + const row = db.prepare( + `SELECT source_kind, source FROM installed_plugins WHERE id = 'sample-plugin'`, + ).get(); + expect(row).toEqual({ + source_kind: 'github', + source: 'github:open-design/sample-plugin', + }); + }); + + it.each([ + 'https://github.com/open-design/sample-plugin/issues', + 'https://github.com/open-design/sample-plugin/tree/main', + 'https://github.com/open-design/sample-plugin?tab=readme', + ])('rejects a non-root GitHub browser URL before fetching it: %s', async (source) => { + let fetched = false; + let error: string | undefined; + for await (const ev of installPlugin(db, { + source, + roots: { userPluginsRoot: pluginsRoot }, + fetcher: async () => { + fetched = true; + return makeResponse('should not fetch'); + }, + })) { + if (ev.kind === 'error') error = ev.message; + } + + expect(fetched).toBe(false); + expect(error).toContain('repository root only'); + }); + it('extracts a github source with a ref and plugin subpath', async () => { const fixtureSrc = path.join(__dirname, 'fixtures', 'plugin-fixtures', 'sample-plugin'); const fixtureFiles = await readdir(fixtureSrc); diff --git a/apps/daemon/tests/plugins-lockfile.test.ts b/apps/daemon/tests/plugins-lockfile.test.ts index e602c826e6b..e403f99d80c 100644 --- a/apps/daemon/tests/plugins-lockfile.test.ts +++ b/apps/daemon/tests/plugins-lockfile.test.ts @@ -68,4 +68,32 @@ describe('plugin lockfile', () => { await rm(dir, { recursive: true, force: true }); } }); + + it('drops no entry when concurrent installs race the same lockfile (#109)', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'od-lock-race-')); + try { + const filePath = path.join(dir, '.od', 'od-plugin-lock.json'); + const names = Array.from({ length: 12 }, (_, i) => `community/plugin-${i}`); + // All twelve read-modify-write cycles start from the same on-disk state + // and race to write back — the exact shape a burst of concurrent + // installs produces. Before the per-path write queue, whichever write + // landed last silently discarded every entry from the others. + await Promise.all( + names.map((name) => + upsertPluginLockfileEntry( + filePath, + { + ...plugin, + sourceMarketplaceEntryName: name, + }, + 123, + ), + ), + ); + const lockfile = await readPluginLockfile(filePath); + expect(Object.keys(lockfile.plugins).sort()).toEqual([...names].sort()); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); }); diff --git a/apps/daemon/tests/plugins-uninstall-workspace-gate.test.ts b/apps/daemon/tests/plugins-uninstall-workspace-gate.test.ts new file mode 100644 index 00000000000..333ae2aa186 --- /dev/null +++ b/apps/daemon/tests/plugins-uninstall-workspace-gate.test.ts @@ -0,0 +1,149 @@ +// Red spec for the plugin uninstall security gap the workspace-isolation +// round fixed: `POST /api/plugins/:id/uninstall` used to carry NO permission +// check at all — any caller, any workspace, any role could uninstall any +// plugin. It is now gated the same way project mutations are, via the shared +// `enforceWorkspaceResourceMutation` (collab/workspace-resource-mutation.ts), +// applied only to plugins that carry an actual `workspace_resources` binding +// row (see routes/plugins/index.ts's doc comment on the uninstall route for +// why a legacy/unbound plugin stays outside the gate). +// +// Follows the same "seed the plugin folder directly on disk, alongside the +// real running server" pattern as tests/plugins-uninstall-traversal.test.ts, +// plus directly seeding the `workspace_resources` binding via db.ts — the +// same SQLite instance the running server already opened (db.ts caches one +// instance per resolved data dir; RUNTIME_DATA_DIR / OD_DATA_DIR agree within +// one vitest file, so this reuses the server's own connection instead of +// racing a second one). + +import type http from 'node:http'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { startServer } from '../src/server.js'; +import { defaultRegistryRoots } from '../src/plugins/registry.js'; +import { ensureWorkspaceResource, openDatabase, updateWorkspaceResource } from '../src/db.js'; + +let server: http.Server; +let baseUrl: string; +let shutdown: (() => Promise | void) | undefined; + +beforeAll(async () => { + const started = (await startServer({ port: 0, returnServer: true })) as { + url: string; + server: http.Server; + shutdown?: () => Promise | void; + }; + baseUrl = started.url; + server = started.server; + shutdown = started.shutdown; +}); + +afterAll(async () => { + await Promise.resolve(shutdown?.()); + await new Promise((resolve) => server.close(() => resolve())); +}); + +function workspaceHeaders(memberId: string, role: 'owner' | 'admin' | 'member', workspaceId: string) { + return { + 'x-od-workspace-id': workspaceId, + 'x-od-workspace-member-id': memberId, + 'x-od-workspace-role': role, + }; +} + +async function seedPluginFolder(pluginId: string): Promise { + const pluginsRoot = defaultRegistryRoots().userPluginsRoot; + const folder = path.join(pluginsRoot, pluginId); + await mkdir(folder, { recursive: true }); + await writeFile( + path.join(folder, 'open-design.json'), + JSON.stringify({ name: pluginId, title: pluginId, version: '1.0.0' }), + ); + return folder; +} + +function bindPluginToWorkspace(pluginId: string, workspaceId: string, createdByWorkspaceMemberId: string) { + const db = openDatabase(process.cwd(), { dataDir: process.env.OD_DATA_DIR! }); + return ensureWorkspaceResource(db, 'plugin', workspaceId, pluginId, { + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId, + updatedByWorkspaceMemberId: createdByWorkspaceMemberId, + }); +} + +describe('POST /api/plugins/:id/uninstall — workspace ownership gate', () => { + it('rejects a non-owner, non-privileged member of the same workspace', async () => { + const pluginId = `wsgate-member-${Date.now()}`; + const folder = await seedPluginFolder(pluginId); + bindPluginToWorkspace(pluginId, 'ws-gate-1', 'member-owner'); + + const resp = await fetch(`${baseUrl}/api/plugins/${pluginId}/uninstall`, { + method: 'POST', + headers: workspaceHeaders('member-other', 'member', 'ws-gate-1'), + }); + + expect(resp.status).toBe(403); + expect(existsSync(folder)).toBe(true); + }); + + it('allows the member who installed the plugin to uninstall it', async () => { + const pluginId = `wsgate-self-${Date.now()}`; + const folder = await seedPluginFolder(pluginId); + bindPluginToWorkspace(pluginId, 'ws-gate-2', 'member-owner'); + + const resp = await fetch(`${baseUrl}/api/plugins/${pluginId}/uninstall`, { + method: 'POST', + headers: workspaceHeaders('member-owner', 'member', 'ws-gate-2'), + }); + + expect(resp.status).toBe(200); + expect(existsSync(folder)).toBe(false); + }); + + it('allows a workspace admin to uninstall a plugin installed by someone else', async () => { + const pluginId = `wsgate-admin-${Date.now()}`; + const folder = await seedPluginFolder(pluginId); + bindPluginToWorkspace(pluginId, 'ws-gate-3', 'member-owner'); + + const resp = await fetch(`${baseUrl}/api/plugins/${pluginId}/uninstall`, { + method: 'POST', + headers: workspaceHeaders('member-admin', 'admin', 'ws-gate-3'), + }); + + expect(resp.status).toBe(200); + expect(existsSync(folder)).toBe(false); + }); + + // No retroactive tagging (spec's stated design principle, same rule + // design-systems already ships): a plugin with no workspace_resources row + // — every plugin installed before this round shipped — stays outside the + // isolation regime rather than becoming permanently un-uninstallable the + // moment a caller happens to carry workspace headers. + it('still allows uninstalling a legacy plugin with no workspace binding at all', async () => { + const pluginId = `wsgate-legacy-${Date.now()}`; + const folder = await seedPluginFolder(pluginId); + + const resp = await fetch(`${baseUrl}/api/plugins/${pluginId}/uninstall`, { + method: 'POST', + headers: workspaceHeaders('member-someone-else', 'member', 'ws-gate-4'), + }); + + expect(resp.status).toBe(200); + expect(existsSync(folder)).toBe(false); + }); + + it('rejects a headerless caller against a team-visibility plugin', async () => { + const pluginId = `wsgate-team-${Date.now()}`; + const folder = await seedPluginFolder(pluginId); + bindPluginToWorkspace(pluginId, 'ws-gate-5', 'member-owner'); + const db = openDatabase(process.cwd(), { dataDir: process.env.OD_DATA_DIR! }); + updateWorkspaceResource(db, 'plugin', 'ws-gate-5', pluginId, { visibility: 'team' }); + + const resp = await fetch(`${baseUrl}/api/plugins/${pluginId}/uninstall`, { method: 'POST' }); + + expect(resp.status).toBe(400); + expect(existsSync(folder)).toBe(true); + }); +}); diff --git a/apps/daemon/tests/plugins-workspace-scope.test.ts b/apps/daemon/tests/plugins-workspace-scope.test.ts new file mode 100644 index 00000000000..0628b025ac5 --- /dev/null +++ b/apps/daemon/tests/plugins-workspace-scope.test.ts @@ -0,0 +1,362 @@ +// `listInstalledPlugins`'s workspace-scoped filter (registry.ts): a plugin +// bound into a DIFFERENT workspace than the one asked about is hidden, but an +// UNBOUND plugin (no `workspace_resources` row — every plugin installed +// before workspace isolation shipped looks like this) stays visible from +// every workspace. Mirrors design-systems' `designSystemVisibleFromWorkspace` +// rule (design-systems/index.ts) applied to the generic `workspace_resources` +// table instead of a metadata.json sidecar. +// +// spec 04 §10 addendum: `workspaceId` OMITTED (the argument not passed at +// all) and `workspaceId: null` (passed explicitly, e.g. by `GET /api/plugins` +// when the request carries no `x-od-workspace-id` header) are DIFFERENT +// signals. Omitted means an internal caller (`od plugin list`, inventory +// stats, the bundled-scenario scan) never asked to be scoped — stays +// unfiltered. Explicit `null` means an HTTP caller DID ask to be scoped but +// has no identity to offer, and must now see only UNBOUND plugins, not +// everything — "no scope" must not mean "trust everything" +// (recvqbeDjAsejl / recvqbklNGDqYY). + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + closeDatabase, + ensureWorkspaceResource, + getWorkspaceResourceByResourceId, + openDatabase, + updateWorkspaceResource, +} from '../src/db.js'; +import { + activateWorkspaceTeamPluginIfStillShared, + listInstalledPlugins, + resolveAndActivateWorkspaceTeamPlugin, + resolveWorkspaceTeamPluginWithBindingGate, + upsertInstalledPlugin, + workspaceTeamPluginBindingActivationFence, + workspaceTeamPluginBindingAllowsRead, + workspaceTeamPluginBindingResourceId, +} from '../src/plugins/registry.js'; +import type { InstalledPluginRecord } from '@open-design/contracts'; + +let tempDir: string; + +beforeEach(() => { + tempDir = mkdtempSync(path.join(os.tmpdir(), 'od-plugins-workspace-scope-')); +}); + +afterEach(() => { + closeDatabase(); + rmSync(tempDir, { recursive: true, force: true }); +}); + +function fakePlugin(id: string): InstalledPluginRecord { + const now = Date.now(); + return { + id, + title: id, + version: '1.0.0', + sourceKind: 'local', + source: `/tmp/${id}`, + trust: 'trusted', + capabilitiesGranted: [], + manifest: { name: id, title: id, version: '1.0.0' } as InstalledPluginRecord['manifest'], + fsPath: `/tmp/${id}`, + installedAt: now, + updatedAt: now, + }; +} + +describe('listInstalledPlugins workspace scope', () => { + it('returns every plugin, unfiltered, when the workspaceId ARGUMENT IS OMITTED (backward compat)', () => { + const db = openDatabase(tempDir, { dataDir: tempDir }); + upsertInstalledPlugin(db, fakePlugin('plugin-unbound')); + upsertInstalledPlugin(db, fakePlugin('plugin-bound')); + ensureWorkspaceResource(db, 'plugin', 'ws-1', 'plugin-bound', { createdByWorkspaceMemberId: 'member-a' }); + + const all = listInstalledPlugins(db); + expect(all.map((p) => p.id).sort()).toEqual(['plugin-bound', 'plugin-unbound']); + }); + + it('hides a bound plugin when the caller passes an explicit null workspaceId (spec 04 §10)', () => { + // `headerValue()` returns `null` (never `undefined`) when a request + // carries no `x-od-workspace-id` header, so `GET /api/plugins` always + // passes a DEFINED second argument. That must reach the workspace filter + // the same as a real workspace id would, not silently take the "omitted" + // unfiltered path above — otherwise a signed-out / headerless caller + // could still see every workspace's claimed plugins. + const db = openDatabase(tempDir, { dataDir: tempDir }); + upsertInstalledPlugin(db, fakePlugin('plugin-unbound')); + upsertInstalledPlugin(db, fakePlugin('plugin-bound')); + ensureWorkspaceResource(db, 'plugin', 'ws-1', 'plugin-bound', { createdByWorkspaceMemberId: 'member-a' }); + + const scoped = listInstalledPlugins(db, null); + expect(scoped.map((p) => p.id)).toEqual(['plugin-unbound']); + }); + + it('keeps an unbound (legacy) plugin visible from every workspace', () => { + const db = openDatabase(tempDir, { dataDir: tempDir }); + upsertInstalledPlugin(db, fakePlugin('plugin-legacy')); + + expect(listInstalledPlugins(db, 'ws-1').map((p) => p.id)).toContain('plugin-legacy'); + expect(listInstalledPlugins(db, 'ws-2').map((p) => p.id)).toContain('plugin-legacy'); + }); + + it('hides a plugin bound to a different workspace, but shows it from its own', () => { + const db = openDatabase(tempDir, { dataDir: tempDir }); + upsertInstalledPlugin(db, fakePlugin('plugin-claimed')); + ensureWorkspaceResource(db, 'plugin', 'ws-1', 'plugin-claimed', { createdByWorkspaceMemberId: 'member-a' }); + + expect(listInstalledPlugins(db, 'ws-1').map((p) => p.id)).toContain('plugin-claimed'); + expect(listInstalledPlugins(db, 'ws-2').map((p) => p.id)).not.toContain('plugin-claimed'); + }); + + it('denies reads of a retired Team plugin without hiding a same-id Personal plugin', () => { + const db = openDatabase(tempDir, { dataDir: tempDir }); + upsertInstalledPlugin(db, fakePlugin('plugin-retracted')); + const teamBindingId = workspaceTeamPluginBindingResourceId( + 'ws-1', + 'plugin-retracted', + ); + ensureWorkspaceResource(db, 'plugin', 'ws-1', teamBindingId, { + visibility: 'team', + resourceState: 'active', + }); + updateWorkspaceResource(db, 'plugin', 'ws-1', teamBindingId, { + resourceState: 'deleted', + }); + + expect(listInstalledPlugins(db, 'ws-1').map((p) => p.id)).toContain( + 'plugin-retracted', + ); + expect(listInstalledPlugins(db).map((p) => p.id)).toContain( + 'plugin-retracted', + ); + expect( + workspaceTeamPluginBindingAllowsRead(db, 'ws-1', 'plugin-retracted'), + ).toBe(false); + }); + + it('scopes Team mirror bindings independently for each Workspace', () => { + const db = openDatabase(tempDir, { dataDir: tempDir }); + ensureWorkspaceResource(db, 'plugin', 'ws-personal', 'legacy-team-plugin', { + visibility: 'personal', + resourceState: 'active', + }); + expect( + workspaceTeamPluginBindingAllowsRead(db, 'ws-1', 'legacy-team-plugin'), + ).toBe(true); + + const otherWorkspaceBindingId = workspaceTeamPluginBindingResourceId( + 'ws-2', + 'legacy-team-plugin', + ); + ensureWorkspaceResource(db, 'plugin', 'ws-2', otherWorkspaceBindingId, { + visibility: 'team', + resourceState: 'active', + }); + expect( + workspaceTeamPluginBindingAllowsRead(db, 'ws-1', 'legacy-team-plugin'), + ).toBe(true); + }); + + it('keeps a Personal plugin visible after a same-id Team mirror is retired', () => { + const db = openDatabase(tempDir, { dataDir: tempDir }); + upsertInstalledPlugin(db, fakePlugin('plugin-collision')); + const teamBindingId = workspaceTeamPluginBindingResourceId( + 'ws-team', + 'plugin-collision', + ); + ensureWorkspaceResource(db, 'plugin', 'ws-team', teamBindingId, { + visibility: 'team', + resourceState: 'active', + }); + updateWorkspaceResource(db, 'plugin', 'ws-team', teamBindingId, { + resourceState: 'deleted', + }); + + expect(listInstalledPlugins(db, 'ws-personal').map((plugin) => plugin.id)) + .toContain('plugin-collision'); + }); + + it('drops a Team plugin retracted while its folder is resolving', async () => { + const db = openDatabase(tempDir, { dataDir: tempDir }); + const pluginId = 'plugin-concurrent-retraction'; + const workspaceId = 'ws-team'; + const bindingId = workspaceTeamPluginBindingResourceId(workspaceId, pluginId); + ensureWorkspaceResource(db, 'plugin', workspaceId, bindingId, { + visibility: 'team', + resourceState: 'active', + }); + + let finishResolve!: (value: { id: string }) => void; + const resolveGate = new Promise<{ id: string }>((resolve) => { + finishResolve = resolve; + }); + let resolveStarted!: () => void; + const started = new Promise((resolve) => { + resolveStarted = resolve; + }); + const pending = resolveWorkspaceTeamPluginWithBindingGate({ + bindingAllowsRead: () => + workspaceTeamPluginBindingAllowsRead(db, workspaceId, pluginId), + resolve: async () => { + resolveStarted(); + return resolveGate; + }, + }); + await started; + updateWorkspaceResource(db, 'plugin', workspaceId, bindingId, { + resourceState: 'deleted', + }); + finishResolve({ id: pluginId }); + + await expect(pending).resolves.toBeNull(); + }); + + it('does not reactivate a Team plugin retracted while materialization is resolving', async () => { + const db = openDatabase(tempDir, { dataDir: tempDir }); + const pluginId = 'plugin-sync-retraction'; + const workspaceId = 'ws-team'; + const bindingId = workspaceTeamPluginBindingResourceId(workspaceId, pluginId); + ensureWorkspaceResource(db, 'plugin', workspaceId, bindingId, { + visibility: 'team', + resourceState: 'active', + }); + + let finishResolve!: (value: { id: string }) => void; + const resolveGate = new Promise<{ id: string }>((resolve) => { + finishResolve = resolve; + }); + let resolveStarted!: () => void; + const started = new Promise((resolve) => { + resolveStarted = resolve; + }); + const pending = resolveAndActivateWorkspaceTeamPlugin({ + resolve: async () => { + resolveStarted(); + return resolveGate; + }, + captureActivationFence: () => 'active', + stillShared: async () => false, + activationFenceIsCurrent: () => false, + activate: () => { + updateWorkspaceResource(db, 'plugin', workspaceId, bindingId, { + resourceState: 'active', + }); + return true; + }, + }); + await started; + updateWorkspaceResource(db, 'plugin', workspaceId, bindingId, { + resourceState: 'deleted', + }); + finishResolve({ id: pluginId }); + await pending; + + expect( + workspaceTeamPluginBindingAllowsRead(db, workspaceId, pluginId), + ).toBe(false); + }); + + it.each(['versioned', 'unversioned'] as const)( + 'does not let an older cached %s listing reactivate a retired Team plugin', + async (mode) => { + const db = openDatabase(tempDir, { dataDir: tempDir }); + const pluginId = `plugin-old-${mode}`; + const workspaceId = 'ws-team'; + const bindingId = workspaceTeamPluginBindingResourceId(workspaceId, pluginId); + ensureWorkspaceResource(db, 'plugin', workspaceId, bindingId, { + visibility: 'team', + resourceState: 'active', + }); + let finishAuthoritativeRead!: (stillShared: boolean) => void; + const authoritativeRead = new Promise((resolve) => { + finishAuthoritativeRead = resolve; + }); + let readStarted!: () => void; + const started = new Promise((resolve) => { + readStarted = resolve; + }); + const oldListing = activateWorkspaceTeamPluginIfStillShared({ + captureActivationFence: () => + workspaceTeamPluginBindingActivationFence(db, workspaceId, pluginId), + stillShared: async () => { + readStarted(); + return authoritativeRead; + }, + activationFenceIsCurrent: (fence) => + workspaceTeamPluginBindingActivationFence(db, workspaceId, pluginId) === fence, + activate: () => { + updateWorkspaceResource(db, 'plugin', workspaceId, bindingId, { + resourceState: 'active', + }); + return true; + }, + }); + await started; + updateWorkspaceResource(db, 'plugin', workspaceId, bindingId, { + resourceState: 'deleted', + }); + finishAuthoritativeRead(false); + await oldListing; + + expect( + workspaceTeamPluginBindingAllowsRead(db, workspaceId, pluginId), + ).toBe(false); + }, + ); + + it('rejects a superseded positive shared read after a newer binding tombstone', async () => { + const db = openDatabase(tempDir, { dataDir: tempDir }); + const pluginId = 'plugin-superseded-positive'; + const workspaceId = 'ws-team'; + const bindingId = workspaceTeamPluginBindingResourceId(workspaceId, pluginId); + ensureWorkspaceResource(db, 'plugin', workspaceId, bindingId, { + visibility: 'team', + resourceState: 'active', + }); + + let finishAuthoritativeRead!: (stillShared: boolean) => void; + const authoritativeRead = new Promise((resolve) => { + finishAuthoritativeRead = resolve; + }); + let readStarted!: () => void; + const started = new Promise((resolve) => { + readStarted = resolve; + }); + const staleActivation = activateWorkspaceTeamPluginIfStillShared({ + captureActivationFence: () => + workspaceTeamPluginBindingActivationFence(db, workspaceId, pluginId), + stillShared: async () => { + readStarted(); + return authoritativeRead; + }, + activationFenceIsCurrent: (fence) => + workspaceTeamPluginBindingActivationFence(db, workspaceId, pluginId) === fence, + activate: () => { + updateWorkspaceResource(db, 'plugin', workspaceId, bindingId, { + resourceState: 'active', + }); + return true; + }, + }); + await started; + const originalUpdatedAt = Number( + getWorkspaceResourceByResourceId(db, 'plugin', bindingId)?.updatedAt, + ); + updateWorkspaceResource(db, 'plugin', workspaceId, bindingId, { + resourceState: 'deleted', + // Simulate two writes in the same millisecond: resourceState must keep + // the tombstone visible even when updatedAt alone cannot distinguish it. + updatedAt: originalUpdatedAt, + }); + finishAuthoritativeRead(true); + + await expect(staleActivation).resolves.toBe(false); + expect( + workspaceTeamPluginBindingAllowsRead(db, workspaceId, pluginId), + ).toBe(false); + }); +}); diff --git a/apps/daemon/tests/project-cli.test.ts b/apps/daemon/tests/project-cli.test.ts index 50404ab88fc..cc36c95d1f9 100644 --- a/apps/daemon/tests/project-cli.test.ts +++ b/apps/daemon/tests/project-cli.test.ts @@ -17,6 +17,7 @@ const TSX_CLI = pathResolve(REPO_ROOT, 'node_modules/tsx/dist/cli.mjs'); interface CapturedRequest { method: string; url: string; + headers: http.IncomingHttpHeaders; body: string; } @@ -47,6 +48,7 @@ async function startProjectStubServer(): Promise { const captured: CapturedRequest = { method: req.method ?? '', url: req.url ?? '', + headers: req.headers, body: raw, }; requests.push(captured); @@ -69,6 +71,59 @@ async function startProjectStubServer(): Promise { })); return; } + if (captured.method === 'GET' && captured.url === '/api/projects/project-1') { + res.statusCode = 200; + res.end(JSON.stringify({ + project: { id: 'project-1', name: 'Project One', workspaceId: 'ws-1' }, + resolvedDir: '/tmp/projects/project-1', + })); + return; + } + if (captured.method === 'GET' && captured.url === '/api/projects/project-1/files') { + res.statusCode = 200; + res.end(JSON.stringify({ files: [] })); + return; + } + if (captured.method === 'GET' && captured.url === '/api/workspaces/ws-1/projects?view=team') { + res.statusCode = 200; + res.end(JSON.stringify({ + projects: [ + { id: 'project-1', name: 'Project One', visibility: 'team', resourceState: 'active' }, + ], + })); + return; + } + if (captured.method === 'POST' && captured.url === '/api/workspace/invite') { + res.statusCode = 200; + res.end(JSON.stringify({ + results: [{ email: 'teammate@example.com', ok: true, inviteId: 'invite-1' }], + })); + return; + } + if (captured.method === 'GET' && captured.url === '/api/workspace/projects/team') { + res.statusCode = 200; + res.end(JSON.stringify({ + projects: [{ projectId: 'team-project-1', displayName: 'Team Project' }], + })); + return; + } + if (captured.method === 'GET' && captured.url === '/api/workspace/members') { + res.statusCode = 200; + res.end(JSON.stringify({ + members: [{ memberId: 'member-1', displayName: 'Member One', role: 'admin' }], + })); + return; + } + if (captured.method === 'GET' && captured.url === '/api/workspace/skills/team') { + res.statusCode = 200; + res.end(JSON.stringify({ ids: ['team-skill'], resources: [{ id: 'team-skill' }] })); + return; + } + if (captured.method === 'POST' && captured.url === '/api/workspaces/ws-1/projects/batch-delete') { + res.statusCode = 200; + res.end(JSON.stringify({ ok: true, deletedProjectIds: ['project-1', 'project-2'] })); + return; + } res.statusCode = 404; res.end(JSON.stringify({ error: { code: 'unexpected-request', message: captured.url } })); @@ -110,6 +165,43 @@ async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; } describe('od project CLI', () => { + it('documents exact workspace identity for bound project and file commands', async () => { + const projectHelp = await runCli(['project', 'help']); + const filesHelp = await runCli(['files', 'help']); + + expect(projectHelp.code).toBe(0); + expect(filesHelp.code).toBe(0); + expect(projectHelp.stdout).toContain('--workspace '); + expect(projectHelp.stdout).toContain('--workspace-member '); + expect(filesHelp.stdout).toContain('--workspace '); + expect(filesHelp.stdout).toContain('--workspace-member '); + }); + + it.each([ + ['project detail', ['project', 'info', 'project-1', '--json']], + ['project files', ['files', 'list', 'project-1', '--json']], + ])('sends exact workspace identity for bound %s', async (_label, command) => { + stub = await startProjectStubServer(); + + const result = await runCli([ + ...command, + '--workspace', + 'ws-1', + '--workspace-member', + 'member-1', + '--daemon-url', + stub.baseUrl, + ]); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(stub.requests).toHaveLength(1); + expect(stub.requests[0]!.headers).toMatchObject({ + 'x-od-workspace-id': 'ws-1', + 'x-od-workspace-member-id': 'member-1', + }); + }); + it('creates a design-system project with prompt-file content and JSON output', async () => { stub = await startProjectStubServer(); tempRoot = mkdtempSync(join(tmpdir(), 'od-project-cli-')); @@ -172,4 +264,229 @@ describe('od project CLI', () => { }); expect(JSON.parse(stub.requests[0]!.body)).toEqual({ name: 'Duplicate Copy' }); }); + + it('lists workspace projects through the workspace-scoped API', async () => { + stub = await startProjectStubServer(); + + const result = await runCli([ + 'workspace', + 'projects', + 'list', + '--workspace', + 'ws-1', + '--member', + 'member-1', + '--role', + 'admin', + '--view', + 'team', + '--json', + '--daemon-url', + stub.baseUrl, + ]); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toMatchObject({ + projects: [{ id: 'project-1', visibility: 'team' }], + }); + expect(stub.requests).toHaveLength(1); + expect(stub.requests[0]).toMatchObject({ + method: 'GET', + url: '/api/workspaces/ws-1/projects?view=team', + }); + expect(stub.requests[0]!.headers).toMatchObject({ + 'x-od-workspace-id': 'ws-1', + 'x-od-workspace-member-id': 'member-1', + 'x-od-workspace-role': 'admin', + }); + }); + + it('creates workspace invites through the workspace invite API', async () => { + stub = await startProjectStubServer(); + + const result = await runCli([ + 'workspace', + 'invite', + '--email', + 'teammate@example.com', + '--role', + 'member', + '--workspace', + 'ws-1', + '--member', + 'member-1', + '--json', + '--daemon-url', + stub.baseUrl, + ]); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual({ + results: [{ email: 'teammate@example.com', ok: true, inviteId: 'invite-1' }], + }); + expect(stub.requests).toHaveLength(1); + expect(stub.requests[0]).toMatchObject({ + method: 'POST', + url: '/api/workspace/invite', + body: JSON.stringify({ email: 'teammate@example.com', role: 'member' }), + }); + expect(stub.requests[0]!.headers).toMatchObject({ + 'x-od-workspace-id': 'ws-1', + 'x-od-workspace-member-id': 'member-1', + }); + }); + + it('lists team projects through the workspace discovery API', async () => { + stub = await startProjectStubServer(); + + const result = await runCli([ + 'workspace', + 'projects', + 'team', + '--workspace', + 'ws-1', + '--member', + 'member-1', + '--json', + '--daemon-url', + stub.baseUrl, + ]); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual({ + projects: [{ projectId: 'team-project-1', displayName: 'Team Project' }], + }); + expect(stub.requests).toHaveLength(1); + expect(stub.requests[0]).toMatchObject({ + method: 'GET', + url: '/api/workspace/projects/team', + }); + expect(stub.requests[0]!.headers).toMatchObject({ + 'x-od-workspace-id': 'ws-1', + 'x-od-workspace-member-id': 'member-1', + }); + }); + + it('lists workspace members through the workspace member directory API', async () => { + stub = await startProjectStubServer(); + + const result = await runCli([ + 'workspace', + 'members', + 'list', + '--workspace', + 'ws-1', + '--member', + 'member-1', + '--json', + '--daemon-url', + stub.baseUrl, + ]); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual({ + members: [{ memberId: 'member-1', displayName: 'Member One', role: 'admin' }], + }); + expect(stub.requests).toHaveLength(1); + expect(stub.requests[0]).toMatchObject({ + method: 'GET', + url: '/api/workspace/members', + }); + expect(stub.requests[0]!.headers).toMatchObject({ + 'x-od-workspace-id': 'ws-1', + 'x-od-workspace-member-id': 'member-1', + }); + }); + + it('rejects workspace directory commands without explicit workspace identity', async () => { + stub = await startProjectStubServer(); + + const result = await runCli([ + 'workspace', + 'members', + 'list', + '--json', + '--daemon-url', + stub.baseUrl, + ]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('--workspace and --workspace-member '); + expect(stub.requests).toHaveLength(0); + }); + + it('sends explicit CLI workspace identity to team resource routes', async () => { + stub = await startProjectStubServer(); + + const result = await runCli([ + 'collab', + 'team-resources', + 'skills', + '--workspace', + 'ws-1', + '--workspace-member', + 'member-1', + '--json', + '--daemon-url', + stub.baseUrl, + ]); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual({ + ids: ['team-skill'], + resources: [{ id: 'team-skill' }], + }); + expect(stub.requests).toHaveLength(1); + expect(stub.requests[0]).toMatchObject({ + method: 'GET', + url: '/api/workspace/skills/team', + }); + expect(stub.requests[0]!.headers).toMatchObject({ + 'x-od-workspace-id': 'ws-1', + 'x-od-workspace-member-id': 'member-1', + }); + }); + + it('sends repeatable project ids for workspace batch delete', async () => { + stub = await startProjectStubServer(); + + const result = await runCli([ + 'workspace', + 'projects', + 'batch-delete', + '--workspace', + 'ws-1', + '--member', + 'member-1', + '--role', + 'admin', + '--project', + 'project-1', + '--project', + 'project-2', + '--json', + '--daemon-url', + stub.baseUrl, + ]); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual({ ok: true, deletedProjectIds: ['project-1', 'project-2'] }); + expect(stub.requests).toHaveLength(1); + expect(stub.requests[0]).toMatchObject({ + method: 'POST', + url: '/api/workspaces/ws-1/projects/batch-delete', + body: JSON.stringify({ projectIds: ['project-1', 'project-2'] }), + }); + expect(stub.requests[0]!.headers).toMatchObject({ + 'x-od-workspace-id': 'ws-1', + 'x-od-workspace-member-id': 'member-1', + 'x-od-workspace-role': 'admin', + }); + }); }); diff --git a/apps/daemon/tests/project-command-cli-workspace-scope.test.ts b/apps/daemon/tests/project-command-cli-workspace-scope.test.ts new file mode 100644 index 00000000000..69ea4349388 --- /dev/null +++ b/apps/daemon/tests/project-command-cli-workspace-scope.test.ts @@ -0,0 +1,551 @@ +import { execFile } from 'node:child_process'; +import http from 'node:http'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const execFileP = promisify(execFile); +const daemonRoot = fileURLToPath(new URL('..', import.meta.url)); +const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)); +const cliEntry = fileURLToPath(new URL('../src/cli.ts', import.meta.url)); +const tsxCli = path.join(repoRoot, 'node_modules/tsx/dist/cli.mjs'); + +const TEAM_WORKSPACE_ID = 'team-workspace'; +const OTHER_WORKSPACE_ID = 'other-workspace'; +const CREATOR_MEMBER_ID = 'creator-member'; +const OTHER_MEMBER_ID = 'other-member'; + +type RequestRecord = { + method: string; + url: string; + headers: http.IncomingHttpHeaders; + body: string; +}; + +type CliResult = { + code: number; + stdout: string; + stderr: string; +}; + +type CommandFixture = { + label: string; + args: (projectId: string) => string[]; + requests: (projectId: string) => string[]; +}; + +let server: http.Server; +let baseUrl = ''; +let tempDir = ''; +let figmaFile = ''; +let requests: RequestRecord[] = []; + +function parseJsonBody(request: RequestRecord): Record { + if (!request.body || !request.headers['content-type']?.includes('application/json')) { + return {}; + } + return JSON.parse(request.body) as Record; +} + +function projectIdForRequest(request: RequestRecord): string { + const pathMatch = request.url.match(/^\/api\/projects\/([^/]+)/); + if (pathMatch?.[1]) return decodeURIComponent(pathMatch[1]); + if (request.url.includes('run-unbound')) return 'unbound-project'; + if (request.url.includes('run-bound')) return 'bound-project'; + const body = parseJsonBody(request); + return typeof body.projectId === 'string' ? body.projectId : 'bound-project'; +} + +function requestNeedsCreator(request: RequestRecord): boolean { + if (request.method === 'GET') return false; + if (/^\/api\/plugins\/[^/]+\/apply$/.test(request.url)) return false; + return true; +} + +function authorize(request: RequestRecord): { status: number; code?: string } { + if (projectIdForRequest(request) === 'unbound-project') return { status: 200 }; + const workspaceId = request.headers['x-od-workspace-id']; + const memberId = request.headers['x-od-workspace-member-id']; + if (!workspaceId || !memberId) { + return { status: 401, code: 'WORKSPACE_CONTEXT_REQUIRED' }; + } + if (workspaceId !== TEAM_WORKSPACE_ID) { + return { status: 403, code: 'WORKSPACE_PROJECT_PERMISSION_DENIED' }; + } + if (requestNeedsCreator(request) && memberId !== CREATOR_MEMBER_ID) { + return { status: 403, code: 'WORKSPACE_PROJECT_PERMISSION_DENIED' }; + } + return { status: 200 }; +} + +function sendJson(res: http.ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function successResponse(request: RequestRecord, res: http.ServerResponse): void { + if (request.url === '/api/runs' && request.method === 'POST') { + const projectId = projectIdForRequest(request); + sendJson(res, 200, { + runId: projectId === 'unbound-project' ? 'run-unbound' : 'run-bound', + appliedPluginSnapshotId: 'snapshot-1', + }); + return; + } + if (/^\/api\/runs\/run-(?:bound|unbound)\/events$/.test(request.url)) { + res.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + }); + res.end('event: end\ndata: {"status":"completed"}\n\n'); + return; + } + if (/^\/api\/plugins\/[^/]+\/apply$/.test(request.url)) { + sendJson(res, 200, { + ok: true, + appliedPlugin: { snapshotId: 'snapshot-1' }, + }); + return; + } + if (/\/conversations$/.test(request.url) && request.method === 'POST') { + sendJson(res, 200, { + conversation: { + id: 'conversation-1', + projectId: projectIdForRequest(request), + sessionMode: 'design', + }, + }); + return; + } + if (/\/conversations$/.test(request.url)) { + sendJson(res, 200, { conversations: [] }); + return; + } + if (/\/applied-plugins$/.test(request.url)) { + sendJson(res, 200, { snapshots: [] }); + return; + } + if (/\/plugin-candidates$/.test(request.url)) { + sendJson(res, 200, { candidates: [] }); + return; + } + if (/\/plugin-candidates\/[^/]+\/draft$/.test(request.url)) { + sendJson(res, 200, { + draftPath: 'plugins/draft', + validation: { ok: true }, + }); + return; + } + if (/\/plugin-candidates\/[^/]+\/dismiss$/.test(request.url)) { + sendJson(res, 200, { ok: true }); + return; + } + if (/\/figma\/import$/.test(request.url)) { + sendJson(res, 200, { + label: 'fixture.fig', + snapshotDir: 'figma', + inventory: { + decoded: true, + nodeCount: 1, + pageCount: 1, + frameCount: 1, + componentCount: 0, + colors: [], + fonts: [], + assetCount: 0, + hasThumbnail: false, + warnings: [], + }, + suggestedPrompt: 'Build the fixture', + }); + return; + } + sendJson(res, 404, { + error: { code: 'NOT_FOUND', message: request.url }, + }); +} + +beforeAll(async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), 'od-project-command-scope-')); + figmaFile = path.join(tempDir, 'fixture.fig'); + await writeFile(figmaFile, Buffer.from('fixture fig bytes')); + + server = http.createServer((req, res) => { + let body = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + const request: RequestRecord = { + method: req.method ?? '', + url: req.url ?? '', + headers: req.headers, + body, + }; + requests.push(request); + const authority = authorize(request); + if (authority.status !== 200) { + sendJson(res, authority.status, { + error: { + code: authority.code, + message: authority.code, + }, + }); + return; + } + successResponse(request, res); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('missing fixture address'); + baseUrl = `http://127.0.0.1:${address.port}`; +}); + +afterAll(async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + await rm(tempDir, { recursive: true, force: true }); +}); + +async function runCli(args: string[]): Promise { + try { + const { stdout, stderr } = await execFileP( + process.execPath, + [tsxCli, cliEntry, ...args], + { + cwd: daemonRoot, + env: { ...process.env, NODE_OPTIONS: '' }, + timeout: 15_000, + maxBuffer: 4 * 1024 * 1024, + }, + ); + return { code: 0, stdout, stderr }; + } catch (error) { + const failure = error as { + code?: number; + stdout?: string; + stderr?: string; + }; + return { + code: failure.code ?? 1, + stdout: failure.stdout ?? '', + stderr: failure.stderr ?? '', + }; + } +} + +function workspaceFlags( + memberId = CREATOR_MEMBER_ID, + workspaceId = TEAM_WORKSPACE_ID, +): string[] { + return [ + '--workspace', + workspaceId, + '--workspace-member', + memberId, + ]; +} + +const commandFixtures: CommandFixture[] = [ + { + label: 'conversation new', + args: (projectId) => ['conversation', 'new', projectId, '--json'], + requests: (projectId) => [`POST /api/projects/${projectId}/conversations`], + }, + { + label: 'conversation list', + args: (projectId) => ['conversation', 'list', projectId, '--json'], + requests: (projectId) => [`GET /api/projects/${projectId}/conversations`], + }, + { + label: 'chat new', + args: (projectId) => ['chat', 'new', '--project', projectId, '--json'], + requests: (projectId) => [`POST /api/projects/${projectId}/conversations`], + }, + { + label: 'plugin run and follow', + args: (projectId) => [ + 'plugin', + 'run', + 'fixture-plugin', + '--project', + projectId, + '--follow', + '--json', + ], + requests: (projectId) => [ + 'POST /api/plugins/fixture-plugin/apply', + 'POST /api/runs', + `GET /api/runs/${projectId === 'unbound-project' ? 'run-unbound' : 'run-bound'}/events`, + ], + }, + { + label: 'project plugin snapshots list', + args: (projectId) => [ + 'plugin', + 'snapshots', + 'list', + '--project', + projectId, + '--json', + ], + requests: (projectId) => [`GET /api/projects/${projectId}/applied-plugins`], + }, + { + label: 'plugin candidates list', + args: (projectId) => [ + 'plugin', + 'candidates', + 'list', + '--project', + projectId, + '--json', + ], + requests: (projectId) => [`GET /api/projects/${projectId}/plugin-candidates`], + }, + { + label: 'plugin candidate draft', + args: (projectId) => [ + 'plugin', + 'candidates', + 'draft', + 'candidate-1', + '--project', + projectId, + '--json', + ], + requests: (projectId) => [ + `POST /api/projects/${projectId}/plugin-candidates/candidate-1/draft`, + ], + }, + { + label: 'plugin candidate dismiss', + args: (projectId) => [ + 'plugin', + 'candidates', + 'dismiss', + 'candidate-1', + '--project', + projectId, + '--json', + ], + requests: (projectId) => [ + `POST /api/projects/${projectId}/plugin-candidates/candidate-1/dismiss`, + ], + }, + { + label: 'Figma URL import', + args: (projectId) => [ + 'figma', + 'import', + '--project', + projectId, + '--figma-url', + 'https://figma.com/file/fixture', + '--json', + ], + requests: () => ['POST /api/runs'], + }, + { + label: 'local Figma import', + args: (projectId) => [ + 'figma', + 'import', + '--project', + projectId, + '--file', + figmaFile, + '--json', + ], + requests: (projectId) => [`POST /api/projects/${projectId}/figma/import`], + }, + { + label: 'local Figma import and build', + args: (projectId) => [ + 'figma', + 'import', + '--project', + projectId, + '--file', + figmaFile, + '--build', + '--json', + ], + requests: (projectId) => [ + `POST /api/projects/${projectId}/figma/import`, + 'POST /api/runs', + ], + }, +]; + +const readFixtures = commandFixtures.filter((fixture) => [ + 'conversation list', + 'project plugin snapshots list', + 'plugin candidates list', +].includes(fixture.label)); + +const writeFixtures = commandFixtures.filter((fixture) => !readFixtures.includes(fixture)); + +describe('project command CLI explicit Workspace scope', () => { + for (const fixture of commandFixtures) { + it(`${fixture.label}: forwards exact creator scope through every request`, async () => { + requests = []; + const result = await runCli([ + ...fixture.args('bound-project'), + ...workspaceFlags(), + '--daemon-url', + baseUrl, + ]); + + expect(result.code, result.stderr).toBe(0); + expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual( + fixture.requests('bound-project'), + ); + for (const request of requests) { + expect(request.headers['x-od-workspace-id']).toBe(TEAM_WORKSPACE_ID); + expect(request.headers['x-od-workspace-member-id']).toBe(CREATOR_MEMBER_ID); + } + }); + + it(`${fixture.label}: preserves unbound legacy headerless behavior`, async () => { + requests = []; + const result = await runCli([ + ...fixture.args('unbound-project'), + '--daemon-url', + baseUrl, + ]); + + expect(result.code, result.stderr).toBe(0); + expect(requests.map((request) => `${request.method} ${request.url}`)).toEqual( + fixture.requests('unbound-project'), + ); + for (const request of requests) { + expect(request.headers['x-od-workspace-id']).toBeUndefined(); + expect(request.headers['x-od-workspace-member-id']).toBeUndefined(); + } + }); + } + + for (const fixture of readFixtures) { + it(`${fixture.label}: keeps Team reads available to another active member`, async () => { + requests = []; + const result = await runCli([ + ...fixture.args('bound-project'), + ...workspaceFlags(OTHER_MEMBER_ID), + '--daemon-url', + baseUrl, + ]); + + expect(result.code, result.stderr).toBe(0); + expect(requests).toHaveLength(1); + expect(requests[0]?.headers['x-od-workspace-member-id']).toBe(OTHER_MEMBER_ID); + }); + } + + for (const fixture of writeFixtures) { + it(`${fixture.label}: preserves another member's write denial`, async () => { + requests = []; + const result = await runCli([ + ...fixture.args('bound-project'), + ...workspaceFlags(OTHER_MEMBER_ID), + '--daemon-url', + baseUrl, + ]); + + expect(result.code).not.toBe(0); + expect(requests.length).toBeGreaterThan(0); + expect(requests.at(-1)?.headers['x-od-workspace-member-id']).toBe(OTHER_MEMBER_ID); + expect(requests).toHaveLength(fixture.label === 'plugin run and follow' ? 2 : 1); + }); + } + + it('forwards a conflicting Workspace for the daemon to reject', async () => { + requests = []; + const result = await runCli([ + 'conversation', + 'list', + 'bound-project', + ...workspaceFlags(CREATOR_MEMBER_ID, OTHER_WORKSPACE_ID), + '--daemon-url', + baseUrl, + ]); + + expect(result.code).not.toBe(0); + expect(requests).toHaveLength(1); + expect(requests[0]?.headers['x-od-workspace-id']).toBe(OTHER_WORKSPACE_ID); + }); + + it.each([ + { + label: 'conversation', + args: ['conversation', 'new', 'bound-project', '--json'], + }, + { + label: 'chat', + args: ['chat', 'new', '--project', 'bound-project', '--json'], + }, + { + label: 'plugin run', + args: ['plugin', 'run', 'fixture-plugin', '--project', 'bound-project', '--json'], + }, + { + label: 'plugin snapshots', + args: ['plugin', 'snapshots', 'list', '--project', 'bound-project', '--json'], + }, + { + label: 'plugin candidates', + args: ['plugin', 'candidates', 'list', '--project', 'bound-project', '--json'], + }, + { + label: 'Figma', + args: [ + 'figma', + 'import', + '--project', + 'bound-project', + '--figma-url', + 'https://figma.com/file/fixture', + '--json', + ], + }, + ])('$label rejects either partial Workspace pair before HTTP', async ({ args }) => { + for (const partial of [ + ['--workspace', TEAM_WORKSPACE_ID], + ['--workspace-member', CREATOR_MEMBER_ID], + ]) { + requests = []; + const result = await runCli([ + ...args, + ...partial, + '--daemon-url', + baseUrl, + ]); + + expect(result.code).not.toBe(0); + expect(result.stderr).toContain('workspace-context-required'); + expect(result.stderr).toContain('--workspace and --workspace-member '); + expect(requests).toHaveLength(0); + } + }); + + it.each([ + ['conversation', ['conversation', 'help']], + ['chat', ['chat', 'help']], + ['plugin', ['plugin', 'help']], + ['plugin snapshots', ['plugin', 'snapshots', 'help']], + ['plugin candidates', ['plugin', 'candidates', 'help']], + ['Figma', ['figma', 'help']], + ])('%s help documents the explicit Workspace pair', async (_label, args) => { + const result = await runCli(args); + + expect(result.code, result.stderr).toBe(0); + expect(result.stdout).toContain('--workspace '); + expect(result.stdout).toContain('--workspace-member '); + }); +}); diff --git a/apps/daemon/tests/project-comment-permissions.test.ts b/apps/daemon/tests/project-comment-permissions.test.ts new file mode 100644 index 00000000000..6cf3348b368 --- /dev/null +++ b/apps/daemon/tests/project-comment-permissions.test.ts @@ -0,0 +1,446 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import express from 'express'; +import http from 'node:http'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + closeDatabase, + deletePreviewComment, + getConversation, + getPreviewComment, + insertConversation, + insertProject, + listPreviewComments, + openDatabase, + reorderPreviewComment, + updatePreviewCommentAnchor, + updatePreviewCommentStatus, + updateProject, + upsertPreviewComment, +} from '../src/db.js'; +import { registerProjectCommentRoutes } from '../src/routes/project/comments.js'; + +// Server-authoritative permission gating for the preview-comment mutation routes +// (product model 2026-07-09): editing a comment is author-only (structurally, via +// the author-scoped POST upsert); changing status (the send-to-agent lifecycle) +// and deleting are allowed for the author AND the project owner, and blocked for +// any other member. + +let server: http.Server | null = null; +let tempDir: string | null = null; + +afterEach(async () => { + if (server) { + const toClose = server; + server = null; + await new Promise((resolve) => toClose.close(() => resolve())); + } + closeDatabase(); + if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; +}); + +const OWNER = 'm-owner'; +const PROJECT = 'p1'; +const CONVERSATION = 'conv-1'; + +/** The Authorization header carries `member:` so a test can act as any member. */ +function asMember(memberId: string): { authorization: string } { + return { authorization: `member:${memberId}` }; +} + +async function startServer({ shared = true }: { shared?: boolean } = {}) { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-comment-perms-')); + const db = openDatabase(tempDir); + insertProject(db, { id: PROJECT, name: 'Project', createdAt: 1, updatedAt: 1 }); + insertConversation(db, { id: CONVERSATION, projectId: PROJECT, title: 'Chat', createdAt: 1, updatedAt: 1 }); + + const updated: string[] = []; + const deleted: string[] = []; + const created: string[] = []; + let syncComments = true; + + const app = express(); + app.use(express.json()); + registerProjectCommentRoutes(app, { + db, + projectStore: { updateProject } as any, + conversations: { + getConversation, + listPreviewComments, + upsertPreviewComment, + getPreviewComment, + updatePreviewCommentStatus, + updatePreviewCommentAnchor, + deletePreviewComment, + reorderPreviewComment, + } as any, + // Identify the caller from the `member:` Authorization header. + resolveAuthorMemberId: async (authorization) => + authorization?.startsWith('member:') ? authorization.slice('member:'.length) : undefined, + // p1 is owned by OWNER. + resolveProjectOwnerMemberId: async () => OWNER, + isSharedProject: async () => shared, + shouldSyncProjectComments: async () => syncComments, + onCommentCreated: (c) => created.push(c.id), + onCommentUpdated: (c) => updated.push(c.id), + onCommentDeleted: (c) => deleted.push(c.id), + }); + server = http.createServer(app); + await new Promise((resolve) => server!.listen(0, resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('server did not bind to a TCP port'); + const base = `http://127.0.0.1:${address.port}`; + + async function json( + route: string, + options: { method?: string; body?: unknown; member?: string } = {}, + ) { + const init: RequestInit = { method: options.method ?? 'GET', headers: {} }; + const headers: Record = {}; + if (options.body !== undefined) { + headers['content-type'] = 'application/json'; + init.body = JSON.stringify(options.body); + } + if (options.member) Object.assign(headers, asMember(options.member)); + init.headers = headers; + const response = await fetch(`${base}${route}`, init); + const text = await response.text(); + return { status: response.status, body: text ? (JSON.parse(text) as any) : {} }; + } + + const commentTarget = { + filePath: 'index.html', + elementId: 'hero', + selector: '[data-od-id="hero"]', + label: 'h1.hero', + text: 'Hero', + htmlHint: '

', + position: { x: 0, y: 0, width: 0, height: 0 }, + }; + + async function createComment(member: string, note = 'a note') { + const res = await json(`/api/projects/${PROJECT}/conversations/${CONVERSATION}/comments`, { + method: 'POST', + member, + body: { target: commentTarget, note }, + }); + return res.body.comment as { + id: string; + authorMemberId?: string; + note: string; + pinSeq?: number; + sortKey?: number; + }; + } + + const listComments = () => + listPreviewComments(db, PROJECT, CONVERSATION) as Array<{ + id: string; + authorMemberId?: string; + note: string; + }>; + + return { + db, + json, + createComment, + listComments, + created, + updated, + deleted, + commentTarget, + setSyncComments(value: boolean) { + syncComments = value; + }, + }; +} + +describe('preview comment permission gating', () => { + it('legacy comments in a shared project are owner-only', async () => { + const api = await startServer(); + const legacy = upsertPreviewComment( + api.db, + PROJECT, + CONVERSATION, + { target: api.commentTarget, note: 'legacy note' }, + ); + expect(legacy?.authorMemberId).toBeUndefined(); + + const memberDelete = await api.json( + `/api/projects/${PROJECT}/conversations/${CONVERSATION}/comments/${legacy!.id}`, + { method: 'DELETE', member: 'm-member' }, + ); + expect(memberDelete.status).toBe(403); + expect(api.listComments()).toHaveLength(1); + + const ownerDelete = await api.json( + `/api/projects/${PROJECT}/conversations/${CONVERSATION}/comments/${legacy!.id}`, + { method: 'DELETE', member: OWNER }, + ); + expect(ownerDelete.status).toBe(200); + expect(api.listComments()).toHaveLength(0); + }); + + it('legacy comments in an unshared project keep single-user mutation behavior', async () => { + const api = await startServer({ shared: false }); + const legacy = upsertPreviewComment( + api.db, + PROJECT, + CONVERSATION, + { target: api.commentTarget, note: 'personal legacy note' }, + ); + + const memberDelete = await api.json( + `/api/projects/${PROJECT}/conversations/${CONVERSATION}/comments/${legacy!.id}`, + { method: 'DELETE', member: 'm-member' }, + ); + expect(memberDelete.status).toBe(200); + expect(api.listComments()).toHaveLength(0); + }); + + it('a non-author non-owner member cannot change status or delete', async () => { + const api = await startServer(); + const comment = await api.createComment('m-author'); + expect(comment.authorMemberId).toBe('m-author'); + + const patch = await api.json( + `/api/projects/${PROJECT}/conversations/${CONVERSATION}/comments/${comment.id}`, + { method: 'PATCH', member: 'm-stranger', body: { status: 'applying' } }, + ); + expect(patch.status).toBe(403); + + const del = await api.json( + `/api/projects/${PROJECT}/conversations/${CONVERSATION}/comments/${comment.id}`, + { method: 'DELETE', member: 'm-stranger' }, + ); + expect(del.status).toBe(403); + + // Nothing changed / propagated. + expect(api.listComments()).toHaveLength(1); + expect(api.updated).toEqual([]); + expect(api.deleted).toEqual([]); + }); + + it('an authored shared comment cannot be changed or deleted without caller identity', async () => { + const api = await startServer(); + const comment = await api.createComment('m-author'); + + const patch = await api.json( + `/api/projects/${PROJECT}/conversations/${CONVERSATION}/comments/${comment.id}`, + { method: 'PATCH', body: { status: 'applying' } }, + ); + expect(patch.status).toBe(403); + + const del = await api.json( + `/api/projects/${PROJECT}/conversations/${CONVERSATION}/comments/${comment.id}`, + { method: 'DELETE' }, + ); + expect(del.status).toBe(403); + + expect(api.listComments()).toHaveLength(1); + expect(api.updated).toEqual([]); + expect(api.deleted).toEqual([]); + }); + + it('the author can change status on their own comment', async () => { + const api = await startServer(); + const comment = await api.createComment('m-author'); + const patch = await api.json( + `/api/projects/${PROJECT}/conversations/${CONVERSATION}/comments/${comment.id}`, + { method: 'PATCH', member: 'm-author', body: { status: 'applying' } }, + ); + expect(patch.status).toBe(200); + expect(patch.body.comment.status).toBe('applying'); + // The status change propagated to the relay seam. + expect(api.updated).toEqual([comment.id]); + }); + + it('the project owner can change status on and delete another member\'s comment', async () => { + const api = await startServer(); + const comment = await api.createComment('m-author'); + + const patch = await api.json( + `/api/projects/${PROJECT}/conversations/${CONVERSATION}/comments/${comment.id}`, + { method: 'PATCH', member: OWNER, body: { status: 'needs_review' } }, + ); + expect(patch.status).toBe(200); + + const del = await api.json( + `/api/projects/${PROJECT}/conversations/${CONVERSATION}/comments/${comment.id}`, + { method: 'DELETE', member: OWNER }, + ); + expect(del.status).toBe(200); + expect(api.deleted).toEqual([comment.id]); + expect(api.listComments()).toHaveLength(0); + }); + + it('POST is author-scoped: a second member commenting on the same element makes their own row', async () => { + const api = await startServer(); + const first = await api.createComment('m-author', 'author note'); + const second = await api.createComment('m-other', 'other note'); + + // Distinct rows — the second member did not overwrite the author's comment. + expect(second.id).not.toBe(first.id); + const rows = api.listComments(); + expect(rows).toHaveLength(2); + expect(rows.find((c) => c.authorMemberId === 'm-author')?.note).toBe('author note'); + expect(rows.find((c) => c.authorMemberId === 'm-other')?.note).toBe('other note'); + }); + + it('POST creates another row for the same author unless an existing id is sent', async () => { + const api = await startServer(); + const first = await api.createComment('m-author', 'first note'); + const second = await api.createComment('m-author', 'second note'); + + expect(second.id).not.toBe(first.id); + expect(api.listComments()).toHaveLength(2); + + const edit = await api.json( + `/api/projects/${PROJECT}/conversations/${CONVERSATION}/comments`, + { + method: 'POST', + member: 'm-author', + body: { id: first.id, target: api.commentTarget, note: 'edited first' }, + }, + ); + + expect(edit.status).toBe(200); + expect(edit.body.comment.id).toBe(first.id); + expect(api.listComments()).toHaveLength(2); + expect(api.listComments().find((c) => c.id === first.id)?.note).toBe('edited first'); + expect(api.listComments().find((c) => c.id === second.id)?.note).toBe('second note'); + }); + + it('POST cannot edit another member comment by passing its id', async () => { + const api = await startServer(); + const first = await api.createComment('m-author', 'author note'); + const edit = await api.json( + `/api/projects/${PROJECT}/conversations/${CONVERSATION}/comments`, + { + method: 'POST', + member: 'm-other', + body: { id: first.id, target: api.commentTarget, note: 'stolen edit' }, + }, + ); + + expect(edit.status).toBe(403); + expect(api.listComments()).toHaveLength(1); + expect(api.listComments()[0]?.note).toBe('author note'); + }); + + it('POST cannot edit an authored shared comment when caller identity is missing', async () => { + const api = await startServer(); + const first = await api.createComment('m-author', 'author note'); + const edit = await api.json( + `/api/projects/${PROJECT}/conversations/${CONVERSATION}/comments`, + { + method: 'POST', + body: { id: first.id, target: api.commentTarget, note: 'anonymous edit' }, + }, + ); + + expect(edit.status).toBe(403); + expect(api.listComments()).toHaveLength(1); + expect(api.listComments()[0]?.note).toBe('author note'); + }); + + it('POST with an unknown id is treated as a missing edit target', async () => { + const api = await startServer(); + const edit = await api.json( + `/api/projects/${PROJECT}/conversations/${CONVERSATION}/comments`, + { + method: 'POST', + member: 'm-author', + body: { id: 'missing-comment', target: api.commentTarget, note: 'edit nothing' }, + }, + ); + + expect(edit.status).toBe(404); + expect(api.listComments()).toHaveLength(0); + }); + + it('does not push local comment mutations when the project is no longer team-shared', async () => { + const api = await startServer(); + api.setSyncComments(false); + + const comment = await api.createComment('m-author', 'local after unshare'); + expect(comment.note).toBe('local after unshare'); + expect(api.created).toEqual([]); + + const patch = await api.json( + `/api/projects/${PROJECT}/conversations/${CONVERSATION}/comments/${comment.id}`, + { method: 'PATCH', member: 'm-author', body: { status: 'applying' } }, + ); + expect(patch.status).toBe(200); + expect(api.updated).toEqual([]); + + const del = await api.json( + `/api/projects/${PROJECT}/conversations/${CONVERSATION}/comments/${comment.id}`, + { method: 'DELETE', member: 'm-author' }, + ); + expect(del.status).toBe(200); + expect(api.deleted).toEqual([]); + expect(api.listComments()).toHaveLength(0); + }); + + // —— reorder (sidebar sort_key) — recvq5BVsolIxi Phase 2 ———————————————————— + + it('reorder is a personal display preference: any member may reorder, not just the author', async () => { + const api = await startServer(); + const comment = await api.createComment('m-author'); + + const patch = await api.json( + `/api/projects/${PROJECT}/conversations/${CONVERSATION}/comments/${comment.id}/reorder`, + { method: 'PATCH', member: 'm-stranger', body: { sortKey: 42 } }, + ); + expect(patch.status).toBe(200); + expect(patch.body.comment.sortKey).toBe(42); + // Unlike status change/delete, reordering is not pushed to the relay and + // does not require caller identity at all. + const anon = await api.json( + `/api/projects/${PROJECT}/conversations/${CONVERSATION}/comments/${comment.id}/reorder`, + { method: 'PATCH', body: { sortKey: 43 } }, + ); + expect(anon.status).toBe(200); + expect(api.updated).toEqual([]); + expect(api.created).toEqual([comment.id]); + }); + + it('reorder writes only sort_key — pin_seq stays exactly what creation assigned', async () => { + const api = await startServer(); + const first = await api.createComment('m-author', 'first'); + const second = await api.createComment('m-other', 'second'); + expect(first.pinSeq).toBe(1); + expect(second.pinSeq).toBe(2); + + const patch = await api.json( + `/api/projects/${PROJECT}/conversations/${CONVERSATION}/comments/${first.id}/reorder`, + { method: 'PATCH', member: 'm-author', body: { sortKey: 99 } }, + ); + expect(patch.status).toBe(200); + expect(patch.body.comment.sortKey).toBe(99); + expect(patch.body.comment.pinSeq).toBe(1); + // The untouched sibling is unaffected. + const rows = api.listComments() as unknown as Array<{ id: string; pinSeq?: number; sortKey?: number }>; + expect(rows.find((c) => c.id === second.id)?.pinSeq).toBe(2); + }); + + it('reorder rejects a non-finite sortKey and 404s for an unknown comment', async () => { + const api = await startServer(); + const comment = await api.createComment('m-author'); + + const bad = await api.json( + `/api/projects/${PROJECT}/conversations/${CONVERSATION}/comments/${comment.id}/reorder`, + { method: 'PATCH', member: 'm-author', body: { sortKey: 'not-a-number' } }, + ); + expect(bad.status).toBe(400); + + const missing = await api.json( + `/api/projects/${PROJECT}/conversations/${CONVERSATION}/comments/missing-comment/reorder`, + { method: 'PATCH', member: 'm-author', body: { sortKey: 1 } }, + ); + expect(missing.status).toBe(404); + }); +}); diff --git a/apps/daemon/tests/project-comment-workspace-gate.test.ts b/apps/daemon/tests/project-comment-workspace-gate.test.ts new file mode 100644 index 00000000000..a01e67d6e4e --- /dev/null +++ b/apps/daemon/tests/project-comment-workspace-gate.test.ts @@ -0,0 +1,758 @@ +// spec 04 §10 fix #4/#6 (recvqbklNGDqYY): before this fix, +// `apps/daemon/src/routes/project/comments.ts` had ZERO `enforceWorkspace*` +// coverage — not "only blocks team, lets personal through" like the other +// three resource types' mutation gate, but literally no gate at all. A +// caller with no workspace identity whatsoever (signed out, a plain `curl`) +// could POST/PATCH/DELETE comments on ANY project, including one bound to a +// team workspace it has never had any relationship with. +// +// This file wires `registerProjectCommentRoutes`'s new +// `enforceWorkspaceProjectMutation`/`sendApiError` deps to the REAL +// `enforceWorkspaceResourceMutation('project', …)` gate (the same one +// `routes/project/index.ts` builds for its own project routes), against a +// real project bound into `workspace_projects` — not a stub — so this is +// exercising the actual production wiring path end to end at the HTTP layer, +// not just the shared gate function in isolation (that is +// `tests/collab/workspace-resource-mutation.test.ts`'s job). + +import http from 'node:http'; +import express from 'express'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + buildWorkspacePermissions, + buildWorkspaceSeatSummary, + type WorkspaceCollabContext, +} from '@open-design/contracts'; + +import { + closeDatabase, + deletePreviewComment, + ensureWorkspaceProject, + getConversation, + getPreviewComment, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + insertConversation, + insertProject, + listPreviewComments, + openDatabase, + reorderPreviewComment, + updatePreviewCommentAnchor, + updatePreviewCommentStatus, + updateProject, + upsertPreviewComment, +} from '../src/db.js'; +import { enforceWorkspaceResourceMutation } from '../src/collab/workspace-resource-mutation.js'; +import { verifyWorkspaceRequestContext } from '../src/collab/request-workspace-context.js'; +import { createCachedWorkspaceDirectoryFetcher } from '../src/collab/vela-workspace-context.js'; +import { registerProjectCommentRoutes } from '../src/routes/project/comments.js'; + +let server: http.Server | null = null; +let tempDir: string | null = null; +let database: ReturnType | null = null; + +afterEach(async () => { + if (server) { + const toClose = server; + server = null; + await new Promise((resolve) => toClose.close(() => resolve())); + } + closeDatabase(); + database = null; + if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; +}); + +const TEAM_PROJECT = 'p-team'; +const TEAM_MIRROR_PROJECT = 'p-team-mirror'; +const PERSONAL_PROJECT = 'p-personal'; +const UNBOUND_PROJECT = 'p-unbound'; +const WORKSPACE_ID = 'ws-comment-gate'; +const OWNER_MEMBER_ID = 'member-owner'; +const OTHER_MEMBER_ID = 'member-other'; + +function sendApiError(res: any, status: number, code: string, message: string) { + return res.status(status).json({ error: { code, message } }); +} + +function workspaceHeaders( + memberId: string, + role: 'owner' | 'admin' | 'member', + canWriteSyncedFiles = true, +) { + return { + 'x-od-workspace-id': WORKSPACE_ID, + 'x-od-workspace-member-id': memberId, + 'x-od-workspace-role': role, + 'x-od-workspace-can-write-synced-files': String(canWriteSyncedFiles), + }; +} + +function activeTeamContext( + memberId = OTHER_MEMBER_ID, + role: 'owner' | 'admin' | 'member' = 'member', +): WorkspaceCollabContext { + return { + workspaceId: WORKSPACE_ID, + workspaceType: 'team', + workspaceMemberId: memberId, + role, + memberStatus: 'active', + lifecycleState: 'active', + billingState: 'active', + planId: null, + providerMode: 'platform_credits', + seatSummary: buildWorkspaceSeatSummary({ seatLimit: 5, usedSeats: 2 }), + permissions: buildWorkspacePermissions({ + role, + lifecycleState: 'active', + }), + teamId: WORKSPACE_ID, + }; +} + +const COMMENT_TARGET = { + filePath: 'index.html', + elementId: 'hero', + selector: '[data-od-id="hero"]', + label: 'h1.hero', + text: 'Hero', + htmlHint: '

', + position: { x: 0, y: 0, width: 0, height: 0 }, +}; + +async function startServer( + routeOverrides: Record = {}, +) { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-comment-ws-gate-')); + const db = openDatabase(tempDir); + database = db; + const now = Date.now(); + for (const [id, conv] of [ + [TEAM_PROJECT, 'conv-team'], + [TEAM_MIRROR_PROJECT, 'conv-team-mirror'], + [PERSONAL_PROJECT, 'conv-personal'], + [UNBOUND_PROJECT, 'conv-unbound'], + ] as const) { + insertProject(db, { id, name: id, createdAt: now, updatedAt: now }); + insertConversation(db, { id: conv, projectId: id, title: 'Chat', createdAt: now, updatedAt: now }); + } + ensureWorkspaceProject(db, { + projectId: TEAM_PROJECT, + workspaceId: WORKSPACE_ID, + visibility: 'team', + createdByWorkspaceMemberId: OWNER_MEMBER_ID, + }); + // The exact row shape `materializePulledTeamMirror` writes on a MEMBER's own + // daemon for someone else's shared project: bound + team visibility, but + // UNATTRIBUTED (`createdByWorkspaceMemberId: null` — the adoption red line + // means lazy projection never assigns the reader as creator). This is the + // row the real member-comment flow gates against. + ensureWorkspaceProject(db, { + projectId: TEAM_MIRROR_PROJECT, + workspaceId: WORKSPACE_ID, + visibility: 'team', + createdByWorkspaceMemberId: null, + }); + ensureWorkspaceProject(db, { + projectId: PERSONAL_PROJECT, + workspaceId: WORKSPACE_ID, + visibility: 'personal', + createdByWorkspaceMemberId: OWNER_MEMBER_ID, + }); + // UNBOUND_PROJECT deliberately gets no `workspace_projects` row — the + // "legacy / never claimed" control case the gate must leave alone. + + const app = express(); + app.use(express.json()); + registerProjectCommentRoutes(app, { + db, + projectStore: { updateProject, getWorkspaceProject, getWorkspaceProjectByProjectId } as any, + conversations: { + getConversation, + listPreviewComments, + upsertPreviewComment, + getPreviewComment, + updatePreviewCommentStatus, + updatePreviewCommentAnchor, + deletePreviewComment, + reorderPreviewComment, + } as any, + sendApiError, + enforceWorkspaceProjectMutation: async (req, res, sendError, getWp, getWpByProjectId, dbArg, projectId, capability) => + enforceWorkspaceResourceMutation( + 'project', + req, + res, + sendError, + getWp, + getWpByProjectId, + dbArg, + projectId, + capability, + ), + resolveAuthorMemberId: async () => undefined, + ...routeOverrides, + }); + const created = http.createServer(app); + server = created; + await new Promise((resolve) => created.listen(0, resolve)); + const address = created.address(); + const port = typeof address === 'object' && address ? address.port : 0; + return `http://127.0.0.1:${port}`; +} + +describe('project comments — workspace mutation gate', () => { + it('leases directory authority only for GET while mutations stay fresh and revocation fails closed', async () => { + let clock = 0; + let directoryItems = [{ + workspaceId: WORKSPACE_ID, + workspaceName: 'Team', + workspaceType: 'team' as const, + workspaceMemberId: OTHER_MEMBER_ID, + role: 'member' as const, + memberStatus: 'active' as const, + lifecycleState: 'active' as const, + }]; + const fetchReadDirectory = vi.fn(async () => ({ + ok: true as const, + items: directoryItems, + })); + const cachedReadDirectory = createCachedWorkspaceDirectoryFetcher({ + fetchDirectory: fetchReadDirectory, + identityKey: () => 'member-comment-read', + ttlMs: 5_000, + now: () => clock, + }); + const fetchFreshMutationDirectory = vi.fn(async () => ({ + ok: true as const, + items: directoryItems, + })); + const baseUrl = await startServer({ + resolveReadWorkspaceContext: (req: unknown) => + verifyWorkspaceRequestContext({ + req, + fetchWorkspaceDirectory: cachedReadDirectory, + }), + resolveWorkspaceContext: (req: unknown) => + verifyWorkspaceRequestContext({ + req, + fetchWorkspaceDirectory: fetchFreshMutationDirectory, + }), + }); + const commentsUrl = + `${baseUrl}/api/projects/${TEAM_MIRROR_PROJECT}/conversations/conv-team-mirror/comments`; + const headers = workspaceHeaders(OTHER_MEMBER_ID, 'member'); + + expect((await fetch(commentsUrl, { headers })).status).toBe(200); + expect((await fetch(commentsUrl, { headers })).status).toBe(200); + expect(fetchReadDirectory).toHaveBeenCalledTimes(1); + expect(fetchFreshMutationDirectory).not.toHaveBeenCalled(); + + const create = await fetch(commentsUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify({ target: COMMENT_TARGET, note: 'fresh mutation' }), + }); + expect(create.status).toBe(200); + expect(fetchFreshMutationDirectory).toHaveBeenCalledTimes(1); + expect(fetchReadDirectory).toHaveBeenCalledTimes(1); + + directoryItems = []; + clock = 5_001; + expect((await fetch(commentsUrl, { headers })).status).toBe(403); + expect(fetchReadDirectory).toHaveBeenCalledTimes(2); + + const deniedMutation = await fetch(commentsUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify({ target: COMMENT_TARGET, note: 'must stay denied' }), + }); + expect(deniedMutation.status).toBe(403); + expect(fetchFreshMutationDirectory).toHaveBeenCalledTimes(2); + expect(fetchReadDirectory).toHaveBeenCalledTimes(2); + }); + + it.each([ + ['revocation', 'revoked'], + ['authority outage', 'outage'], + ] as const)( + 'keeps the warm GET lease but does not redeem a dirty pull during %s', + async (_label, deniedMode) => { + let readClock = 0; + let freshMode: 'active' | 'revoked' | 'outage' = 'active'; + const activeDirectory = [{ + workspaceId: WORKSPACE_ID, + workspaceName: 'Team', + workspaceType: 'team' as const, + workspaceMemberId: OTHER_MEMBER_ID, + role: 'member' as const, + memberStatus: 'active' as const, + lifecycleState: 'active' as const, + }]; + const cachedReadDirectory = createCachedWorkspaceDirectoryFetcher({ + fetchDirectory: async () => ({ + ok: true as const, + items: activeDirectory, + }), + identityKey: () => 'member-comment-dirty-read', + ttlMs: 5_000, + now: () => readClock, + }); + const pullProject = vi.fn( + async ( + _projectId: string, + _context: WorkspaceCollabContext, + ) => true, + ); + let dirty = false; + let redemption = Promise.resolve(); + const baseUrl = await startServer({ + resolveReadWorkspaceContext: (req: unknown) => + verifyWorkspaceRequestContext({ + req, + fetchWorkspaceDirectory: cachedReadDirectory, + }), + resolveWorkspaceContext: (req: unknown) => + verifyWorkspaceRequestContext({ + req, + fetchWorkspaceDirectory: async () => { + if (freshMode === 'outage') { + return { ok: false as const, items: [] }; + } + return { + ok: true as const, + items: freshMode === 'active' ? activeDirectory : [], + }; + }, + }), + onCommentsRead: ( + projectId: string, + leasedContext: WorkspaceCollabContext | null, + resolveFreshContext: () => Promise< + | { ok: true; context: WorkspaceCollabContext | null } + | { ok: false } + >, + ) => { + if (!dirty) return; + dirty = false; + redemption = (async () => { + const fresh = await resolveFreshContext(); + if ( + !fresh.ok + || !fresh.context + || !leasedContext + || fresh.context.workspaceId !== leasedContext.workspaceId + || fresh.context.workspaceMemberId + !== leasedContext.workspaceMemberId + ) { + dirty = true; + return; + } + if (!await pullProject(projectId, fresh.context)) dirty = true; + })(); + }, + }); + const commentsUrl = + `${baseUrl}/api/projects/${TEAM_MIRROR_PROJECT}/conversations/conv-team-mirror/comments`; + const headers = workspaceHeaders(OTHER_MEMBER_ID, 'member'); + + // Warm the successful read lease while authority is active. + expect((await fetch(commentsUrl, { headers })).status).toBe(200); + + // The list read still succeeds from that bounded lease, but the dirty + // cloud pull/local merge must independently prove fresh authority. + freshMode = deniedMode; + dirty = true; + expect((await fetch(commentsUrl, { headers })).status).toBe(200); + await redemption; + expect(pullProject).not.toHaveBeenCalled(); + expect(dirty).toBe(true); + + // The unredeemed mark survives the denial/outage and is consumed exactly + // once after fresh authority recovers. The read lease never expired. + freshMode = 'active'; + expect((await fetch(commentsUrl, { headers })).status).toBe(200); + await redemption; + expect(pullProject).toHaveBeenCalledTimes(1); + expect(pullProject).toHaveBeenCalledWith( + TEAM_MIRROR_PROJECT, + expect.objectContaining({ + workspaceId: WORKSPACE_ID, + workspaceMemberId: OTHER_MEMBER_ID, + }), + ); + expect(dirty).toBe(false); + + readClock = 1; + expect((await fetch(commentsUrl, { headers })).status).toBe(200); + await redemption; + expect(pullProject).toHaveBeenCalledTimes(1); + }, + ); + + it('uses the verified project A scope after ambient identity moved to B', async () => { + const projectContext = activeTeamContext(); + const pushedScopes: Array<{ workspaceId: string; workspaceMemberId: string }> = []; + const baseUrl = await startServer({ + // Models the stale daemon-global answer after another tab moved to B. + resolveAuthorMemberId: async () => 'member-b', + resolveWorkspaceContext: async () => ({ ok: true, context: projectContext }), + shouldSyncProjectComments: async () => true, + onCommentCreated: ( + _comment: unknown, + scope: WorkspaceCollabContext | null, + ) => { + if (scope) { + pushedScopes.push({ + workspaceId: scope.workspaceId, + workspaceMemberId: scope.workspaceMemberId, + }); + } + }, + }); + const response = await fetch( + `${baseUrl}/api/projects/${TEAM_MIRROR_PROJECT}/conversations/conv-team-mirror/comments`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...workspaceHeaders(OTHER_MEMBER_ID, 'member'), + }, + body: JSON.stringify({ target: COMMENT_TARGET, note: 'scoped to A' }), + }, + ); + + expect(response.status).toBe(200); + const { comment } = (await response.json()) as { + comment: { authorMemberId?: string }; + }; + expect(comment.authorMemberId).toBe(OTHER_MEMBER_ID); + expect(pushedScopes).toEqual([ + { + workspaceId: WORKSPACE_ID, + workspaceMemberId: OTHER_MEMBER_ID, + }, + ]); + }); + + it('fails closed before saving or relaying when project scope authority is unavailable', async () => { + let relayed = 0; + const baseUrl = await startServer({ + resolveWorkspaceContext: async () => ({ + ok: false, + status: 503, + code: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + message: 'workspace membership authority is temporarily unavailable', + retryable: true, + }), + onCommentCreated: () => { + relayed += 1; + }, + }); + const response = await fetch( + `${baseUrl}/api/projects/${TEAM_PROJECT}/conversations/conv-team/comments`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...workspaceHeaders(OWNER_MEMBER_ID, 'owner'), + }, + body: JSON.stringify({ target: COMMENT_TARGET, note: 'must not persist' }), + }, + ); + + expect(response.status).toBe(503); + expect(relayed).toBe(0); + expect(database).not.toBeNull(); + expect(listPreviewComments(database!, TEAM_PROJECT, 'conv-team')).toEqual([]); + }); + + it('fails closed before derived anchor or reorder writes when scope authority is unavailable', async () => { + const baseUrl = await startServer({ + resolveWorkspaceContext: async () => ({ + ok: false, + status: 503, + code: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + message: 'workspace membership authority is temporarily unavailable', + retryable: true, + }), + }); + expect(database).not.toBeNull(); + const seeded = upsertPreviewComment( + database!, + TEAM_PROJECT, + 'conv-team', + { + id: 'comment-derived-write', + target: COMMENT_TARGET, + note: 'keep the original derived state', + }, + ); + expect(seeded).not.toBeNull(); + if (!seeded) throw new Error('expected the comment fixture to be created'); + const before = getPreviewComment( + database!, + TEAM_PROJECT, + 'conv-team', + seeded.id, + ); + + const anchor = await fetch( + `${baseUrl}/api/projects/${TEAM_PROJECT}/conversations/conv-team/comments/${seeded.id}/anchor`, + { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + ...workspaceHeaders(OWNER_MEMBER_ID, 'owner'), + }, + body: JSON.stringify({ + selector: '[data-od-id="different"]', + position: { x: 99, y: 99, width: 10, height: 10 }, + }), + }, + ); + const reorder = await fetch( + `${baseUrl}/api/projects/${TEAM_PROJECT}/conversations/conv-team/comments/${seeded.id}/reorder`, + { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + ...workspaceHeaders(OWNER_MEMBER_ID, 'owner'), + }, + body: JSON.stringify({ sortKey: 999 }), + }, + ); + + expect(anchor.status).toBe(503); + expect(reorder.status).toBe(503); + expect( + getPreviewComment( + database!, + TEAM_PROJECT, + 'conv-team', + seeded.id, + ), + ).toEqual(before); + }); + + it('rejects a headerless POST (new comment) against a team-bound project', async () => { + const baseUrl = await startServer(); + const resp = await fetch(`${baseUrl}/api/projects/${TEAM_PROJECT}/conversations/conv-team/comments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target: COMMENT_TARGET, note: 'hi' }), + }); + expect(resp.status).toBe(401); + }); + + it('rejects a headerless PATCH (status transition) against a team-bound project', async () => { + const baseUrl = await startServer(); + // Seed a comment with a member header first (allowed — owner), then + // retry the status PATCH with no headers at all. + const create = await fetch(`${baseUrl}/api/projects/${TEAM_PROJECT}/conversations/conv-team/comments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...workspaceHeaders(OWNER_MEMBER_ID, 'owner') }, + body: JSON.stringify({ target: COMMENT_TARGET, note: 'hi' }), + }); + expect(create.status).toBe(200); + const { comment } = (await create.json()) as { comment: { id: string } }; + + const resp = await fetch( + `${baseUrl}/api/projects/${TEAM_PROJECT}/conversations/conv-team/comments/${comment.id}`, + { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'resolved' }) }, + ); + expect(resp.status).toBe(401); + }); + + it('rejects a headerless DELETE against a team-bound project', async () => { + const baseUrl = await startServer(); + const create = await fetch(`${baseUrl}/api/projects/${TEAM_PROJECT}/conversations/conv-team/comments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...workspaceHeaders(OWNER_MEMBER_ID, 'owner') }, + body: JSON.stringify({ target: COMMENT_TARGET, note: 'hi' }), + }); + const { comment } = (await create.json()) as { comment: { id: string } }; + + const resp = await fetch( + `${baseUrl}/api/projects/${TEAM_PROJECT}/conversations/conv-team/comments/${comment.id}`, + { method: 'DELETE' }, + ); + expect(resp.status).toBe(401); + }); + + // The exact recvqbklNGDqYY-shaped regression: BEFORE this fix, the shared + // gate's null-ctx branch only refused `visibility: 'team'`, so a headerless + // caller could still write to a `personal`-but-CLAIMED project's comments. + it('rejects a headerless POST against a personal-visibility (but bound) project too', async () => { + const baseUrl = await startServer(); + const resp = await fetch(`${baseUrl}/api/projects/${PERSONAL_PROJECT}/conversations/conv-personal/comments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target: COMMENT_TARGET, note: 'hi' }), + }); + expect(resp.status).toBe(401); + }); + + it('still allows a headerless POST/PATCH/DELETE against a never-claimed (legacy) project', async () => { + const baseUrl = await startServer(); + const create = await fetch(`${baseUrl}/api/projects/${UNBOUND_PROJECT}/conversations/conv-unbound/comments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ target: COMMENT_TARGET, note: 'hi' }), + }); + expect(create.status).toBe(200); + const { comment } = (await create.json()) as { comment: { id: string } }; + + const patch = await fetch( + `${baseUrl}/api/projects/${UNBOUND_PROJECT}/conversations/conv-unbound/comments/${comment.id}`, + { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'resolved' }) }, + ); + expect(patch.status).toBe(200); + + const del = await fetch( + `${baseUrl}/api/projects/${UNBOUND_PROJECT}/conversations/conv-unbound/comments/${comment.id}`, + { method: 'DELETE' }, + ); + expect(del.status).toBe(200); + }); + + // The member-comment regression (2026-07-27 dogfood, beta 0.15.2-beta.137): + // the comment gate borrowed the project's `writeFiles` capability, whose + // `canMutate` requires `privileged || selfCreated` — so a PLAIN member could + // never comment on someone else's team-shared project even though the + // product's read-only banner explicitly promises "view and comment". A + // comment is not a file write; it needs its own capability that any active + // member of the sharing workspace passes. + it('allows a plain team member (not the creator) to comment on a team-shared project', async () => { + const baseUrl = await startServer(); + const resp = await fetch(`${baseUrl}/api/projects/${TEAM_PROJECT}/conversations/conv-team/comments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...workspaceHeaders(OTHER_MEMBER_ID, 'member') }, + body: JSON.stringify({ target: COMMENT_TARGET, note: '这啥?' }), + }); + expect(resp.status).toBe(200); + const { comment } = (await resp.json()) as { comment: { id: string } }; + expect(comment.id).toBeTruthy(); + }); + + it.each([ + ['member', OTHER_MEMBER_ID], + ['admin', 'member-admin'], + ] as const)( + 'allows an active %s to comment when synced-file writes are disabled', + async (role, memberId) => { + const baseUrl = await startServer(); + const resp = await fetch(`${baseUrl}/api/projects/${TEAM_PROJECT}/conversations/conv-team/comments`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...workspaceHeaders(memberId, role, false), + }, + body: JSON.stringify({ target: COMMENT_TARGET, note: `${role} comment` }), + }); + expect(resp.status).toBe(200); + }, + ); + + // Same as above but against the exact row shape the member's own daemon + // holds after `POST /api/projects/:id/collab/pull` — the unattributed + // (`createdByWorkspaceMemberId: null`) team mirror. This is the literal + // production shape of the dogfood failure (403 + // WORKSPACE_PROJECT_PERMISSION_DENIED → “评论保存失败,请重试。”). + it('allows a member to comment on a pulled team mirror (unattributed binding)', async () => { + const baseUrl = await startServer(); + const resp = await fetch( + `${baseUrl}/api/projects/${TEAM_MIRROR_PROJECT}/conversations/conv-team-mirror/comments`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...workspaceHeaders(OTHER_MEMBER_ID, 'member') }, + body: JSON.stringify({ target: COMMENT_TARGET, note: '这啥?' }), + }, + ); + expect(resp.status).toBe(200); + }); + + // Comment-capability follow-through: the same borrowed gate also fronts + // status change and delete, so a member must reach the per-comment author + // rules (`callerMayMutate`) instead of being 403'd at the workspace layer. + // (Author-based restrictions themselves are pinned by + // project-comment-permissions.test.ts; this fixture stores unauthored + // comments, which degrade open by design.) + it('lets a member reach the author rules for status/delete on a team-shared project', async () => { + const baseUrl = await startServer(); + const create = await fetch( + `${baseUrl}/api/projects/${TEAM_MIRROR_PROJECT}/conversations/conv-team-mirror/comments`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...workspaceHeaders(OTHER_MEMBER_ID, 'member') }, + body: JSON.stringify({ target: COMMENT_TARGET, note: 'mine' }), + }, + ); + expect(create.status).toBe(200); + const { comment } = (await create.json()) as { comment: { id: string } }; + + const patch = await fetch( + `${baseUrl}/api/projects/${TEAM_MIRROR_PROJECT}/conversations/conv-team-mirror/comments/${comment.id}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...workspaceHeaders(OTHER_MEMBER_ID, 'member') }, + body: JSON.stringify({ status: 'resolved' }), + }, + ); + expect(patch.status).toBe(200); + + const del = await fetch( + `${baseUrl}/api/projects/${TEAM_MIRROR_PROJECT}/conversations/conv-team-mirror/comments/${comment.id}`, + { method: 'DELETE', headers: workspaceHeaders(OTHER_MEMBER_ID, 'member') }, + ); + expect(del.status).toBe(200); + }); + + // Guard against overshoot: widening the comment gate must not open comments + // on a personal-visibility (unshared, merely claimed) project to other + // members — only the sharing act (`visibility: 'team'`) grants comment + // standing beyond creator/privileged. + it('still rejects a non-creator member commenting on a personal-visibility project', async () => { + const baseUrl = await startServer(); + const resp = await fetch( + `${baseUrl}/api/projects/${PERSONAL_PROJECT}/conversations/conv-personal/comments`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...workspaceHeaders(OTHER_MEMBER_ID, 'member') }, + body: JSON.stringify({ target: COMMENT_TARGET, note: 'hi' }), + }, + ); + expect(resp.status).toBe(403); + }); + + it('allows a properly-authenticated team member to POST/PATCH/DELETE against the team-bound project', async () => { + const baseUrl = await startServer(); + const create = await fetch(`${baseUrl}/api/projects/${TEAM_PROJECT}/conversations/conv-team/comments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...workspaceHeaders(OWNER_MEMBER_ID, 'owner') }, + body: JSON.stringify({ target: COMMENT_TARGET, note: 'hi' }), + }); + expect(create.status).toBe(200); + const { comment } = (await create.json()) as { comment: { id: string } }; + + const patch = await fetch( + `${baseUrl}/api/projects/${TEAM_PROJECT}/conversations/conv-team/comments/${comment.id}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...workspaceHeaders(OWNER_MEMBER_ID, 'owner') }, + body: JSON.stringify({ status: 'resolved' }), + }, + ); + expect(patch.status).toBe(200); + + const del = await fetch( + `${baseUrl}/api/projects/${TEAM_PROJECT}/conversations/conv-team/comments/${comment.id}`, + { method: 'DELETE', headers: workspaceHeaders(OWNER_MEMBER_ID, 'owner') }, + ); + expect(del.status).toBe(200); + }); +}); diff --git a/apps/daemon/tests/project-consumer-cli-workspace-scope.test.ts b/apps/daemon/tests/project-consumer-cli-workspace-scope.test.ts new file mode 100644 index 00000000000..b8e9e969730 --- /dev/null +++ b/apps/daemon/tests/project-consumer-cli-workspace-scope.test.ts @@ -0,0 +1,404 @@ +import { execFile } from 'node:child_process'; +import http from 'node:http'; +import { mkdtemp, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const execFileP = promisify(execFile); +const daemonRoot = fileURLToPath(new URL('..', import.meta.url)); +const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)); +const cliEntry = fileURLToPath(new URL('../src/cli.ts', import.meta.url)); +const tsxCli = path.join(repoRoot, 'node_modules/tsx/dist/cli.mjs'); + +const TEAM_WORKSPACE_ID = 'team-workspace'; +const CREATOR_MEMBER_ID = 'creator-member'; +const OTHER_MEMBER_ID = 'other-member'; + +type RequestRecord = { + method: string; + url: string; + headers: http.IncomingHttpHeaders; + body: string; +}; + +let server: http.Server; +let baseUrl = ''; +let outputDir = ''; +let requests: RequestRecord[] = []; + +function projectForRequest(request: RequestRecord): 'bound' | 'unbound' { + if ( + request.url.includes('unbound-project') + || request.url.includes('run-unbound') + || request.url.includes('task-unbound') + ) { + return 'unbound'; + } + if (request.url === '/api/runs' && request.body) { + const body = JSON.parse(request.body) as { projectId?: string }; + return body.projectId === 'unbound-project' ? 'unbound' : 'bound'; + } + if (request.url.includes('/api/library/assets/') && request.body) { + const body = JSON.parse(request.body) as { projectId?: string }; + return body.projectId === 'unbound-project' ? 'unbound' : 'bound'; + } + return 'bound'; +} + +function authorizeProjectRequest(request: RequestRecord): { + status: number; + code?: string; +} { + if (request.headers.authorization === 'Bearer tool-proof') return { status: 200 }; + if (projectForRequest(request) === 'unbound') return { status: 200 }; + const workspaceId = request.headers['x-od-workspace-id']; + const memberId = request.headers['x-od-workspace-member-id']; + if (!workspaceId || !memberId) { + return { status: 401, code: 'WORKSPACE_CONTEXT_REQUIRED' }; + } + if (workspaceId !== TEAM_WORKSPACE_ID || memberId !== CREATOR_MEMBER_ID) { + return { status: 403, code: 'PROJECT_WRITE_FORBIDDEN' }; + } + return { status: 200 }; +} + +function json( + res: http.ServerResponse, + status: number, + body: unknown, +): void { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +beforeAll(async () => { + outputDir = await mkdtemp(path.join(os.tmpdir(), 'od-cli-project-scope-')); + server = http.createServer((req, res) => { + let body = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + const request: RequestRecord = { + method: req.method ?? '', + url: req.url ?? '', + headers: req.headers, + body, + }; + requests.push(request); + const authority = authorizeProjectRequest(request); + if (authority.status !== 200) { + json(res, authority.status, { + error: { + code: authority.code, + message: authority.code, + }, + }); + return; + } + if (request.url.includes('/export/')) { + res.writeHead(200, { + 'content-type': 'image/png', + 'content-disposition': 'attachment; filename="artifact.png"', + }); + res.end('png'); + return; + } + if (request.url.endsWith('/media/generate')) { + json(res, 200, { + taskId: projectForRequest(request) === 'unbound' + ? 'task-unbound' + : 'task-bound', + status: 'queued', + }); + return; + } + if (request.url.includes('/api/media/tasks/')) { + json(res, 200, { + status: 'done', + file: { name: 'generated.png', size: 3 }, + }); + return; + } + if (request.url.includes('/genui')) { + json(res, 200, request.method === 'POST' + ? { surface: { id: 'surface-row', surfaceId: 'surface-1' } } + : { surfaces: [] }); + return; + } + if (request.url.endsWith('/deploy')) { + json(res, 200, { + id: 'deployment-1', + status: 'ready', + url: 'https://example.invalid', + }); + return; + } + if (request.url.includes('/api/library/assets/')) { + json(res, 200, { relPath: 'library/asset.png' }); + return; + } + if (request.url === '/api/runs' && request.method === 'POST') { + json(res, 200, { runId: 'run-1' }); + return; + } + json(res, 404, { error: { code: 'NOT_FOUND', message: request.url } }); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('missing fixture address'); + baseUrl = `http://127.0.0.1:${address.port}`; +}); + +afterAll(async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + await rm(outputDir, { recursive: true, force: true }); +}); + +async function runCli( + args: string[], + env: NodeJS.ProcessEnv = {}, +): Promise<{ code: number; stdout: string; stderr: string }> { + try { + const { stdout, stderr } = await execFileP( + process.execPath, + [tsxCli, cliEntry, ...args], + { + cwd: daemonRoot, + env: { ...process.env, ...env, NODE_OPTIONS: '' }, + timeout: 15_000, + maxBuffer: 4 * 1024 * 1024, + }, + ); + return { code: 0, stdout, stderr }; + } catch (error) { + const failure = error as { + code?: number; + stdout?: string; + stderr?: string; + }; + return { + code: failure.code ?? 1, + stdout: failure.stdout ?? '', + stderr: failure.stderr ?? '', + }; + } +} + +type ConsumerFixture = { + label: string; + args: (projectId: string, outputPath: string) => string[]; +}; + +const consumers: ConsumerFixture[] = [ + { + label: 'export', + args: (projectId, outputPath) => [ + 'export', + 'index.html', + '--project', + projectId, + '--format', + 'image', + '--out', + outputPath, + ], + }, + { + label: 'media generate and wait', + args: (projectId) => [ + 'media', + 'generate', + '--project', + projectId, + '--surface', + 'image', + '--model', + 'fixture-model', + '--prompt', + 'fixture prompt', + ], + }, + { + label: 'project GenUI read', + args: (projectId) => ['ui', 'list', '--project', projectId, '--json'], + }, + { + label: 'run GenUI read', + args: (projectId) => [ + 'ui', + 'list', + '--run', + projectId === 'unbound-project' ? 'run-unbound' : 'run-bound', + '--json', + ], + }, + { + label: 'project GenUI write', + args: (projectId) => [ + 'ui', + 'revoke', + '--project', + projectId, + 'surface-1', + '--json', + ], + }, + { + label: 'run GenUI write', + args: (projectId) => [ + 'ui', + 'respond', + '--run', + projectId === 'unbound-project' ? 'run-unbound' : 'run-bound', + 'surface-1', + '--value', + 'approved', + '--json', + ], + }, + { + label: 'deploy', + args: (projectId) => ['deploy', projectId, '--file', 'index.html', '--json'], + }, + { + label: 'library apply', + args: (projectId) => [ + 'library', + 'apply', + 'asset-1', + '--project', + projectId, + '--json', + ], + }, + { + label: 'run start', + args: (projectId) => [ + 'run', + 'start', + '--project', + projectId, + '--json', + ], + }, +]; + +function workspaceFlags(memberId: string): string[] { + return [ + '--workspace', + TEAM_WORKSPACE_ID, + '--workspace-member', + memberId, + ]; +} + +describe('project consumer CLI explicit Workspace fixture matrix', () => { + for (const consumer of consumers) { + it(`${consumer.label}: allows the bound project creator`, async () => { + requests = []; + const result = await runCli([ + ...consumer.args( + 'bound-project', + path.join(outputDir, `${consumer.label}-creator.out`), + ), + ...workspaceFlags(CREATOR_MEMBER_ID), + '--daemon-url', + baseUrl, + ]); + + expect(result.code, result.stderr).toBe(0); + expect(requests.length).toBeGreaterThan(0); + for (const request of requests) { + expect(request.headers['x-od-workspace-id']).toBe(TEAM_WORKSPACE_ID); + expect(request.headers['x-od-workspace-member-id']).toBe(CREATOR_MEMBER_ID); + } + }); + + it(`${consumer.label}: preserves the non-creator denial`, async () => { + requests = []; + const result = await runCli([ + ...consumer.args( + 'bound-project', + path.join(outputDir, `${consumer.label}-noncreator.out`), + ), + ...workspaceFlags(OTHER_MEMBER_ID), + '--daemon-url', + baseUrl, + ]); + + expect(result.code).not.toBe(0); + expect(requests[0]?.headers['x-od-workspace-member-id']).toBe(OTHER_MEMBER_ID); + }); + + it(`${consumer.label}: lets the daemon reject missing bound-project scope`, async () => { + requests = []; + const result = await runCli([ + ...consumer.args( + 'bound-project', + path.join(outputDir, `${consumer.label}-missing.out`), + ), + '--daemon-url', + baseUrl, + ]); + + expect(result.code).not.toBe(0); + expect(requests[0]?.headers['x-od-workspace-id']).toBeUndefined(); + expect(requests[0]?.headers['x-od-workspace-member-id']).toBeUndefined(); + }); + + it(`${consumer.label}: keeps a historical unbound project headerless`, async () => { + requests = []; + const result = await runCli([ + ...consumer.args( + 'unbound-project', + path.join(outputDir, `${consumer.label}-unbound.out`), + ), + '--daemon-url', + baseUrl, + ]); + + expect(result.code, result.stderr).toBe(0); + expect(requests.length).toBeGreaterThan(0); + for (const request of requests) { + expect(request.headers['x-od-workspace-id']).toBeUndefined(); + expect(request.headers['x-od-workspace-member-id']).toBeUndefined(); + } + }); + } + + it('retains the authorized tool token through media polling', async () => { + requests = []; + const result = await runCli( + [ + 'media', + 'generate', + '--surface', + 'image', + '--model', + 'fixture-model', + '--prompt', + 'fixture prompt', + '--daemon-url', + baseUrl, + ], + { + OD_PROJECT_ID: 'bound-project', + OD_TOOL_TOKEN: 'tool-proof', + }, + ); + + expect(result.code, result.stderr).toBe(0); + expect(requests.map((request) => request.headers.authorization)).toEqual([ + 'Bearer tool-proof', + 'Bearer tool-proof', + ]); + }); +}); diff --git a/apps/daemon/tests/project-delete-staging.test.ts b/apps/daemon/tests/project-delete-staging.test.ts new file mode 100644 index 00000000000..38b6cd2ea68 --- /dev/null +++ b/apps/daemon/tests/project-delete-staging.test.ts @@ -0,0 +1,32 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { stageProjectDirsForDelete } from '../src/projects.js'; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe('project delete staging', () => { + it('rolls back already staged directories when a later project id fails', async () => { + const root = path.join(tmpdir(), `od-delete-staging-${Date.now()}-${Math.random()}`); + roots.push(root); + const firstProjectDir = path.join(root, 'project-a'); + mkdirSync(firstProjectDir, { recursive: true }); + writeFileSync(path.join(firstProjectDir, 'index.html'), '

still here

'); + + await expect( + stageProjectDirsForDelete(root, ['project-a', '../bad'], 'batch-1'), + ).rejects.toThrow('invalid project id'); + + expect(existsSync(firstProjectDir)).toBe(true); + expect(existsSync(path.join(firstProjectDir, 'index.html'))).toBe(true); + expect(existsSync(path.join(root, '.delete-staging', 'batch-1', 'project-a'))).toBe(false); + }); +}); diff --git a/apps/daemon/tests/project-file-range.test.ts b/apps/daemon/tests/project-file-range.test.ts index ef0150119bd..0254acfcf59 100644 --- a/apps/daemon/tests/project-file-range.test.ts +++ b/apps/daemon/tests/project-file-range.test.ts @@ -174,6 +174,13 @@ describe('GET /api/projects/:id/raw/* range request route', () => { baseUrl = started.url; server = started.server; + const createResponse = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: projectId, name: 'Raw range fixture' }), + }); + expect(createResponse.status).toBe(200); + // Write a test video file into the daemon's projects root. // OD_DATA_DIR is set by tests/setup.ts so we can derive the path. projectsRoot = path.join(process.env.OD_DATA_DIR!, 'projects'); @@ -351,6 +358,12 @@ describe('GET /api/projects/:id/raw/* range request route', () => { expect(html).toContain("type: 'od:preview-scroll'"); expect(html).toContain("type: 'od:preview-content-size'"); expect(html).toContain('od:preview-content-size-request'); + expect(html).toContain('lastContentSizeRequest.measurementId'); + expect(html).toContain('lastContentSizeRequest.generation'); + expect(html).toContain('documentEpoch: contentSizeDocumentEpoch'); + expect(html).toContain("get('odPreviewEpoch')"); + expect(html).toContain('scrollWidth: size && size.scrollWidth'); + expect(html).toContain('clientWidth: size && size.clientWidth'); }); it('injects the URL preview scroll bridge before the closing body tag', async () => { @@ -370,6 +383,8 @@ describe('GET /api/projects/:id/raw/* range request route', () => { const html = await bridged.text(); expect(html).toContain('data-od-url-selection-bridge'); expect(html).toContain("type: 'od:comment-target'"); + expect(html).toContain("type: 'od:preview-runtime-state-captured'"); + expect(html).toContain('roots: roots'); expect(html).not.toContain('data-od-url-scroll-bridge'); }); @@ -437,6 +452,12 @@ describe('GET /api/projects/:id/raw/* range request route', () => { expect(html).toContain('data-od-url-scroll-bridge'); expect(html).toContain("type: 'od:preview-content-size'"); expect(html).toContain('od:preview-content-size-request'); + expect(html).toContain('lastContentSizeRequest.measurementId'); + expect(html).toContain('lastContentSizeRequest.generation'); + expect(html).toContain('documentEpoch: contentSizeDocumentEpoch'); + expect(html).toContain("get('odPreviewEpoch')"); + expect(html).toContain('scrollWidth: size && size.scrollWidth'); + expect(html).toContain('clientWidth: size && size.clientWidth'); }); it('does not let the powered preview origin call normal daemon APIs', async () => { diff --git a/apps/daemon/tests/project-file-version-readonly-mirror.test.ts b/apps/daemon/tests/project-file-version-readonly-mirror.test.ts new file mode 100644 index 00000000000..43c23bbd354 --- /dev/null +++ b/apps/daemon/tests/project-file-version-readonly-mirror.test.ts @@ -0,0 +1,182 @@ +// A readonly member's version-history GET must not write into the project. +// +// Reported as "a member can still open the time machine on a readonly shared +// project". Investigation (2026-07-27, feature-test hub, real owner/member +// daemons) found the *entry point* is intentional — 飞书 recvq56vFjQKfT +// deliberately un-gated it, because browsing history is a read action and the +// restore button keeps its own `viewerOnly` gate — and the restore ENDPOINT is +// correctly gated: a real member's headers get 403 +// WORKSPACE_PROJECT_PERMISSION_DENIED, headerless gets 401. +// +// What is NOT correct is what the readonly member's GET does on the way to +// rendering that history: +// +// GET /api/projects/:id/files/*/versions calls +// `ensureCurrentProjectFileVersion` whenever the manifest is empty, which +// takes a lock and CREATES a version on disk. On a member's mirror of +// someone else's shared project that is a write into a project whose own +// banner says "你可以查看和评论,但不能通过 Chat 或编辑工具修改 Artifact". +// +// It is also the reason the feature cannot deliver what recvq56vFjQKfT asked +// for. `.file-versions` is in `MEMBER_MIRROR_EXCLUDED_ENTRIES` +// (collab/vela-cli-resource-adapter.ts), so the owner's real history NEVER +// syncs to a member. Measured live: the owner had 4 versions; the member's +// panel showed exactly 1, labelled "Version 1", created by their own GET at +// the moment they opened the panel. The member was not reading the owner's +// history — they were reading a synthetic one their own read had just +// manufactured. +// +// Invariant this file pins: the baseline-version bootstrap belongs to callers +// with write authority over the project. A caller whose workspace identity +// proves it cannot write still gets to READ the history (no 401/403 — the +// entry stays open per recvq56vFjQKfT), it just gets the truthful empty +// history instead of a fabricated entry, and leaves nothing behind on disk. +// +// A persisted Workspace binding also means a headerless caller is not allowed +// to enter this data plane. It must prove the exact Workspace/member pair; +// absence of identity must neither expose history nor fabricate a local +// baseline version. + +import type http from 'node:http'; +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { startServer } from '../src/server.js'; + +const WORKSPACE_ID = 'ws-readonly-mirror'; +const OWNER_MEMBER_ID = 'member-owner-readonly-mirror'; +const READER_MEMBER_ID = 'member-reader-readonly-mirror'; + +describe('version history on a readonly shared mirror', () => { + let server: http.Server; + let baseUrl: string; + const projectsToClean: string[] = []; + + beforeAll(async () => { + const started = (await startServer({ port: 0, returnServer: true })) as { + url: string; + server: http.Server; + }; + baseUrl = started.url; + server = started.server; + }); + + afterAll(async () => { + for (const id of projectsToClean.splice(0)) { + await fetch(`${baseUrl}/api/projects/${id}`, { + method: 'DELETE', + headers: memberHeaders(OWNER_MEMBER_ID, 'member'), + }).catch(() => {}); + } + await new Promise((resolve) => server.close(() => resolve())); + }); + + function projectsRoot(): string { + const dataDir = process.env.OD_DATA_DIR; + if (!dataDir) throw new Error('OD_DATA_DIR is required for daemon route tests'); + return path.join(dataDir, 'projects'); + } + + async function versionRootExists(projectId: string): Promise { + try { + await fs.stat(path.join(projectsRoot(), projectId, '.file-versions')); + return true; + } catch { + return false; + } + } + + /** + * A team-bound project owned by `OWNER_MEMBER_ID`, seeded only through the + * production create endpoint so the workspace binding is the one the daemon + * writes itself (`created_by_workspace_member_id = OWNER_MEMBER_ID`) rather + * than a row a test reached into sqlite to forge. + * + * Content is then written straight to the project directory instead of + * through the file-write API, because that API bootstraps a version of its + * own. The result is the shape a freshly pulled member mirror has: real + * content on disk, no version manifest — `.file-versions` is in + * `MEMBER_MIRROR_EXCLUDED_ENTRIES`, so a mirror never receives the owner's. + */ + async function seedTeamProject(): Promise { + const id = `readonly-mirror-${randomUUID()}`; + const created = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...memberHeaders(OWNER_MEMBER_ID, 'member') }, + body: JSON.stringify({ id, name: 'Readonly mirror project' }), + }); + expect(created.status).toBe(200); + projectsToClean.push(id); + + const dir = path.join(projectsRoot(), id); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + path.join(dir, 'index.html'), + '

owner content

', + 'utf8', + ); + await fs.rm(path.join(dir, '.file-versions'), { recursive: true, force: true }); + return id; + } + + function memberHeaders(memberId: string, role: 'owner' | 'member') { + return { + 'x-od-workspace-id': WORKSPACE_ID, + 'x-od-workspace-member-id': memberId, + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': role, + 'x-od-workspace-lifecycle-state': 'active', + 'x-od-workspace-member-status': 'active', + 'x-od-workspace-can-share-projects': 'true', + 'x-od-workspace-can-write-synced-files': 'true', + }; + } + + async function getVersions(projectId: string, headers?: Record) { + const resp = await fetch( + `${baseUrl}/api/projects/${projectId}/files/index.html/versions`, + headers ? { headers } : undefined, + ); + expect(resp.status).toBe(200); + return (await resp.json()) as { versions: { id: string; label?: string | null }[] }; + } + + it('does not fabricate a version, or write one, for a member who cannot write the project', async () => { + const projectId = await seedTeamProject(); + expect(await versionRootExists(projectId)).toBe(false); + + const body = await getVersions(projectId, memberHeaders(READER_MEMBER_ID, 'member')); + + // The read still succeeds — the entry point stays open (recvq56vFjQKfT). + // It just reports the truth: this mirror carries no version history. + expect(body.versions).toEqual([]); + // And it left nothing behind in a project the member cannot write. + expect(await versionRootExists(projectId)).toBe(false); + }); + + it('still bootstraps a baseline version for the member who owns the project', async () => { + const projectId = await seedTeamProject(); + + const body = await getVersions(projectId, memberHeaders(OWNER_MEMBER_ID, 'member')); + + expect(body.versions.length).toBe(1); + expect(await versionRootExists(projectId)).toBe(true); + }); + + it('rejects a headerless caller for a Workspace-bound project without writing', async () => { + const projectId = await seedTeamProject(); + expect(await versionRootExists(projectId)).toBe(false); + + const response = await fetch( + `${baseUrl}/api/projects/${projectId}/files/index.html/versions`, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'WORKSPACE_CONTEXT_REQUIRED' }, + }); + expect(await versionRootExists(projectId)).toBe(false); + }); +}); diff --git a/apps/daemon/tests/project-raw-cache.test.ts b/apps/daemon/tests/project-raw-cache.test.ts index 3b689a17f08..5ebff3df032 100644 --- a/apps/daemon/tests/project-raw-cache.test.ts +++ b/apps/daemon/tests/project-raw-cache.test.ts @@ -30,6 +30,13 @@ describe('GET /api/projects/:id/raw/* cache revalidation', () => { baseUrl = started.url; server = started.server; + const createResponse = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: projectId, name: 'Raw cache fixture' }), + }); + expect(createResponse.status).toBe(200); + projectsRoot = path.join(process.env.OD_DATA_DIR!, 'projects'); const dir = path.join(projectsRoot, projectId); await mkdir(dir, { recursive: true }); diff --git a/apps/daemon/tests/project-share-dir.test.ts b/apps/daemon/tests/project-share-dir.test.ts new file mode 100644 index 00000000000..f937d559dd3 --- /dev/null +++ b/apps/daemon/tests/project-share-dir.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { resolveProjectShareDir } from '../src/collab/project-share-dir.js'; + +describe('resolveProjectShareDir', () => { + it('passes project metadata through when resolving a team-share source directory', () => { + const metadata = { kind: 'prototype', baseDir: '/external/project-root' }; + const resolveProjectDir = vi.fn(() => '/external/project-root'); + + const dir = resolveProjectShareDir( + '/managed/projects', + 'project-imported', + { id: 'project-imported', metadata }, + resolveProjectDir, + ); + + expect(dir).toBe('/external/project-root'); + expect(resolveProjectDir).toHaveBeenCalledWith('/managed/projects', 'project-imported', metadata); + }); + + it('fails when the project row is missing', () => { + expect(() => + resolveProjectShareDir('/managed/projects', 'missing-project', null, vi.fn()), + ).toThrow('Project missing-project not found'); + }); +}); diff --git a/apps/daemon/tests/prompts/freeform-deck-signal.test.ts b/apps/daemon/tests/prompts/freeform-deck-signal.test.ts index 1ea4cdfda39..061e8ee30b5 100644 --- a/apps/daemon/tests/prompts/freeform-deck-signal.test.ts +++ b/apps/daemon/tests/prompts/freeform-deck-signal.test.ts @@ -9,6 +9,7 @@ const NESTED_DIAGRAM_HEADING = '## Nested / concentric diagram discipline'; describe('detectDeckIntentSignal', () => { it('fires on English deck vocabulary', () => { expect(detectDeckIntentSignal('build me a pitch deck for investors')).toBe(true); + expect(detectDeckIntentSignal('Write a Seed Pitch like a Top Pre-Seed Founder')).toBe(true); expect(detectDeckIntentSignal('a 10-slide keynote')).toBe(true); expect(detectDeckIntentSignal('export the PPT')).toBe(true); expect(detectDeckIntentSignal('make a slideshow of the trip')).toBe(true); diff --git a/apps/daemon/tests/proxy-routes.test.ts b/apps/daemon/tests/proxy-routes.test.ts index c409db9c398..4af10ac6425 100644 --- a/apps/daemon/tests/proxy-routes.test.ts +++ b/apps/daemon/tests/proxy-routes.test.ts @@ -1760,6 +1760,13 @@ describe('API proxy routes', () => { const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x42, 0x59]); let capturedUrl: string | undefined; + const createResponse = await realFetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: 'test-project', name: 'Proxy route fixture' }), + }); + expect(createResponse.status).toBe(200); + const fetchMock = vi.fn(async (input: FetchInput, init?: FetchInit) => { const url = String(input); if (url.startsWith(baseUrl)) return realFetch(input, init); diff --git a/apps/daemon/tests/resource-cli.test.ts b/apps/daemon/tests/resource-cli.test.ts new file mode 100644 index 00000000000..705d46d1548 --- /dev/null +++ b/apps/daemon/tests/resource-cli.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { runVelaCommandMock } = vi.hoisted(() => ({ + runVelaCommandMock: vi.fn(), +})); + +vi.mock('../src/integrations/vela-command.js', () => ({ + runVelaCommand: runVelaCommandMock, +})); + +import { runResource } from '../src/resource-cli.js'; + +describe('od resource Vela compatibility entry point', () => { + beforeEach(() => { + process.exitCode = undefined; + runVelaCommandMock.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; + }); + + it('forwards resource arguments to the login-backed Vela CLI', async () => { + runVelaCommandMock.mockResolvedValue('{"version":3}\n'); + const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + await runResource([ + 'push', + 'project', + 'project-1', + '/tmp/project-1', + '--json', + ]); + + expect(runVelaCommandMock).toHaveBeenCalledWith([ + 'resource', + 'push', + 'project', + 'project-1', + '/tmp/project-1', + '--json', + ]); + expect(write).toHaveBeenCalledWith('{"version":3}\n'); + }); + + it('shows Vela resource help when no subcommand is provided', async () => { + runVelaCommandMock.mockResolvedValue('resource help\n'); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + await runResource([]); + + expect(runVelaCommandMock).toHaveBeenCalledWith(['resource', '--help']); + }); + + it('surfaces Vela errors as a failed od command', async () => { + runVelaCommandMock.mockRejectedValue(new Error('profile is not logged in')); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await runResource(['shared', '--json']); + + expect(error).toHaveBeenCalledWith('profile is not logged in'); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/apps/daemon/tests/resource-workspace-authority-preflight.test.ts b/apps/daemon/tests/resource-workspace-authority-preflight.test.ts new file mode 100644 index 00000000000..167076f8652 --- /dev/null +++ b/apps/daemon/tests/resource-workspace-authority-preflight.test.ts @@ -0,0 +1,85 @@ +import type http from 'node:http'; +import { existsSync } from 'node:fs'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { openDatabase } from '../src/db.js'; +import { getInstalledPlugin } from '../src/plugins/registry.js'; +import { startServer } from '../src/server.js'; + +let server: http.Server; +let baseUrl: string; +let sourceRoot: string; +let shutdown: (() => Promise | void) | undefined; + +beforeAll(async () => { + sourceRoot = await mkdtemp(path.join(os.tmpdir(), 'od-workspace-authority-preflight-')); + const started = (await startServer({ port: 0, returnServer: true })) as { + url: string; + server: http.Server; + shutdown?: () => Promise | void; + }; + baseUrl = started.url; + server = started.server; + shutdown = started.shutdown; +}); + +afterAll(async () => { + await Promise.resolve(shutdown?.()); + await new Promise((resolve) => server.close(() => resolve())); + await rm(sourceRoot, { recursive: true, force: true }); +}); + +describe('Workspace resource mutation authority preflight', () => { + it('rejects a partial Plugin install scope before filesystem or database effects', async () => { + const pluginId = `partial-plugin-${Date.now()}`; + const pluginSource = path.join(sourceRoot, pluginId); + await mkdir(pluginSource, { recursive: true }); + await writeFile( + path.join(pluginSource, 'open-design.json'), + JSON.stringify({ + name: pluginId, + title: pluginId, + version: '1.0.0', + }), + ); + + const response = await fetch(`${baseUrl}/api/plugins/install`, { + method: 'POST', + headers: { + accept: 'text/event-stream', + 'content-type': 'application/json', + 'x-od-workspace-id': 'workspace-partial-plugin', + }, + body: JSON.stringify({ source: pluginSource }), + }); + await response.text(); + + expect(response.status).toBe(400); + const db = openDatabase(process.cwd(), { dataDir: process.env.OD_DATA_DIR! }); + expect(getInstalledPlugin(db, pluginId)).toBeNull(); + }); + + it('rejects a partial Skill import scope before creating its folder or binding', async () => { + const skillId = `partial-skill-${Date.now()}`; + const userSkillDir = path.join(process.env.OD_DATA_DIR!, 'skills', skillId); + + const response = await fetch(`${baseUrl}/api/skills/import`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-od-workspace-id': 'workspace-partial-skill', + }, + body: JSON.stringify({ + name: skillId, + description: 'partial Workspace authority fixture', + body: `---\nname: ${skillId}\ndescription: fixture\n---\n\n# Fixture\n`, + }), + }); + + expect(response.status).toBe(400); + expect(existsSync(userSkillDir)).toBe(false); + }); +}); diff --git a/apps/daemon/tests/routes/design-system-delete-unshares-team-share.test.ts b/apps/daemon/tests/routes/design-system-delete-unshares-team-share.test.ts new file mode 100644 index 00000000000..444e428859f --- /dev/null +++ b/apps/daemon/tests/routes/design-system-delete-unshares-team-share.test.ts @@ -0,0 +1,304 @@ +// spec 04 §11: before this fix, `DELETE /api/design-systems/:id` only ever +// called `deleteUserDesignSystem` (rm -rf the canonical directory) + +// `deleteWorkspaceResourceByResourceId` — it never called +// `designSystemsTeamShare.unshare`, the same "remove from the hub index" +// function the dedicated unshare route (`routes/team-resource-share.ts`) +// already uses. `canMutateUserDesignSystem`'s permission gate reads +// `isTeamSyncedUserDesignSystem`, which is true ONLY on a teammate's PULLED +// copy — the sharer deleting their OWN original always reads +// `teamSynced: false`, so the delete sailed straight through with the hub +// index left dangling, and every teammate's `syncSharedTeamDesignSystem` +// kept re-stamping `markTeamSynced()` onto their already-synced local copy +// forever (since the hub never stopped reporting the resource as shared). +// +// This spec drives the REAL `registerDesignSystemRoutes` DELETE handler over +// real HTTP, wired to a REAL `createTeamResourceShareService` whose `run` +// callback holds actual mutable "hub" state (a live shared-resource list), +// not a spy — exactly the `team-resource-share.test.ts` pattern this +// sprint's other specs already use for simulating the Vela CLI hub. +// Assertions check the real state transition (the hub's `shared --json` +// listing losing the entry, and the `remove` command actually being issued) +// rather than "was unshare() called" — the shallow mock-call assertion the +// bug review explicitly ruled insufficient. + +import type http from 'node:http'; +import { mkdtempSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import express from 'express'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { registerDesignSystemRoutes } from '../../src/routes/design-systems.js'; +import type { DesignSystemSummary } from '../../src/design-systems/index.js'; +import { closeDatabase, openDatabase } from '../../src/db.js'; +import { + createTeamResourceShareService, + unshareIfCurrentlyShared, + type TeamResourceRequestScope, +} from '../../src/collab/team-resource-share.js'; +import type { ResourceHubPrincipal } from '../../src/collab/resource-principal.js'; + +let server: http.Server | null = null; +let tempDir: string | null = null; + +afterEach(async () => { + if (server) { + await new Promise((resolve) => server?.close(() => resolve())); + server = null; + } + closeDatabase(); + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } +}); + +function listen(app: express.Express): Promise { + return new Promise((resolve) => { + server = app.listen(0, '127.0.0.1', () => { + const address = server?.address() as { port: number }; + resolve(`http://127.0.0.1:${address.port}`); + }); + }); +} + +const OWNER_PRINCIPAL: ResourceHubPrincipal = { + memberId: 'wm-owner', + teamId: 't-1', + role: 'owner', + lifecycleState: 'active', + workspaceType: 'team', +}; + +const designSystemSummary: DesignSystemSummary = { + id: 'user:my-brand', + title: 'My Brand', + category: 'Custom', + summary: 'Shared to the team.', + swatches: [], + surface: 'web', + body: '# My Brand', + source: 'user', + status: 'draft', + isEditable: true, +}; + +/** In-memory stand-in for the Vela CLI resource hub. `shared --json` reads + * the live map; `remove` mutates it — the same shape + * `team-resource-share.test.ts` already uses to simulate hub-state + * transitions, applied here at the HTTP-route boundary instead of the bare + * service. */ +function fakeHub() { + // `localId` mirrors what real production's `describeResource` (server.ts) + // always stamps into `metadata.localId` when it shares a design system — + // `sharedResources()` prefers that explicit field over decoding it back out + // of the raw hub id, which is also what makes its scoped-vs-legacy-prefix + // double-parse (see `team-resource-share.ts`) collapse to ONE record + // instead of two for a properly-scoped id. + const resources = new Map(); + const removeCalls: string[][] = []; + + const run = async (args: string[]): Promise => { + if (args[0] === 'remove') { + removeCalls.push(args); + resources.delete(args[1]!); + return JSON.stringify({ ok: true }); + } + if (args[0] === 'shared' && args[1] === '--json') { + return JSON.stringify({ + resources: [...resources.entries()].map(([id, meta]) => ({ + id, + kind: 'design_system', + deletedAt: null, + ownerMemberId: meta.ownerMemberId, + metadata: { localId: meta.localId, ...(meta.title ? { title: meta.title } : {}) }, + })), + }); + } + throw new Error(`unexpected vela resource args: ${args.join(' ')}`); + }; + + return { resources, removeCalls, run }; +} + +function registerRoutes( + app: express.Express, + opts: { + hub: ReturnType; + canMutate?: (root: string, id: string, req: any) => Promise; + principal?: ResourceHubPrincipal; + unshareTeamDesignSystemIfShared?: (id: string, req: any) => Promise; + }, +) { + tempDir = mkdtempSync(path.join(os.tmpdir(), 'od-ds-delete-unshare-')); + const db = openDatabase(tempDir, { dataDir: tempDir }); + const deleteUserDesignSystem = vi.fn(async () => true); + const scope: TeamResourceRequestScope = { + principal: opts.principal ?? OWNER_PRINCIPAL, + canShare: true, + }; + const designSystemsTeamShare = createTeamResourceShareService({ + kind: 'design_system', + idPrefix: 'ds', + resolveDir: () => '/tmp/ds', + run: opts.hub.run, + env: { OD_WORKSPACE_CONTEXT_SOURCE: 'vela' }, + }); + registerDesignSystemRoutes(app, { + db, + paths: { + CRAFT_DIR: '', + USER_DESIGN_SYSTEMS_DIR: '', + } as never, + projectFiles: {} as never, + projectStore: {} as never, + verifyWorkspaceRequestAuthority: async () => { + throw new Error('unbound fixture must not verify Workspace authority'); + }, + workspaceResources: { + getWorkspaceResource: () => undefined, + getWorkspaceResourceByResourceId: () => undefined, + }, + designSystems: { + buildUserDesignSystemArchive: async () => null, + canMutateUserDesignSystem: opts.canMutate ?? (async () => true), + createUserDesignSystem: async () => designSystemSummary, + deleteUserDesignSystem, + ensureUserDesignSystemWorkspaceProject: async () => null, + listAllDesignSystems: async () => [designSystemSummary], + listUserDesignSystemFiles: async () => null, + listUserDesignSystemRevisions: async () => null, + prepareDesignTokenContractRebuild: async () => ({ decision: { available: false } }) as never, + readAvailableDesignSystem: async () => null, + readAvailableDesignSystemPackageInfo: async () => null, + readAvailableDesignSystemStaticFile: async () => null, + readDesignSystemWorkspaceTextFile: async () => null, + readUserDesignSystemFile: async () => null, + renderDesignSystemPreview: () => '', + renderDesignSystemShowcase: () => '', + syncUserDesignSystemAssetsFromWorkspace: async () => ({ ok: false, reason: 'not-found' }), + unshareTeamDesignSystemIfShared: + opts.unshareTeamDesignSystemIfShared ?? + ((id) => unshareIfCurrentlyShared(designSystemsTeamShare, id, scope)), + updateUserDesignSystem: async () => null, + updateUserDesignSystemRevisionStatus: async () => null, + }, + generationJobs: { + get: () => null, + rebuildTokenContract: () => ({}) as never, + revise: () => ({}) as never, + start: () => ({}) as never, + }, + }); + return { deleteUserDesignSystem, designSystemsTeamShare, scope }; +} + +describe('DELETE /api/design-systems/:id unshares from the team hub first', () => { + it('removes the hub index entry BEFORE the local delete, for the sharer deleting their OWN design system', async () => { + const hub = fakeHub(); + // Seed the hub as ALREADY reporting this design system shared, owned by + // the SAME principal the route's `canMutateUserDesignSystem` will let + // through — the exact "sharer deletes their own thing" bug shape: this + // resource is NOT `isTeamSyncedUserDesignSystem` (it is the sharer's own + // canonical copy, not a pulled copy), so `canMutateUserDesignSystem` + // returns true unconditionally in production, same as the `async () => + // true` default here. + const hubResourceId = 'ds-t-1-user-my-brand'; + hub.resources.set(hubResourceId, { + ownerMemberId: OWNER_PRINCIPAL.memberId, + localId: 'user:my-brand', + title: 'My Brand', + }); + + const app = express(); + app.use(express.json()); + const { deleteUserDesignSystem, designSystemsTeamShare, scope } = registerRoutes(app, { hub }); + const baseUrl = await listen(app); + + // Sanity: the hub really does report it shared before the delete. + await expect(designSystemsTeamShare.sharedResources(scope)).resolves.toEqual([ + expect.objectContaining({ id: 'user:my-brand' }), + ]); + + const res = await fetch(`${baseUrl}/api/design-systems/user:my-brand`, { method: 'DELETE' }); + expect(res.status).toBe(204); + + // The real hub state transitioned — not just "a function was called". + expect(hub.resources.has(hubResourceId)).toBe(false); + expect(hub.removeCalls).toHaveLength(1); + expect(hub.removeCalls[0]).toEqual(['remove', hubResourceId, '--json']); + await expect(designSystemsTeamShare.sharedResources(scope)).resolves.toEqual([]); + + // AND the local delete actually proceeded (unshare-then-delete). + expect(deleteUserDesignSystem).toHaveBeenCalledOnce(); + }); + + it('does not touch the hub at all when deleting a design system that was never shared', async () => { + const hub = fakeHub(); + const app = express(); + app.use(express.json()); + const { deleteUserDesignSystem } = registerRoutes(app, { hub }); + const baseUrl = await listen(app); + + const res = await fetch(`${baseUrl}/api/design-systems/user:my-brand`, { method: 'DELETE' }); + expect(res.status).toBe(204); + + // No unshare traffic at all for a design system that was never on the + // team share list — regression guard against always calling + // `service.unshare()` regardless of current share state (which would + // itself unconditionally issue a hub `remove` even for an unknown id, + // per `TeamResourceShareService.unshare`'s own implementation). + expect(hub.removeCalls).toHaveLength(0); + expect(deleteUserDesignSystem).toHaveBeenCalledOnce(); + }); + + it('continues local deletion when authoritative Personal scope has no Team share to retract', async () => { + const hub = fakeHub(); + const unsharePersonal = vi.fn(async () => false); + const app = express(); + app.use(express.json()); + const { deleteUserDesignSystem } = registerRoutes(app, { + hub, + unshareTeamDesignSystemIfShared: unsharePersonal, + }); + const baseUrl = await listen(app); + + const res = await fetch(`${baseUrl}/api/design-systems/user:my-brand`, { + method: 'DELETE', + }); + + expect(res.status).toBe(204); + expect(unsharePersonal).toHaveBeenCalledOnce(); + expect(hub.removeCalls).toHaveLength(0); + expect(deleteUserDesignSystem).toHaveBeenCalledOnce(); + }); + + it('aborts the local delete when the caller cannot actually manage the shared resource', async () => { + // Defensive case: if `unshare()` ever throws (e.g. a `canUnshare: false` + // race), the route must not proceed to the local delete. Simulate that by + // resolving a plain-member principal (not owner/admin) whose memberId + // differs from the hub's recorded `ownerMemberId`, so + // `canManageSharedResource` inside `unshare()` refuses. + const hub = fakeHub(); + hub.resources.set('ds-t-1-user-my-brand', { ownerMemberId: 'wm-someone-else', localId: 'user:my-brand' }); + const memberPrincipal: ResourceHubPrincipal = { + memberId: 'wm-owner', + teamId: 't-1', + role: 'member', + lifecycleState: 'active', + workspaceType: 'team', + }; + + const app = express(); + app.use(express.json()); + const { deleteUserDesignSystem } = registerRoutes(app, { hub, principal: memberPrincipal }); + const baseUrl = await listen(app); + + const res = await fetch(`${baseUrl}/api/design-systems/user:my-brand`, { method: 'DELETE' }); + + expect(res.status).toBe(500); + expect(deleteUserDesignSystem).not.toHaveBeenCalled(); + // The hub entry survives untouched — the abort really stopped the whole + // chain, not just the local delete. + expect(hub.resources.has('ds-t-1-user-my-brand')).toBe(true); + }); +}); diff --git a/apps/daemon/tests/routes/design-system-showcase-assets.test.ts b/apps/daemon/tests/routes/design-system-showcase-assets.test.ts index 5b047779626..11fbb142153 100644 --- a/apps/daemon/tests/routes/design-system-showcase-assets.test.ts +++ b/apps/daemon/tests/routes/design-system-showcase-assets.test.ts @@ -29,8 +29,16 @@ function registerRoutes(app: express.Express, staticHtml: string | null) { } as never, projectFiles: {} as never, projectStore: {} as never, + verifyWorkspaceRequestAuthority: async () => { + throw new Error('unbound fixture must not verify Workspace authority'); + }, + workspaceResources: { + getWorkspaceResource: () => undefined, + getWorkspaceResourceByResourceId: () => undefined, + }, designSystems: { buildUserDesignSystemArchive: async () => null, + canMutateUserDesignSystem: async () => true, createUserDesignSystem: async () => ({}) as never, deleteUserDesignSystem: async () => false, ensureUserDesignSystemWorkspaceProject: async () => null, @@ -53,6 +61,8 @@ function registerRoutes(app: express.Express, staticHtml: string | null) { renderDesignSystemPreview: () => 'preview', renderDesignSystemShowcase: (id: string, body: string) => `${id} synthetic
${body}
`, + syncUserDesignSystemAssetsFromWorkspace: async () => ({ ok: false, reason: 'not-found' }), + unshareTeamDesignSystemIfShared: async () => false, updateUserDesignSystem: async () => null, updateUserDesignSystemRevisionStatus: async () => null, }, diff --git a/apps/daemon/tests/routes/design-systems-team-mutation-guard.test.ts b/apps/daemon/tests/routes/design-systems-team-mutation-guard.test.ts new file mode 100644 index 00000000000..e4f868c28cc --- /dev/null +++ b/apps/daemon/tests/routes/design-systems-team-mutation-guard.test.ts @@ -0,0 +1,356 @@ +// recvqb6mfyqXLD: a design system materialized locally from a teammate's team +// share must not be editable, publish-toggleable, or deletable by a plain +// member — only the original sharer, or a workspace owner/admin, may mutate +// it (the same rule "who can unshare" already enforces). The UI hides the +// affordances (DesignSystemsTab.tsx `canManageTeamSynced`), but nothing +// stopped a direct PATCH/DELETE call before this guard: `canMutateUserDesignSystem` +// is the server-side enforcement point these specs pin down. +// +// Spec 9.2 adds a second, independent gate on top: a locked/deleted workspace +// (billing lapse, deletion in progress) must refuse every PATCH/DELETE +// regardless of what `canMutateUserDesignSystem` itself would say — see the +// "workspace lock" describe block below. That gate lives in the route, not +// inside the (here mocked) `canMutateUserDesignSystem`, precisely so it holds +// no matter what a caller-supplied mutation predicate decides. + +import type http from 'node:http'; +import { mkdtempSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import express from 'express'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { registerDesignSystemRoutes } from '../../src/routes/design-systems.js'; +import type { DesignSystemSummary } from '../../src/design-systems/index.js'; +import { closeDatabase, openDatabase } from '../../src/db.js'; + +let server: http.Server | null = null; +let tempDir: string | null = null; + +afterEach(async () => { + if (server) { + await new Promise((resolve) => server?.close(() => resolve())); + server = null; + } + closeDatabase(); + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } +}); + +function listen(app: express.Express): Promise { + return new Promise((resolve) => { + server = app.listen(0, '127.0.0.1', () => { + const address = server?.address() as { port: number }; + resolve(`http://127.0.0.1:${address.port}`); + }); + }); +} + +const designSystemSummary: DesignSystemSummary = { + id: 'user:teammate-ds', + title: 'Teammate DS', + category: 'Custom', + summary: 'Synced from a teammate.', + swatches: [], + surface: 'web', + body: '# Teammate DS', + source: 'user', + status: 'draft', + isEditable: true, +}; + +function registerRoutes(app: express.Express, canMutate: (root: string, id: string, req: any) => Promise) { + tempDir = mkdtempSync(path.join(os.tmpdir(), 'od-ds-mutation-guard-')); + const db = openDatabase(tempDir, { dataDir: tempDir }); + const updateUserDesignSystem = vi.fn(async () => ({ ...designSystemSummary, status: 'published' as const })); + const deleteUserDesignSystem = vi.fn(async () => true); + const updateUserDesignSystemRevisionStatus = vi.fn(async (_root: string, _id: string, revisionId: string, status: 'accepted' | 'rejected') => ({ + id: revisionId, + designSystemId: designSystemSummary.id, + status, + feedback: 'Tighten the spacing scale.', + baseBody: designSystemSummary.body, + proposedBody: `${designSystemSummary.body}\nMore.`, + createdAt: '2026-07-24T00:00:00.000Z', + updatedAt: '2026-07-24T00:00:00.000Z', + })); + registerDesignSystemRoutes(app, { + db, + paths: { + CRAFT_DIR: '', + USER_DESIGN_SYSTEMS_DIR: '', + } as never, + projectFiles: {} as never, + projectStore: {} as never, + verifyWorkspaceRequestAuthority: async () => { + throw new Error('unbound fixture must not verify Workspace authority'); + }, + workspaceResources: { + getWorkspaceResource: () => undefined, + getWorkspaceResourceByResourceId: () => undefined, + }, + designSystems: { + buildUserDesignSystemArchive: async () => null, + canMutateUserDesignSystem: canMutate, + createUserDesignSystem: async () => designSystemSummary, + deleteUserDesignSystem, + ensureUserDesignSystemWorkspaceProject: async () => null, + listAllDesignSystems: async () => [designSystemSummary], + listUserDesignSystemFiles: async () => null, + listUserDesignSystemRevisions: async () => null, + prepareDesignTokenContractRebuild: async () => ({ decision: { available: false } }) as never, + readAvailableDesignSystem: async () => designSystemSummary.body, + readAvailableDesignSystemPackageInfo: async () => null, + readAvailableDesignSystemStaticFile: async () => null, + readDesignSystemWorkspaceTextFile: async () => null, + readUserDesignSystemFile: async () => null, + renderDesignSystemPreview: () => '', + renderDesignSystemShowcase: () => '', + syncUserDesignSystemAssetsFromWorkspace: async () => ({ ok: false, reason: 'not-found' }), + unshareTeamDesignSystemIfShared: async () => false, + updateUserDesignSystem, + updateUserDesignSystemRevisionStatus, + }, + generationJobs: { + get: () => null, + rebuildTokenContract: () => ({}) as never, + revise: () => ({}) as never, + start: () => ({}) as never, + }, + }); + return { updateUserDesignSystem, deleteUserDesignSystem, updateUserDesignSystemRevisionStatus }; +} + +describe('design system PATCH/DELETE team-share mutation guard', () => { + it('rejects publishing/editing a team-synced design system the caller may not manage', async () => { + const app = express(); + app.use(express.json()); + const { updateUserDesignSystem } = registerRoutes(app, async () => false); + const baseUrl = await listen(app); + + const res = await fetch(`${baseUrl}/api/design-systems/user:teammate-ds`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'published' }), + }); + + expect(res.status).toBe(403); + const body = (await res.json()) as { error?: string }; + expect(body.error).toBe('WORKSPACE_RESOURCE_MANAGE_DENIED'); + expect(updateUserDesignSystem).not.toHaveBeenCalled(); + }); + + it('rejects deleting a team-synced design system the caller may not manage', async () => { + const app = express(); + app.use(express.json()); + const { deleteUserDesignSystem } = registerRoutes(app, async () => false); + const baseUrl = await listen(app); + + const res = await fetch(`${baseUrl}/api/design-systems/user:teammate-ds`, { method: 'DELETE' }); + + expect(res.status).toBe(403); + const body = (await res.json()) as { error?: string }; + expect(body.error).toBe('WORKSPACE_RESOURCE_MANAGE_DENIED'); + expect(deleteUserDesignSystem).not.toHaveBeenCalled(); + }); + + it('still allows publishing/editing when the caller can manage the shared system (owner or workspace admin)', async () => { + const app = express(); + app.use(express.json()); + const { updateUserDesignSystem } = registerRoutes(app, async () => true); + const baseUrl = await listen(app); + + const res = await fetch(`${baseUrl}/api/design-systems/user:teammate-ds`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'published' }), + }); + + expect(res.status).toBe(200); + expect(updateUserDesignSystem).toHaveBeenCalledOnce(); + }); + + it('still allows deleting a personal (non-team-synced) design system', async () => { + const app = express(); + app.use(express.json()); + const { deleteUserDesignSystem } = registerRoutes(app, async () => true); + const baseUrl = await listen(app); + + const res = await fetch(`${baseUrl}/api/design-systems/user:mine`, { method: 'DELETE' }); + + expect(res.status).toBe(204); + expect(deleteUserDesignSystem).toHaveBeenCalledOnce(); + }); + + // recvqb6mfyqXLD: the single-item GET decorates its response with the same + // verdict, so any detail surface that reads off this endpoint (not just + // DesignSystemsTab's own separate `/team` share lookup) can gate its own + // Publish toggle / Save button / delete affordance on it. + it('decorates GET /api/design-systems/:id with canMutate=false when the caller may not manage the share', async () => { + const app = express(); + app.use(express.json()); + registerRoutes(app, async () => false); + const baseUrl = await listen(app); + + const res = await fetch(`${baseUrl}/api/design-systems/user:teammate-ds`); + + expect(res.status).toBe(200); + const body = (await res.json()) as { canMutate?: boolean; designSystem?: { canMutate?: boolean } }; + expect(body.canMutate).toBe(false); + expect(body.designSystem?.canMutate).toBe(false); + }); + + it('decorates GET /api/design-systems/:id with canMutate=true when the caller can manage the share', async () => { + const app = express(); + app.use(express.json()); + registerRoutes(app, async () => true); + const baseUrl = await listen(app); + + const res = await fetch(`${baseUrl}/api/design-systems/user:teammate-ds`); + + expect(res.status).toBe(200); + const body = (await res.json()) as { canMutate?: boolean }; + expect(body.canMutate).toBe(true); + }); +}); + +// recvqb6mfyqXLD: accepting/rejecting a design system revision commits (or +// discards) its proposed body onto the canonical design system — the same +// "edit" this route family gates everywhere else. Before this, a plain +// member viewing a teammate's team-synced design system could accept/reject +// its pending revision with no server-side check at all. +describe('design system revision accept/reject team-share mutation guard', () => { + it('rejects resolving a pending revision on a team-synced design system the caller may not manage', async () => { + const app = express(); + app.use(express.json()); + const { updateUserDesignSystemRevisionStatus } = registerRoutes(app, async () => false); + const baseUrl = await listen(app); + + const res = await fetch(`${baseUrl}/api/design-systems/user:teammate-ds/revisions/rev-1`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'accepted' }), + }); + + expect(res.status).toBe(403); + const body = (await res.json()) as { error?: string }; + expect(body.error).toBe('WORKSPACE_RESOURCE_MANAGE_DENIED'); + expect(updateUserDesignSystemRevisionStatus).not.toHaveBeenCalled(); + }); + + it('still allows resolving a pending revision when the caller can manage the shared system', async () => { + const app = express(); + app.use(express.json()); + const { updateUserDesignSystemRevisionStatus } = registerRoutes(app, async () => true); + const baseUrl = await listen(app); + + const res = await fetch(`${baseUrl}/api/design-systems/user:teammate-ds/revisions/rev-1`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'accepted' }), + }); + + expect(res.status).toBe(200); + expect(updateUserDesignSystemRevisionStatus).toHaveBeenCalledOnce(); + }); + + it('rejects resolving a revision when the caller workspace is locked, even if otherwise permitted', async () => { + const app = express(); + app.use(express.json()); + const { updateUserDesignSystemRevisionStatus } = registerRoutes(app, async () => true); + const baseUrl = await listen(app); + + const res = await fetch(`${baseUrl}/api/design-systems/user:mine/revisions/rev-1`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'x-od-workspace-id': 'ws-locked', + 'x-od-workspace-member-id': 'member-1', + 'x-od-workspace-lifecycle-state': 'locked', + }, + body: JSON.stringify({ status: 'accepted' }), + }); + + expect(res.status).toBe(403); + const body = (await res.json()) as { error?: string }; + expect(body.error).toBe('WORKSPACE_LOCKED'); + expect(updateUserDesignSystemRevisionStatus).not.toHaveBeenCalled(); + }); +}); + +describe('design system PATCH/DELETE workspace-lock guard (spec 9.2)', () => { + const lockedHeaders = { + 'x-od-workspace-id': 'ws-locked', + 'x-od-workspace-member-id': 'member-1', + 'x-od-workspace-lifecycle-state': 'locked', + }; + + it('rejects publishing/editing when the caller workspace is locked, even if otherwise permitted', async () => { + const app = express(); + app.use(express.json()); + // canMutate itself says yes — the lock gate must still win. + const { updateUserDesignSystem } = registerRoutes(app, async () => true); + const baseUrl = await listen(app); + + const res = await fetch(`${baseUrl}/api/design-systems/user:mine`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...lockedHeaders }, + body: JSON.stringify({ status: 'published' }), + }); + + expect(res.status).toBe(403); + const body = (await res.json()) as { error?: string }; + expect(body.error).toBe('WORKSPACE_LOCKED'); + expect(updateUserDesignSystem).not.toHaveBeenCalled(); + }); + + it('rejects deleting when the caller workspace is locked, even if otherwise permitted', async () => { + const app = express(); + app.use(express.json()); + const { deleteUserDesignSystem } = registerRoutes(app, async () => true); + const baseUrl = await listen(app); + + const res = await fetch(`${baseUrl}/api/design-systems/user:mine`, { + method: 'DELETE', + headers: lockedHeaders, + }); + + expect(res.status).toBe(403); + const body = (await res.json()) as { error?: string }; + expect(body.error).toBe('WORKSPACE_LOCKED'); + expect(deleteUserDesignSystem).not.toHaveBeenCalled(); + }); + + it('rejects deleting when the caller workspace is deleted', async () => { + const app = express(); + app.use(express.json()); + const { deleteUserDesignSystem } = registerRoutes(app, async () => true); + const baseUrl = await listen(app); + + const res = await fetch(`${baseUrl}/api/design-systems/user:mine`, { + method: 'DELETE', + headers: { ...lockedHeaders, 'x-od-workspace-lifecycle-state': 'deleted' }, + }); + + expect(res.status).toBe(403); + const body = (await res.json()) as { error?: string }; + expect(body.error).toBe('WORKSPACE_LOCKED'); + expect(deleteUserDesignSystem).not.toHaveBeenCalled(); + }); + + it('still allows deleting an active (unlocked) workspace resource', async () => { + const app = express(); + app.use(express.json()); + const { deleteUserDesignSystem } = registerRoutes(app, async () => true); + const baseUrl = await listen(app); + + const res = await fetch(`${baseUrl}/api/design-systems/user:mine`, { + method: 'DELETE', + headers: { ...lockedHeaders, 'x-od-workspace-lifecycle-state': 'active' }, + }); + + expect(res.status).toBe(204); + expect(deleteUserDesignSystem).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/daemon/tests/routes/project-delete-unshares-team-share.test.ts b/apps/daemon/tests/routes/project-delete-unshares-team-share.test.ts new file mode 100644 index 00000000000..3ae5395681b --- /dev/null +++ b/apps/daemon/tests/routes/project-delete-unshares-team-share.test.ts @@ -0,0 +1,484 @@ +// spec 04 §11: before this fix, `DELETE /api/projects/:id` never called +// `collabSync.requestTeamUnshare` for a team-visible project — unlike the +// `/move` route's `visibility: 'personal'` branch, which already knows how to +// take a project out of the team space via the exact same helper +// (`requestTeamVisibility` in routes/project/index.ts). `dbDeleteProject`'s +// `ON DELETE CASCADE` only ever drops the DELETER's own `workspace_projects` +// row — the resource hub's published entry (and every other member's own, +// separately-stored, already-bound row) never learned the project was gone, +// so a deleted team project kept surfacing for teammates. +// +// This spec drives the REAL `registerProjectRoutes` DELETE handler over real +// HTTP, wired to a REAL `createCollabRuntime` instance with an injected fake +// `ResourcePublishAdapter` + team-project-catalog sink that hold actual +// mutable "hub" state (a shared/unshared map), not a spy. Assertions check +// the state transition on that fake hub (published → unpublished, catalog +// entry removed) rather than "was requestTeamUnshare called" — the shallow +// mock-call assertion the bug review explicitly ruled insufficient. + +import http from 'node:http'; +import express from 'express'; +import { afterEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + closeDatabase, + deleteProject as dbDeleteProject, + ensureWorkspaceProject, + getProject, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + insertProject, + listWorkspaceProjects, + openDatabase, +} from '../../src/db.js'; +import { removeProjectDir } from '../../src/projects.js'; +import { registerProjectRoutes } from '../../src/routes/project/index.js'; +import { createCollabRuntime } from '../../src/collab/runtime.js'; +import { workspaceContextFromDirectoryItem } from '../../src/collab/vela-workspace-context.js'; +import type { ResourcePublishAdapter, ResourcePublishInput } from '../../src/collab/publish-scheduler.js'; +import type { ResourceHubPrincipal } from '../../src/collab/resource-principal.js'; + +let server: http.Server | null = null; +let tempDir: string | null = null; +let projectsDir: string | null = null; + +afterEach(async () => { + if (server) { + const toClose = server; + server = null; + await new Promise((resolve) => toClose.close(() => resolve())); + } + closeDatabase(); + if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); + if (projectsDir) fs.rmSync(projectsDir, { recursive: true, force: true }); + tempDir = null; + projectsDir = null; +}); + +const WORKSPACE_ID = 'ws-delete-unshare'; +const OWNER_MEMBER_ID = 'member-owner'; + +function sendApiError(res: any, status: number, code: string, message: string) { + return res.status(status).json({ error: { code, message } }); +} + +function ownerHeaders() { + return { + 'x-od-workspace-id': WORKSPACE_ID, + 'x-od-workspace-member-id': OWNER_MEMBER_ID, + 'x-od-workspace-role': 'owner', + }; +} + +/** In-memory stand-in for the resource hub: a mutable map keyed on + * `teamId:projectId`, plus call logs so assertions can check BOTH the real + * state transition and the exact args the route passed through. */ +function fakeHub() { + const published = new Map(); + const catalog = new Map(); + const publishCalls: Array = []; + const unpublishCalls: ResourcePublishInput[] = []; + const catalogRemoveCalls: Array<{ projectId: string; principal: ResourceHubPrincipal | null | undefined }> = []; + + const key = (projectId: string, principal?: ResourceHubPrincipal | null) => + principal ? `${principal.teamId}:${projectId}` : projectId; + + const adapter: ResourcePublishAdapter = { + async publish(input) { + publishCalls.push(input); + published.set(key(input.projectId, input.principal), { version: 1 }); + return { version: 1, versionId: 'v1' }; + }, + async unpublish(input) { + unpublishCalls.push(input); + published.delete(key(input.projectId, input.principal)); + }, + }; + + const teamProjectCatalog = { + async upsert(input: { projectId: string }, principal?: ResourceHubPrincipal | null) { + catalog.set(key(input.projectId, principal), input); + }, + async remove(projectId: string, principal?: ResourceHubPrincipal | null) { + catalogRemoveCalls.push({ projectId, principal }); + catalog.delete(key(projectId, principal)); + }, + }; + + return { adapter, teamProjectCatalog, published, catalog, publishCalls, unpublishCalls, catalogRemoveCalls, key }; +} + +async function startServer( + hub: ReturnType, +) { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-project-delete-unshare-')); + projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-project-delete-unshare-dir-')); + const db = openDatabase(tempDir); + + const collab = createCollabRuntime({ + adapter: hub.adapter, + teamProjectCatalog: hub.teamProjectCatalog, + }); + + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, { + db, + design: { runs: { list: () => [], cancel: async () => {} } }, + http: { sendApiError, createSseResponse: () => ({ send: () => {} }) }, + paths: { PROJECTS_DIR: projectsDir }, + projectStore: { + getProject, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + dbDeleteProject, + removeProjectDir, + insertProject, + ensureWorkspaceProject, + }, + projectFiles: {}, + conversations: {}, + templates: {}, + status: {}, + events: {}, + ids: { randomId: () => 'unused' }, + appConfig: {}, + agents: {}, + validation: {}, + verifyWorkspaceRequestAuthority: async (req: any) => { + const workspaceId = req.get('x-od-workspace-id'); + const memberId = req.get('x-od-workspace-member-id'); + if (!workspaceId || !memberId) { + return { + ok: false, + status: 400, + code: 'WORKSPACE_CONTEXT_REQUIRED', + message: 'an explicit workspace context is required', + }; + } + if (workspaceId !== WORKSPACE_ID || memberId !== OWNER_MEMBER_ID) { + return { + ok: false, + status: 403, + code: 'WORKSPACE_ACCESS_DENIED', + message: 'workspace access denied', + }; + } + return { + ok: true, + context: workspaceContextFromDirectoryItem({ + workspaceId, + workspaceName: workspaceId, + workspaceType: 'team', + workspaceMemberId: memberId, + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + }), + }; + }, + collabSync: { + requestTeamShare: (projectId: string, share?: string | ResourceHubPrincipal) => + collab.requestTeamShare(projectId, share), + requestTeamUnshare: (projectId: string, share?: ResourceHubPrincipal | null) => + collab.requestTeamUnshare(projectId, share), + refreshTeamProjectMetadata: (projectId: string) => collab.refreshTeamProjectMetadata(projectId), + invalidateTeamProjectCatalog: () => {}, + }, + } as any); + + const created = http.createServer(app); + server = created; + await new Promise((resolve) => created.listen(0, resolve)); + const address = created.address(); + const port = typeof address === 'object' && address ? address.port : 0; + return { baseUrl: `http://127.0.0.1:${port}`, db }; +} + +describe('DELETE /api/projects/:id unshares a team-visible project from the hub first', () => { + it('unpublishes and drops the catalog entry BEFORE the local delete, for a project the caller shared', async () => { + const hub = fakeHub(); + const { baseUrl, db } = await startServer(hub); + const now = Date.now(); + const projectId = 'p-team-shared'; + insertProject(db, { id: projectId, name: 'Team shared', createdAt: now, updatedAt: now }); + ensureWorkspaceProject(db, { + projectId, + workspaceId: WORKSPACE_ID, + visibility: 'team', + createdByWorkspaceMemberId: OWNER_MEMBER_ID, + resourceState: 'active', + syncState: 'synced', + }); + + // Seed the "hub" as ALREADY reporting the project shared — the exact + // "hub still says shared" scenario the bug review asked to simulate, + // modeling a resource published from an earlier `/move` or the initial + // share (not something this test drives through `share()` itself). + const principal: ResourceHubPrincipal = { + memberId: OWNER_MEMBER_ID, + teamId: WORKSPACE_ID, + role: 'owner', + lifecycleState: 'active', + }; + hub.published.set(hub.key(projectId, principal), { version: 1 }); + hub.catalog.set(hub.key(projectId, principal), { projectId }); + expect(hub.published.has(hub.key(projectId, principal))).toBe(true); + + const res = await fetch(`${baseUrl}/api/projects/${projectId}`, { + method: 'DELETE', + headers: ownerHeaders(), + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + + // The real hub state actually transitioned — not just "a function was + // called". This is the fix for the reported bug #1 pattern applied to + // projects: the hub no longer reports the project as shared. + expect(hub.published.has(hub.key(projectId, principal))).toBe(false); + expect(hub.catalog.has(hub.key(projectId, principal))).toBe(false); + expect(hub.unpublishCalls).toHaveLength(1); + expect(hub.unpublishCalls[0]).toMatchObject({ + projectId, + principal: { memberId: OWNER_MEMBER_ID, teamId: WORKSPACE_ID }, + }); + expect(hub.catalogRemoveCalls).toHaveLength(1); + expect(hub.catalogRemoveCalls[0]).toMatchObject({ + projectId, + principal: { memberId: OWNER_MEMBER_ID, teamId: WORKSPACE_ID }, + }); + + // AND the local delete actually proceeded (unshare-then-delete, not + // unshare-instead-of-delete). + expect(getProject(db, projectId)).toBeFalsy(); + expect(getWorkspaceProjectByProjectId(db, projectId)).toBeFalsy(); + }); + + it('does not touch the hub at all when deleting a personal (never-shared) project', async () => { + const hub = fakeHub(); + const { baseUrl, db } = await startServer(hub); + const now = Date.now(); + const projectId = 'p-personal'; + insertProject(db, { id: projectId, name: 'Personal', createdAt: now, updatedAt: now }); + ensureWorkspaceProject(db, { + projectId, + workspaceId: WORKSPACE_ID, + visibility: 'personal', + createdByWorkspaceMemberId: OWNER_MEMBER_ID, + }); + + const res = await fetch(`${baseUrl}/api/projects/${projectId}`, { + method: 'DELETE', + headers: ownerHeaders(), + }); + expect(res.status).toBe(200); + + // No unshare traffic at all for a project that was never team-visible — + // regression guard against always calling unpublish regardless of + // visibility (which `TeamResourceShareService.unshare` itself does for + // an unknown id, per the design-system module's own doc comment). + expect(hub.unpublishCalls).toHaveLength(0); + expect(hub.catalogRemoveCalls).toHaveLength(0); + expect(getProject(db, projectId)).toBeFalsy(); + }); +}); + +// spec 04 §11 explicitly parks the deeper gap — "already-bound rows are never +// re-verified against the hub" — as out of scope for this fix, but asks for +// empirical confirmation of whether "delete triggers unshare" alone is +// enough to make a MEMBER's own regular project list converge. Each daemon +// is a separate process with its OWN local SQLite `workspace_projects` +// table (see AGENTS.md's daemon data directory contract and +// `getWorkspaceProjectByProjectId`'s doc comment: "a project has exactly one +// workspace" — one row PER LOCAL DATABASE, not a shared cross-member table), +// so this models the member side as a second, independent `openDatabase` +// call with its own already-bound row for the SAME project id, exactly as it +// would look after the member previously opened/synced the team project. +// +// `GET /api/workspaces/:workspaceId/projects` (routes/project/index.ts) +// builds its response as +// `[...rows.map(normalizeWorkspaceProjectRow), ...(needsRemoteTeamProjects +// ? await listRemoteTeamProjectSummaries(rows, ctx) : [])]` +// where `rows = listWorkspaceProjects(db, ctx.workspaceId)` is read +// UNCONDITIONALLY from the LOCAL table, and `listRemoteTeamProjectSummaries` +// only ever ADDS remote entries with NO local match +// (`.filter((project) => !localResourceIds.has(project.resourceId))`) — it +// never removes an already-matched local row when the hub stops reporting +// it. `reconcileLocalRowWithRemoteTeamAccess`'s own doc comment says the same +// thing directly: it only reaches "a row that predates this project's +// current team binding", never an already-correctly-bound one. This test +// exercises that exact `listWorkspaceProjects` read (the literal query the +// route embeds verbatim) on the member's own database after the owner's +// hub-side unshare above, to confirm the row survives untouched. +describe('member-side convergence after an owner unshare (spec 04 §11, known gap — not fixed here)', () => { + it('leaves the member\'s own already-bound workspace_projects row untouched after the owner unshares from the hub', async () => { + // `openDatabase` is a module-level singleton (db.ts: opening a second + // path closes whatever is currently open) — exactly like two real + // daemons, which are separate PROCESSES with separate SQLite files, so + // "owner" and "member" cannot be two simultaneously-open connections in + // one test process either. The two phases below are run strictly in + // sequence (owner phase fully closes its db before the member phase + // opens its own), which is representative: this test isn't claiming the + // member "watches" the delete live, only that the member's own row, + // wherever/whenever it is read, is never touched by the owner's unshare. + const hub = fakeHub(); + const { baseUrl, db: ownerDb } = await startServer(hub); + const now = Date.now(); + const projectId = 'p-team-shared-member-view'; + insertProject(ownerDb, { id: projectId, name: 'Team shared', createdAt: now, updatedAt: now }); + ensureWorkspaceProject(ownerDb, { + projectId, + workspaceId: WORKSPACE_ID, + visibility: 'team', + createdByWorkspaceMemberId: OWNER_MEMBER_ID, + resourceState: 'active', + syncState: 'synced', + }); + const principal: ResourceHubPrincipal = { + memberId: OWNER_MEMBER_ID, + teamId: WORKSPACE_ID, + role: 'owner', + lifecycleState: 'active', + }; + hub.published.set(hub.key(projectId, principal), { version: 1 }); + + // Owner deletes — the SAME hub-state-transition already asserted in the + // describe block above (real unpublish, real catalog removal). + const ownerRes = await fetch(`${baseUrl}/api/projects/${projectId}`, { + method: 'DELETE', + headers: ownerHeaders(), + }); + expect(ownerRes.status).toBe(200); + expect(hub.published.has(hub.key(projectId, principal))).toBe(false); + + // Owner's server/db phase is done — close it before opening the + // member's own, separate local database (see comment above). + await new Promise((resolve) => server!.close(() => resolve())); + server = null; + closeDatabase(); + + // The MEMBER's own separate local database, already carrying a + // synced-and-bound row for the exact same project — as it would look + // after the member previously opened/synced the team project once. This + // is created AFTER the owner's hub-side unshare above has already + // completed, so the hub is provably already in the "unshared" state by + // the time the member's row is read below. + const memberTempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-project-delete-unshare-member-')); + const memberDb = openDatabase(memberTempDir); + try { + insertProject(memberDb, { id: projectId, name: 'Team shared', createdAt: now, updatedAt: now }); + ensureWorkspaceProject(memberDb, { + projectId, + workspaceId: WORKSPACE_ID, + visibility: 'team', + createdByWorkspaceMemberId: OWNER_MEMBER_ID, + resourceState: 'active', + syncState: 'synced', + }); + + // `listWorkspaceProjects` is the exact call the production list route + // embeds unconditionally (`rows = listWorkspaceProjects(db, + // ctx.workspaceId)` — see the module doc comment above), so this reads + // the real function the route calls rather than reimplementing its + // logic. The row survives untouched: nothing reconciles an + // already-bound row against the hub's now-unshared state. + const memberRows = listWorkspaceProjects(memberDb, WORKSPACE_ID); + const memberRow = memberRows.find((row: any) => row.id === projectId); + expect(memberRow).toBeTruthy(); + expect(memberRow?.workspaceVisibility).toBe('team'); + } finally { + fs.rmSync(memberTempDir, { recursive: true, force: true }); + } + }); + + // Review regression (mrcfps, #6216). Once a headerless caller was allowed to + // mutate a project the daemon's own identity owns, this handler's side effect + // still re-derived its context from REQUEST HEADERS — which are absent — so + // `requestTeamVisibility` was skipped and `dbDeleteProject` ran anyway. The + // owner's local row and directory disappeared while the hub kept serving the + // resource, so teammates went on seeing a project that no longer existed. + // + // Drives the real route with NO request headers and `workspaceContext.lastKnown()` + // populated — the `od project delete` shape on a signed-in daemon — and asserts + // the hub transition, not a spy call count. Ordering matters as much as + // occurrence: a local delete that lands while the hub call is skipped leaves + // exactly the inconsistency this guards, so the hub state is asserted to be + // clean at the same moment the project is gone locally. + it('unshares from the hub for an explicitly-scoped authoritative owner', async () => { + const hub = fakeHub(); + const { baseUrl, db } = await startServer(hub); + const now = Date.now(); + const projectId = 'p-team-shared-headerless'; + insertProject(db, { id: projectId, name: 'Team shared headerless', createdAt: now, updatedAt: now }); + ensureWorkspaceProject(db, { + projectId, + workspaceId: WORKSPACE_ID, + visibility: 'team', + createdByWorkspaceMemberId: OWNER_MEMBER_ID, + resourceState: 'active', + syncState: 'synced', + }); + + const principal: ResourceHubPrincipal = { + memberId: OWNER_MEMBER_ID, + teamId: WORKSPACE_ID, + role: 'owner', + lifecycleState: 'active', + }; + hub.published.set(hub.key(projectId, principal), { version: 1 }); + hub.catalog.set(hub.key(projectId, principal), { projectId }); + + const res = await fetch(`${baseUrl}/api/projects/${projectId}`, { + method: 'DELETE', + headers: ownerHeaders(), + }); + expect(res.status).toBe(200); + + // The hub learned about it... + expect( + hub.unpublishCalls.length, + 'an authorized delete must unpublish the shared resource', + ).toBeGreaterThan(0); + expect(hub.published.has(hub.key(projectId, principal))).toBe(false); + expect( + hub.catalogRemoveCalls.map((call) => call.projectId), + 'the catalog entry must be removed too', + ).toContain(projectId); + expect(hub.catalog.has(hub.key(projectId, principal))).toBe(false); + + // ...and the unshare was attributed to the verified explicit identity. + expect(hub.unpublishCalls[0]?.principal?.teamId).toBe(WORKSPACE_ID); + expect(hub.unpublishCalls[0]?.principal?.memberId).toBe(OWNER_MEMBER_ID); + + // Only then is it gone locally. + expect(getProject(db, projectId)).toBeFalsy(); + expect(getWorkspaceProjectByProjectId(db, projectId)).toBeFalsy(); + }); + + // The other half of the same invariant: with NO ambient identity either, the + // gate refuses before anything is destroyed. A team-bound project must never be + // deletable by a caller nothing can vouch for. + it('refuses a headerless delete of a team-bound project when the daemon has no identity', async () => { + const hub = fakeHub(); + const { baseUrl, db } = await startServer(hub); + const now = Date.now(); + const projectId = 'p-team-shared-no-identity'; + insertProject(db, { id: projectId, name: 'Team shared no identity', createdAt: now, updatedAt: now }); + ensureWorkspaceProject(db, { + projectId, + workspaceId: WORKSPACE_ID, + visibility: 'team', + createdByWorkspaceMemberId: OWNER_MEMBER_ID, + resourceState: 'active', + syncState: 'synced', + }); + + const res = await fetch(`${baseUrl}/api/projects/${projectId}`, { method: 'DELETE' }); + expect(res.status).toBe(400); + expect(hub.unpublishCalls.length).toBe(0); + expect(getProject(db, projectId), 'nothing may be destroyed locally either').toBeTruthy(); + }); +}); diff --git a/apps/daemon/tests/routes/project-move-owner-conflict.test.ts b/apps/daemon/tests/routes/project-move-owner-conflict.test.ts new file mode 100644 index 00000000000..5cb805882b7 --- /dev/null +++ b/apps/daemon/tests/routes/project-move-owner-conflict.test.ts @@ -0,0 +1,420 @@ +// Red-spec coverage for the error half of recvqzjnshIlOe: when the hub +// refuses a "move to team space" because the project is already registered +// under ANOTHER member's ownership (vela `team_project_owner_conflict`, +// surfaced through the CLI transport's stderr), the daemon must answer with +// the discriminable contract code `TEAM_PROJECT_OWNER_CONFLICT` — not the +// generic 400 BAD_REQUEST the web can only render as "try again later". +// An owner conflict is permanent until the registered owner unshares, so a +// retry hint is a lie. +import express from 'express'; +import type http from 'node:http'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerProjectRoutes } from '../../src/routes/project/index.js'; +import { + closeDatabase, + ensureWorkspaceProject, + getProject, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + insertProject, + listWorkspaceProjectBindings, + listWorkspaceProjects, + openDatabase, + rebindWorkspaceProject, + updateWorkspaceProject, +} from '../../src/db.js'; + +const TEAM_WORKSPACE_ID = 'ws-team-1'; +const READER_MEMBER_ID = 'member-reader'; + +// The real shape observed in the live incident: the vela CLI rethrows the +// hub's 403 body through execFile stderr, so the daemon-side error text +// embeds the hub's stable error token. +const OWNER_CONFLICT_ERROR = new Error( + 'Error: Command failed: vela team-projects upsert … 403: {"error":"team_project_owner_conflict"}', +); + +function readerTeamHeaders(extra: Record = {}) { + return { + 'content-type': 'application/json', + 'x-od-workspace-id': TEAM_WORKSPACE_ID, + 'x-od-workspace-member-id': READER_MEMBER_ID, + 'x-od-workspace-role': 'member', + 'x-od-workspace-type': 'team', + 'x-od-workspace-member-status': 'active', + 'x-od-workspace-lifecycle-state': 'active', + 'x-od-workspace-can-share-projects': 'true', + 'x-od-workspace-can-write-synced-files': 'true', + ...extra, + }; +} + +async function listen(app: express.Express): Promise<{ server: http.Server; url: string }> { + return new Promise((resolve) => { + const server = app.listen(0, () => { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + resolve({ server, url: `http://127.0.0.1:${port}` }); + }); + }); +} + +async function close(server: http.Server): Promise { + return new Promise((resolve) => server.close(() => resolve())); +} + +describe('project move refused by the hub with team_project_owner_conflict', () => { + let tempDir: string; + let projectsRoot: string; + let db: ReturnType; + + beforeEach(async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'od-move-owner-conflict-')); + projectsRoot = path.join(tempDir, 'projects'); + db = openDatabase(projectsRoot, { dataDir: tempDir }); + }); + + afterEach(async () => { + closeDatabase(); + await rm(tempDir, { recursive: true, force: true }); + }); + + function buildDeps(collabSync: Record) { + const noop = vi.fn(); + return { + db, + design: {}, + http: { + createSseResponse: noop, + sendApiError: (res: any, status: number, code: string, message: string) => + res.status(status).json({ error: { code, message } }), + }, + paths: { + DESIGN_SYSTEMS_DIR: '', + PROJECTS_DIR: projectsRoot, + RUNTIME_DATA_DIR: tempDir, + RUNTIME_DATA_DIR_CANONICAL: tempDir, + SKILLS_DIR: '', + BRANDS_DIR: path.join(tempDir, 'brands'), + USER_DESIGN_SYSTEMS_DIR: path.join(tempDir, 'user-design-systems'), + }, + projectStore: { + insertProject: (row: any) => insertProject(db, row), + validateLinkedDirs: () => ({ dirs: [] }), + getProject: (_db: unknown, id: string) => getProject(db, id), + updateProject: noop, + dbDeleteProject: noop, + removeProjectDir: noop, + stageProjectDirsForDelete: vi.fn(async () => ({ + rollback: vi.fn(async () => {}), + commit: vi.fn(async () => {}), + })), + deleteWorkspaceProject: noop, + countWorkspaceProjectRefs: vi.fn(() => 1), + ensureWorkspaceProject: (_db: unknown, input: any) => ensureWorkspaceProject(db, input), + getWorkspaceProject: (_db: unknown, workspaceId: string, projectId: string) => + getWorkspaceProject(db, workspaceId, projectId), + getWorkspaceProjectByProjectId: (_db: unknown, projectId: string) => + getWorkspaceProjectByProjectId(db, projectId), + listWorkspaceProjectBindings: () => listWorkspaceProjectBindings(db), + listWorkspaceProjects: (_db: unknown, workspaceId: string) => listWorkspaceProjects(db, workspaceId), + updateWorkspaceProject: (_db: unknown, workspaceId: string, projectId: string, patch: any) => + updateWorkspaceProject(db, workspaceId, projectId, patch), + rebindWorkspaceProject: (_db: unknown, projectId: string, patch: any) => + rebindWorkspaceProject(db, projectId, patch), + }, + projectFiles: { + writeProjectFile: noop, + readProjectFile: noop, + ensureProject: noop, + listFiles: () => [], + listTabs: () => [], + setTabs: noop, + resolveProjectDir: () => '', + }, + conversations: { insertConversation: noop }, + templates: { + getTemplate: noop, + listTemplates: () => [], + deleteTemplate: noop, + insertTemplate: noop, + findTemplateByNameAndProject: noop, + updateTemplate: noop, + }, + status: { + listLatestProjectRunStatuses: () => new Map(), + listProjectsAwaitingInput: () => new Set(), + normalizeProjectDisplayStatus: (status: string) => status, + composeProjectDisplayStatus: (status: unknown) => status, + listProjects: () => [], + }, + events: { subscribeFileEvents: noop, activeProjectEventSinks: new Map() }, + ids: { randomId: () => `id-${Math.random().toString(36).slice(2)}` }, + telemetry: { reportFinalizedMessage: noop }, + appConfig: { readAppConfig: vi.fn(async () => ({})), writeAppConfig: noop }, + agents: {}, + validation: { + validateProjectDesignSystemId: async () => ({ ok: true, id: null }), + validateProjectSkillId: async () => ({ ok: true, id: null }), + }, + collabSync, + } as unknown as Parameters[1]; + } + + function seedSelfDraft(projectId: string) { + insertProject(db, { + id: projectId, + name: 'Looks like my own draft', + skillId: null, + designSystemId: null, + pendingPrompt: null, + metadata: null, + customInstructions: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + ensureWorkspaceProject(db, { + projectId, + workspaceId: TEAM_WORKSPACE_ID, + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: READER_MEMBER_ID, + updatedByWorkspaceMemberId: READER_MEMBER_ID, + resourceHubResourceId: null, + cloudTombstonedAt: null, + syncState: 'local_only', + }); + } + + it('answers 409 TEAM_PROJECT_OWNER_CONFLICT (not the retryable 400 BAD_REQUEST) and restores the local row', async () => { + const projectId = 'conflicted-draft'; + seedSelfDraft(projectId); + const app = express(); + app.use(express.json()); + registerProjectRoutes( + app, + buildDeps({ + requestTeamShare: vi.fn(async () => { + throw OWNER_CONFLICT_ERROR; + }), + requestTeamUnshare: vi.fn(), + invalidateTeamProjectCatalog: vi.fn(), + }), + ); + const routeServer = await listen(app); + try { + const resp = await fetch( + `${routeServer.url}/api/workspaces/${TEAM_WORKSPACE_ID}/projects/${projectId}/move`, + { + method: 'POST', + headers: readerTeamHeaders(), + body: JSON.stringify({ visibility: 'team' }), + }, + ); + expect(resp.status).toBe(409); + const body = (await resp.json()) as { error: { code: string; message: string } }; + expect(body.error.code).toBe('TEAM_PROJECT_OWNER_CONFLICT'); + expect(body.error.message).toMatch(/team_project_owner_conflict/); + // The optimistic local flip must have been rolled back. + expect(getWorkspaceProjectByProjectId(db, projectId)).toMatchObject({ + visibility: 'personal', + syncState: 'local_only', + }); + } finally { + await close(routeServer.server); + } + }); + + it('keeps mapping unrelated share failures to the generic BAD_REQUEST', async () => { + const projectId = 'transient-failure'; + seedSelfDraft(projectId); + const app = express(); + app.use(express.json()); + registerProjectRoutes( + app, + buildDeps({ + requestTeamShare: vi.fn(async () => { + throw new Error('Error: Command failed: vela team-projects upsert … network timeout'); + }), + requestTeamUnshare: vi.fn(), + invalidateTeamProjectCatalog: vi.fn(), + }), + ); + const routeServer = await listen(app); + try { + const resp = await fetch( + `${routeServer.url}/api/workspaces/${TEAM_WORKSPACE_ID}/projects/${projectId}/move`, + { + method: 'POST', + headers: readerTeamHeaders(), + body: JSON.stringify({ visibility: 'team' }), + }, + ); + expect(resp.status).toBe(400); + const body = (await resp.json()) as { error: { code: string } }; + expect(body.error.code).toBe('BAD_REQUEST'); + } finally { + await close(routeServer.server); + } + }); +}); + +describe('owner_conflict unreachability through the normal UI path', () => { + // Product ruling on recvqzjnshIlOe: a user's own drafts can never + // legitimately collide with another member's hub registration, so the + // owner-conflict error must be unreachable through normal clicks. With the + // reconciler membership fix, a teammate's project only ever exists locally + // as a `visibility: 'team'` mirror — and for that shape the move route's + // own `canMoveToTeam` gate (requires `visibility === 'personal'`) refuses + // BEFORE any hub call. This pins that ordering: the hub transport must not + // even be consulted. + let tempDir: string; + let projectsRoot: string; + let db: ReturnType; + + beforeEach(async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'od-move-owner-unreachable-')); + projectsRoot = path.join(tempDir, 'projects'); + db = openDatabase(projectsRoot, { dataDir: tempDir }); + }); + + afterEach(async () => { + closeDatabase(); + await rm(tempDir, { recursive: true, force: true }); + }); + + it("refuses move-to-team on a teammate's team mirror at the local gate, without calling the hub", async () => { + const projectId = 'foreign-team-mirror'; + insertProject(db, { + id: projectId, + name: 'Teammate mirror', + skillId: null, + designSystemId: null, + pendingPrompt: null, + metadata: null, + customInstructions: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + // The healthy mirror shape the reconciler now preserves for a teammate's + // project (including one whose hub row is merely sync-failed). + ensureWorkspaceProject(db, { + projectId, + workspaceId: TEAM_WORKSPACE_ID, + visibility: 'team', + resourceState: 'active', + createdByWorkspaceMemberId: null, + updatedByWorkspaceMemberId: READER_MEMBER_ID, + resourceHubResourceId: `project-${projectId}`, + cloudTombstonedAt: null, + syncState: 'synced', + }); + + const requestTeamShare = vi.fn(async () => { + throw new Error('the hub must not be consulted for a refused move'); + }); + const noop = vi.fn(); + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, { + db, + design: {}, + http: { + createSseResponse: noop, + sendApiError: (res: any, status: number, code: string, message: string) => + res.status(status).json({ error: { code, message } }), + }, + paths: { + DESIGN_SYSTEMS_DIR: '', + PROJECTS_DIR: projectsRoot, + RUNTIME_DATA_DIR: tempDir, + RUNTIME_DATA_DIR_CANONICAL: tempDir, + SKILLS_DIR: '', + BRANDS_DIR: path.join(tempDir, 'brands'), + USER_DESIGN_SYSTEMS_DIR: path.join(tempDir, 'user-design-systems'), + }, + projectStore: { + insertProject: (row: any) => insertProject(db, row), + validateLinkedDirs: () => ({ dirs: [] }), + getProject: (_db: unknown, id: string) => getProject(db, id), + updateProject: noop, + dbDeleteProject: noop, + removeProjectDir: noop, + stageProjectDirsForDelete: vi.fn(async () => ({ + rollback: vi.fn(async () => {}), + commit: vi.fn(async () => {}), + })), + deleteWorkspaceProject: noop, + countWorkspaceProjectRefs: vi.fn(() => 1), + ensureWorkspaceProject: (_db: unknown, input: any) => ensureWorkspaceProject(db, input), + getWorkspaceProject: (_db: unknown, workspaceId: string, projectId: string) => + getWorkspaceProject(db, workspaceId, projectId), + getWorkspaceProjectByProjectId: (_db: unknown, projectId: string) => + getWorkspaceProjectByProjectId(db, projectId), + listWorkspaceProjectBindings: () => listWorkspaceProjectBindings(db), + listWorkspaceProjects: (_db: unknown, workspaceId: string) => listWorkspaceProjects(db, workspaceId), + updateWorkspaceProject: (_db: unknown, workspaceId: string, projectId: string, patch: any) => + updateWorkspaceProject(db, workspaceId, projectId, patch), + rebindWorkspaceProject: (_db: unknown, projectId: string, patch: any) => + rebindWorkspaceProject(db, projectId, patch), + }, + projectFiles: { + writeProjectFile: noop, + readProjectFile: noop, + ensureProject: noop, + listFiles: () => [], + listTabs: () => [], + setTabs: noop, + resolveProjectDir: () => '', + }, + conversations: { insertConversation: noop }, + templates: { + getTemplate: noop, + listTemplates: () => [], + deleteTemplate: noop, + insertTemplate: noop, + findTemplateByNameAndProject: noop, + updateTemplate: noop, + }, + status: { + listLatestProjectRunStatuses: () => new Map(), + listProjectsAwaitingInput: () => new Set(), + normalizeProjectDisplayStatus: (status: string) => status, + composeProjectDisplayStatus: (status: unknown) => status, + listProjects: () => [], + }, + events: { subscribeFileEvents: noop, activeProjectEventSinks: new Map() }, + ids: { randomId: () => `id-${Math.random().toString(36).slice(2)}` }, + telemetry: { reportFinalizedMessage: noop }, + appConfig: { readAppConfig: vi.fn(async () => ({})), writeAppConfig: noop }, + agents: {}, + validation: { + validateProjectDesignSystemId: async () => ({ ok: true, id: null }), + validateProjectSkillId: async () => ({ ok: true, id: null }), + }, + collabSync: { requestTeamShare, requestTeamUnshare: noop, invalidateTeamProjectCatalog: noop }, + } as unknown as Parameters[1]); + + const routeServer = await listen(app); + try { + const resp = await fetch( + `${routeServer.url}/api/workspaces/${TEAM_WORKSPACE_ID}/projects/${projectId}/move`, + { + method: 'POST', + headers: readerTeamHeaders(), + body: JSON.stringify({ visibility: 'team' }), + }, + ); + expect(resp.status).toBe(403); + const body = (await resp.json()) as { error: { code: string } }; + expect(body.error.code).toBe('PROJECT_DELETE_FORBIDDEN'); + expect(requestTeamShare).not.toHaveBeenCalled(); + } finally { + await close(routeServer.server); + } + }); +}); diff --git a/apps/daemon/tests/routes/project-move-to-personal.test.ts b/apps/daemon/tests/routes/project-move-to-personal.test.ts new file mode 100644 index 00000000000..67544bf254c --- /dev/null +++ b/apps/daemon/tests/routes/project-move-to-personal.test.ts @@ -0,0 +1,813 @@ +// Regression coverage for recvqfNnRETNtM ("提取的设计系统,移回仅自己可见失败") and +// recvqgejeqK2OJ ("移动到团队空间后,没有办法移回了"). +// +// Root cause: `/api/workspaces/:workspaceId/projects/:projectId/move` binds a +// project with NO existing `workspace_projects` row via +// `ensureWorkspaceProjection(project, ctx, 'personal')`, hard-coding +// `visibility: 'personal'` regardless of what the caller actually requested. +// `canMoveToPersonal` then requires the row to ALREADY be `visibility: 'team'` +// — a requirement the route itself just made impossible to satisfy — so the +// very first "move to personal" ever attempted on an unbound project always +// 403s with `PROJECT_DELETE_FORBIDDEN`, no matter how privileged the caller +// is. See `reconcileUnboundProjectBeforeMove` in +// `apps/daemon/src/routes/project/index.ts` for the fix and its full +// reasoning. +// +// Two real, independently-producible sources of an unbound project are +// exercised here, matching the two Feishu reports: +// 1. `startBrandExtraction` (the real "extract design system" backing +// pipeline, `apps/daemon/src/brands/index.ts`) never calls +// `ensureWorkspaceProject` for the project it creates — confirmed by +// calling the real function and asserting the row is absent, not by +// hand-writing a database row. +// 2. `POST /api/projects` with no workspace headers (the same "legacy / +// orphan project" shape the rest of this test directory already relies +// on) — the ordinary-project case behind recvqgejeqK2OJ. +import express from 'express'; +import type http from 'node:http'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerProjectRoutes } from '../../src/routes/project/index.js'; +import { registerCollabContextRoutes } from '../../src/routes/collab-context.js'; +import { startBrandExtraction } from '../../src/brands/index.js'; +import { + closeDatabase, + deleteWorkspaceProject, + ensureWorkspaceProject, + getProject, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + insertProject, + listWorkspaceProjectBindings, + listWorkspaceProjects, + openDatabase, + rebindWorkspaceProject, + updateWorkspaceProject, +} from '../../src/db.js'; + +// Real repo skills root so the bundled brand-kit template resolves, exactly +// like brand-extraction-engine.test.ts. +const SKILLS_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../skills'); + +const NO_LOGO_FALLBACK = async () => ({ changed: false }); +const NO_IMAGERY_FALLBACK = async () => ({ changed: false }); +const NO_SEED_FALLBACK = async () => ({ changed: false }); + +// The reporter's real ids from recvqfNnRETNtM, kept verbatim. +const TEAM_WORKSPACE_ID = 'i05lx8ufnrrhloo8qxrpetwt'; +const OWNER_MEMBER_ID = 'eu7o99459dcojtv2osoprr5k'; + +const DESIGN_MD_INPUT = `--- +name: Heritage +colors: + primary: "#1A1C1E" + secondary: "#6C7278" +--- + +# Heritage + +## Overview +A minimal reference brand used to drive brand extraction fully offline. +`; + +function ownerTeamHeaders(extra: Record = {}) { + // Mirrors the real curl from recvqfNnRETNtM byte-for-byte where it matters: + // owner role, active member, every permission bit granted. + return { + 'content-type': 'application/json', + 'x-od-workspace-id': TEAM_WORKSPACE_ID, + 'x-od-workspace-member-id': OWNER_MEMBER_ID, + 'x-od-workspace-role': 'owner', + 'x-od-workspace-type': 'team', + 'x-od-workspace-member-status': 'active', + 'x-od-workspace-lifecycle-state': 'active', + 'x-od-workspace-can-share-projects': 'true', + 'x-od-workspace-can-write-synced-files': 'true', + ...extra, + }; +} + +function teamHeaders(input: { + memberId: string; + role: 'owner' | 'admin' | 'member'; +}) { + return ownerTeamHeaders({ + 'x-od-workspace-member-id': input.memberId, + 'x-od-workspace-role': input.role, + }); +} + +async function listen(app: express.Express): Promise<{ server: http.Server; url: string }> { + return new Promise((resolve) => { + const server = app.listen(0, () => { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + resolve({ server, url: `http://127.0.0.1:${port}` }); + }); + }); +} + +async function close(server: http.Server): Promise { + return new Promise((resolve) => server.close(() => resolve())); +} + +describe('project move to personal on an unbound (never-locally-shared) project', () => { + let tempDir: string; + let projectsRoot: string; + let brandsRoot: string; + let userDesignSystemsRoot: string; + let db: ReturnType; + + beforeEach(async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'od-move-to-personal-')); + projectsRoot = path.join(tempDir, 'projects'); + brandsRoot = path.join(tempDir, 'brands'); + userDesignSystemsRoot = path.join(tempDir, 'user-design-systems'); + db = openDatabase(projectsRoot, { dataDir: tempDir }); + }); + + afterEach(async () => { + closeDatabase(); + await rm(tempDir, { recursive: true, force: true }); + }); + + // Real deps object for `registerProjectRoutes`, wired to the SAME real + // sqlite db the brand extraction call above writes into — every + // `projectStore` function is the genuine `db.ts` implementation, not a + // fixed-snapshot stub, so the route exercises real read-modify-write + // behavior end to end. + function buildDeps(overrides: { + teamProjectCatalog?: unknown; + collabSync?: Record; + } = {}) { + const noop = vi.fn(); + return { + db, + design: {}, + http: { + createSseResponse: noop, + sendApiError: (res: any, status: number, code: string, message: string) => + res.status(status).json({ error: { code, message } }), + }, + paths: { + DESIGN_SYSTEMS_DIR: '', + PROJECTS_DIR: projectsRoot, + SKILLS_DIR: '', + BRANDS_DIR: brandsRoot, + USER_DESIGN_SYSTEMS_DIR: userDesignSystemsRoot, + }, + projectStore: { + insertProject: (row: any) => insertProject(db, row), + validateLinkedDirs: () => ({ dirs: [] }), + getProject: (_db: unknown, id: string) => getProject(db, id), + updateProject: noop, + dbDeleteProject: noop, + removeProjectDir: noop, + stageProjectDirsForDelete: vi.fn(async () => ({ + rollback: vi.fn(async () => {}), + commit: vi.fn(async () => {}), + })), + deleteWorkspaceProject: (_db: unknown, workspaceId: string, projectId: string) => + deleteWorkspaceProject(db, workspaceId, projectId), + countWorkspaceProjectRefs: vi.fn(() => 1), + ensureWorkspaceProject: (_db: unknown, input: any) => ensureWorkspaceProject(db, input), + getWorkspaceProject: (_db: unknown, workspaceId: string, projectId: string) => + getWorkspaceProject(db, workspaceId, projectId), + getWorkspaceProjectByProjectId: (_db: unknown, projectId: string) => + getWorkspaceProjectByProjectId(db, projectId), + listWorkspaceProjectBindings: () => listWorkspaceProjectBindings(db), + listWorkspaceProjects: (_db: unknown, workspaceId: string) => listWorkspaceProjects(db, workspaceId), + updateWorkspaceProject: (_db: unknown, workspaceId: string, projectId: string, patch: any) => + updateWorkspaceProject(db, workspaceId, projectId, patch), + rebindWorkspaceProject: (_db: unknown, projectId: string, patch: any) => + rebindWorkspaceProject(db, projectId, patch), + }, + projectFiles: { + writeProjectFile: noop, + readProjectFile: noop, + ensureProject: noop, + listFiles: () => [], + listTabs: () => [], + setTabs: noop, + resolveProjectDir: () => '', + }, + conversations: { insertConversation: noop }, + templates: { + getTemplate: noop, + listTemplates: () => [], + deleteTemplate: noop, + insertTemplate: noop, + findTemplateByNameAndProject: noop, + updateTemplate: noop, + }, + status: { + listLatestProjectRunStatuses: () => new Map(), + listProjectsAwaitingInput: () => new Set(), + normalizeProjectDisplayStatus: (status: string) => status, + composeProjectDisplayStatus: (status: unknown) => status, + listProjects: () => [], + }, + events: { subscribeFileEvents: noop, activeProjectEventSinks: new Map() }, + ids: { randomId: () => `id-${Math.random().toString(36).slice(2)}` }, + telemetry: { reportFinalizedMessage: noop }, + appConfig: { readAppConfig: vi.fn(async () => ({})), writeAppConfig: noop }, + agents: {}, + validation: { + validateProjectDesignSystemId: async () => ({ ok: true, id: null }), + validateProjectSkillId: async () => ({ ok: true, id: null }), + }, + collabSync: overrides.collabSync ?? { + requestTeamShare: noop, + requestTeamUnshare: noop, + invalidateTeamProjectCatalog: noop, + }, + teamProjectCatalog: overrides.teamProjectCatalog, + } as unknown as Parameters[1]; + } + + it('never locally binds the backing project a real design-system extraction creates', async () => { + // Real production call, no hand-written database rows: this is exactly + // `POST /api/brands` → `startBrandExtraction` (apps/daemon/src/brand-routes.ts). + // Deliberately omits `userDesignSystemsRoot` so the pipeline takes its + // synchronous, non-programmatic path (no backgrounded network-touching + // work left dangling past this call) — the exact line under test + // (brands/index.ts's unconditional `insertProject` with no workspace + // binding) runs on BOTH paths, so this stays a faithful real-code repro + // of the root cause without the flakiness of a real font/network fetch. + const result = await startBrandExtraction({ + designMd: DESIGN_MD_INPUT, + brandsRoot, + projectsRoot, + skillsRoot: SKILLS_ROOT, + db, + logoFallback: NO_LOGO_FALLBACK, + imageryFallback: NO_IMAGERY_FALLBACK, + seedFallback: NO_SEED_FALLBACK, + }); + + expect(getProject(db, result.projectId)).toBeTruthy(); + // The actual bug precondition, produced by real code: the backing + // project has no `workspace_projects` row at all. + expect(getWorkspaceProjectByProjectId(db, result.projectId)).toBeUndefined(); + }); + + it('closes the causal gap: the real endpoint the web client reads for "is this shared" reports the unbound project as shared', async () => { + // Sharpest objection to this whole diagnosis: if the project's local + // workspace_projects row genuinely never existed, why would the web + // client ever have shown it as team-shared in the first place (the + // precondition for a user to see, and click, "move out of team")? + // + // Verified answer, from the REAL client + REAL daemon code (not + // assumption): the web client does NOT read `workspace_projects` to + // decide this. Both real UI surfaces that gate the "move to personal" + // affordance — + // - `RecentProjectsStrip.tsx`'s "共享" badge / "移出团队" menu item, via + // `createSharedProjectPredicate({ teamProjects })` + // (apps/web/src/collab/all-projects-list.ts) + // - `FileWorkspace.tsx`'s in-project Share toggle, via + // `projectIsSharedWithWorkspace(projectId)`, which falls back to the + // exact same source + // both resolve `teamProjects` from `GET /api/workspace/projects/team` + // (apps/daemon/src/routes/collab-context.ts), which is backed by the + // resource hub's OWN team-project catalog — an external system this + // daemon's local sqlite does not control and is not the same store as + // `workspace_projects`. `apps/daemon/src/collab/team-projects.ts` + // confirms it: `createTeamProjectsLister` calls + // `teamProjectCatalog.list()` directly, the identical client instance + // (`velaCliTeamProjectCatalog`, wired in server.ts) `/move`'s own + // `teamProjectCatalog` reconciliation reads. + // + // So: whenever the hub genuinely lists a project (for whatever external + // reason — this codebase's own brand-extraction pipeline never + // registers one, so that registration is not something this repo's code + // performs; it is either the hub's own cross-referencing of the + // extraction's linked, team-claimed design system, or a share taken from + // a different device/session), a user opening this project SEES it as + // team-shared and "move to personal" is a live, meaningful action — with + // or without a matching LOCAL `workspace_projects` row. This test proves + // that half of the chain with the real route, not a guess: inject the + // exact same hub record `/move`'s tests use directly into + // `registerCollabContextRoutes`'s `listTeamProjects` seam (the + // documented test injection point for this exact external dependency) + // and confirm the endpoint the client actually calls echoes it back. + const result = await startBrandExtraction({ + designMd: DESIGN_MD_INPUT, + brandsRoot, + projectsRoot, + skillsRoot: SKILLS_ROOT, + db, + logoFallback: NO_LOGO_FALLBACK, + imageryFallback: NO_IMAGERY_FALLBACK, + seedFallback: NO_SEED_FALLBACK, + }); + expect(getWorkspaceProjectByProjectId(db, result.projectId)).toBeUndefined(); + + const app = express(); + app.use(express.json()); + registerCollabContextRoutes(app, { + workspaceContext: { current: async () => null }, + fetchWorkspaceDirectory: async () => ({ + ok: true, + items: [ + { + workspaceId: TEAM_WORKSPACE_ID, + workspaceMemberId: OWNER_MEMBER_ID, + workspaceType: 'team', + workspaceName: 'Heritage Team', + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + resourceTeamId: TEAM_WORKSPACE_ID, + permissions: { + canManageBilling: true, + canManageMembers: true, + canInviteMembers: true, + canShareProjects: true, + canWriteSyncedFiles: true, + }, + }, + ], + }), + listTeamProjects: async () => [ + { + projectId: result.projectId, + ownerMemberId: 'some-other-member-id', + sharedAt: new Date(10).toISOString(), + name: 'Heritage Design System', + }, + ], + }); + const routeServer = await listen(app); + try { + const resp = await fetch(`${routeServer.url}/api/workspace/projects/team`, { + headers: ownerTeamHeaders(), + }); + expect(resp.status).toBe(200); + const body = (await resp.json()) as { projects: Array<{ projectId: string }> }; + expect(body.projects.some((project) => project.projectId === result.projectId)).toBe(true); + } finally { + await close(routeServer.server); + } + }); + + it.each(['owner', 'admin'] as const)( + 'lets a Workspace %s use the one-request catalog witness to recover a real unbound extraction project (recvqfNnRETNtM)', + async (role) => { + const result = await startBrandExtraction({ + designMd: DESIGN_MD_INPUT, + brandsRoot, + projectsRoot, + skillsRoot: SKILLS_ROOT, + db, + logoFallback: NO_LOGO_FALLBACK, + imageryFallback: NO_IMAGERY_FALLBACK, + seedFallback: NO_SEED_FALLBACK, + }); + // Precondition, from real code: still unbound. + expect(getWorkspaceProjectByProjectId(db, result.projectId)).toBeUndefined(); + + const resourceId = `project-${result.projectId}`; + const teamProjectCatalog = { + list: vi.fn(async () => [ + { + id: `catalog-${result.projectId}`, + workspaceId: TEAM_WORKSPACE_ID, + projectId: result.projectId, + resourceId, + // The hub never learned an owner for this project either — nothing + // in the brand-extraction pipeline ever registers one. The fix must + // NOT require this to equal the caller's own member id. + ownerMemberId: 'some-other-member-id', + displayName: 'Heritage Design System', + syncState: 'synced', + lastSyncedVersionId: 'v1', + createdAt: new Date(10).toISOString(), + updatedAt: new Date(20).toISOString(), + access: { canView: true, canComment: true, canEdit: true, frozen: false }, + }, + ]), + upsert: vi.fn(), + }; + + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, buildDeps({ teamProjectCatalog })); + const routeServer = await listen(app); + try { + const resp = await fetch( + `${routeServer.url}/api/workspaces/${TEAM_WORKSPACE_ID}/projects/${result.projectId}/move`, + { + method: 'POST', + headers: teamHeaders({ memberId: OWNER_MEMBER_ID, role }), + body: JSON.stringify({ visibility: 'personal' }), + }, + ); + const body = await resp.json() as any; + expect(resp.status, `expected 200, got ${resp.status}: ${JSON.stringify(body)}`).toBe(200); + expect(body.project).toMatchObject({ + id: result.projectId, + visibility: 'personal', + syncState: 'local_only', + resourceHubResourceId: null, + }); + + const row = getWorkspaceProjectByProjectId(db, result.projectId); + expect(row).toMatchObject({ workspaceId: TEAM_WORKSPACE_ID, visibility: 'personal' }); + } finally { + await close(routeServer.server); + } + }, + ); + + it('removes a privileged orphan recovery binding after unshare fails so owner/admin can retry', async () => { + const projectId = `retryable-orphan-${Date.now()}`; + insertProject(db, { + id: projectId, + name: 'Retryable privileged orphan', + skillId: null, + designSystemId: null, + pendingPrompt: null, + metadata: null, + customInstructions: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + expect(getWorkspaceProjectByProjectId(db, projectId)).toBeUndefined(); + + const teamProjectCatalog = { + list: vi.fn(async () => [ + { + id: `catalog-${projectId}`, + workspaceId: TEAM_WORKSPACE_ID, + projectId, + resourceId: `project-${projectId}`, + ownerMemberId: 'historical-project-creator', + displayName: 'Retryable privileged orphan', + syncState: 'synced', + lastSyncedVersionId: 'v1', + createdAt: new Date(10).toISOString(), + updatedAt: new Date(20).toISOString(), + access: { canView: true, canComment: true, canEdit: true, frozen: false }, + }, + ]), + upsert: vi.fn(), + }; + const requestTeamUnshare = vi.fn() + .mockRejectedValueOnce(new Error('temporary resource hub failure')) + .mockResolvedValueOnce(undefined); + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, buildDeps({ + teamProjectCatalog, + collabSync: { + requestTeamShare: vi.fn(), + requestTeamUnshare, + invalidateTeamProjectCatalog: vi.fn(), + }, + })); + const routeServer = await listen(app); + try { + const move = () => fetch( + `${routeServer.url}/api/workspaces/${TEAM_WORKSPACE_ID}/projects/${projectId}/move`, + { + method: 'POST', + headers: teamHeaders({ memberId: OWNER_MEMBER_ID, role: 'admin' }), + body: JSON.stringify({ visibility: 'personal' }), + }, + ); + + const failed = await move(); + expect(failed.status).toBe(400); + expect(getWorkspaceProjectByProjectId(db, projectId)).toBeUndefined(); + + const retried = await move(); + expect(retried.status).toBe(200); + expect(requestTeamUnshare).toHaveBeenCalledTimes(2); + expect(getWorkspaceProjectByProjectId(db, projectId)).toMatchObject({ + workspaceId: TEAM_WORKSPACE_ID, + visibility: 'personal', + }); + } finally { + await close(routeServer.server); + } + }); + + it('403s PROJECT_DELETE_FORBIDDEN for the same orphan project when the fix is bypassed (documents the pre-fix failure)', async () => { + // Same setup as above, but WITHOUT a teamProjectCatalog wired up — the + // exact condition `reconcileUnboundProjectBeforeMove` early-returns on + // (`if (!teamProjectCatalog) return;`). This is what production looked + // like before the fix for every caller, and is still the real, correct + // behavior today when the hub genuinely has no opinion (e.g. catalog + // unconfigured) — the code must fall back to reporting "not currently + // team" rather than guessing. + const result = await startBrandExtraction({ + designMd: DESIGN_MD_INPUT, + brandsRoot, + projectsRoot, + skillsRoot: SKILLS_ROOT, + db, + logoFallback: NO_LOGO_FALLBACK, + imageryFallback: NO_IMAGERY_FALLBACK, + seedFallback: NO_SEED_FALLBACK, + }); + + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, buildDeps({ teamProjectCatalog: undefined })); + const routeServer = await listen(app); + try { + const resp = await fetch( + `${routeServer.url}/api/workspaces/${TEAM_WORKSPACE_ID}/projects/${result.projectId}/move`, + { + method: 'POST', + headers: ownerTeamHeaders(), + body: JSON.stringify({ visibility: 'personal' }), + }, + ); + expect(resp.status).toBe(403); + const body = await resp.json() as any; + expect(body.error.code).toBe('PROJECT_DELETE_FORBIDDEN'); + } finally { + await close(routeServer.server); + } + }); + + it('moves an ordinary orphaned project back to personal once the team hub confirms it is shared (recvqgejeqK2OJ)', async () => { + // No brand/design-system involved at all — a plain project created with + // no workspace headers (`POST /api/projects` without headers), the same + // orphan shape the rest of this directory's suite already relies on + // (see "projects legacy rows into a workspace list ..." above). + const projectId = `plain-clone-${Date.now()}`; + insertProject(db, { + id: projectId, + name: 'Cloned website', + skillId: null, + designSystemId: null, + pendingPrompt: null, + metadata: null, + customInstructions: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + expect(getWorkspaceProjectByProjectId(db, projectId)).toBeUndefined(); + + const resourceId = `project-${projectId}`; + const teamProjectCatalog = { + list: vi.fn(async () => [ + { + id: `catalog-${projectId}`, + workspaceId: TEAM_WORKSPACE_ID, + projectId, + resourceId, + ownerMemberId: OWNER_MEMBER_ID, + displayName: 'Cloned website', + syncState: 'synced', + lastSyncedVersionId: 'v1', + createdAt: new Date(10).toISOString(), + updatedAt: new Date(20).toISOString(), + access: { canView: true, canComment: true, canEdit: true, frozen: false }, + }, + ]), + upsert: vi.fn(), + }; + + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, buildDeps({ teamProjectCatalog })); + const routeServer = await listen(app); + try { + const resp = await fetch( + `${routeServer.url}/api/workspaces/${TEAM_WORKSPACE_ID}/projects/${projectId}/move`, + { + method: 'POST', + headers: ownerTeamHeaders(), + body: JSON.stringify({ visibility: 'personal' }), + }, + ); + const body = await resp.json() as any; + expect(resp.status, `expected 200, got ${resp.status}: ${JSON.stringify(body)}`).toBe(200); + expect(body.project).toMatchObject({ id: projectId, visibility: 'personal' }); + } finally { + await close(routeServer.server); + } + }); + + it('does not let a non-creator member consume the catalog recovery witness', async () => { + const projectId = `orphan-non-creator-${Date.now()}`; + insertProject(db, { + id: projectId, + name: 'Orphan owned by another member', + skillId: null, + designSystemId: null, + pendingPrompt: null, + metadata: null, + customInstructions: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + const teamProjectCatalog = { + list: vi.fn(async () => [ + { + id: `catalog-${projectId}`, + workspaceId: TEAM_WORKSPACE_ID, + projectId, + resourceId: `project-${projectId}`, + ownerMemberId: 'actual-project-creator', + displayName: 'Orphan owned by another member', + syncState: 'synced', + lastSyncedVersionId: 'v1', + createdAt: new Date(10).toISOString(), + updatedAt: new Date(20).toISOString(), + access: { canView: true, canComment: true, canEdit: false, frozen: false }, + }, + ]), + upsert: vi.fn(), + }; + const requestTeamUnshare = vi.fn(); + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, buildDeps({ + teamProjectCatalog, + collabSync: { + requestTeamShare: vi.fn(), + requestTeamUnshare, + invalidateTeamProjectCatalog: vi.fn(), + }, + })); + const routeServer = await listen(app); + try { + const response = await fetch( + `${routeServer.url}/api/workspaces/${TEAM_WORKSPACE_ID}/projects/${projectId}/move`, + { + method: 'POST', + headers: teamHeaders({ memberId: 'another-member', role: 'member' }), + body: JSON.stringify({ visibility: 'personal' }), + }, + ); + + expect(response.status).toBe(403); + expect(requestTeamUnshare).not.toHaveBeenCalled(); + // A rejected reader must not consume the orphan state by leaving a + // sticky binding that prevents a later owner/admin recovery request. + expect(getWorkspaceProjectByProjectId(db, projectId)).toBeUndefined(); + } finally { + await close(routeServer.server); + } + }); + + it('keeps an ordinary bound Team project creator-only for move-to-personal', async () => { + const projectId = `bound-team-project-${Date.now()}`; + insertProject(db, { + id: projectId, + name: 'Ordinary shared project', + skillId: null, + designSystemId: null, + pendingPrompt: null, + metadata: null, + customInstructions: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + ensureWorkspaceProject(db, { + projectId, + workspaceId: TEAM_WORKSPACE_ID, + visibility: 'team', + resourceState: 'active', + createdByWorkspaceMemberId: 'project-creator', + updatedByWorkspaceMemberId: 'project-creator', + resourceHubResourceId: `project-${projectId}`, + cloudTombstonedAt: null, + syncState: 'synced', + }); + const teamProjectCatalog = { list: vi.fn(async () => []), upsert: vi.fn() }; + const requestTeamUnshare = vi.fn(async () => undefined); + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, buildDeps({ + teamProjectCatalog, + collabSync: { + requestTeamShare: vi.fn(), + requestTeamUnshare, + invalidateTeamProjectCatalog: vi.fn(), + }, + })); + const routeServer = await listen(app); + try { + for (const role of ['owner', 'admin'] as const) { + const denied = await fetch( + `${routeServer.url}/api/workspaces/${TEAM_WORKSPACE_ID}/projects/${projectId}/move`, + { + method: 'POST', + headers: teamHeaders({ memberId: `${role}-non-creator`, role }), + body: JSON.stringify({ visibility: 'personal' }), + }, + ); + expect(denied.status).toBe(403); + } + + const creator = await fetch( + `${routeServer.url}/api/workspaces/${TEAM_WORKSPACE_ID}/projects/${projectId}/move`, + { + method: 'POST', + headers: teamHeaders({ memberId: 'project-creator', role: 'member' }), + body: JSON.stringify({ visibility: 'personal' }), + }, + ); + expect(creator.status).toBe(200); + expect(requestTeamUnshare).toHaveBeenCalledTimes(1); + expect(teamProjectCatalog.list).not.toHaveBeenCalled(); + } finally { + await close(routeServer.server); + } + }); + + it('still 403s move-to-personal for a project the hub has never heard of (no over-grant)', async () => { + // Guards against the fix over-relaxing the check: a project that really + // is only ever personal (never shared anywhere) must keep 403ing a + // redundant "move to personal" — this is existing, correct behavior + // (see "projects legacy rows for batch operations..." in + // workspace-projects.test.ts) and must not regress. + const projectId = `never-shared-${Date.now()}`; + insertProject(db, { + id: projectId, + name: 'Never shared', + skillId: null, + designSystemId: null, + pendingPrompt: null, + metadata: null, + customInstructions: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + const teamProjectCatalog = { list: vi.fn(async () => []), upsert: vi.fn() }; + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, buildDeps({ teamProjectCatalog })); + const routeServer = await listen(app); + try { + const resp = await fetch( + `${routeServer.url}/api/workspaces/${TEAM_WORKSPACE_ID}/projects/${projectId}/move`, + { + method: 'POST', + headers: ownerTeamHeaders(), + body: JSON.stringify({ visibility: 'personal' }), + }, + ); + expect(resp.status).toBe(403); + const body = await resp.json() as any; + expect(body.error.code).toBe('PROJECT_DELETE_FORBIDDEN'); + expect(teamProjectCatalog.list).toHaveBeenCalled(); + + // And the row this defaulted to stays a normal, genuinely personal + // local draft — not silently promoted to team. + const row = getWorkspaceProjectByProjectId(db, projectId); + expect(row).toMatchObject({ visibility: 'personal' }); + } finally { + await close(routeServer.server); + } + }); + + it('does not touch the move-to-team direction for an unbound project (canMoveToTeam unaffected)', async () => { + const projectId = `fresh-orphan-${Date.now()}`; + insertProject(db, { + id: projectId, + name: 'Fresh orphan', + skillId: null, + designSystemId: null, + pendingPrompt: null, + metadata: null, + customInstructions: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + const teamProjectCatalog = { list: vi.fn(async () => []), upsert: vi.fn() }; + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, buildDeps({ teamProjectCatalog })); + const routeServer = await listen(app); + try { + const resp = await fetch( + `${routeServer.url}/api/workspaces/${TEAM_WORKSPACE_ID}/projects/${projectId}/move`, + { + method: 'POST', + headers: ownerTeamHeaders(), + body: JSON.stringify({ visibility: 'team' }), + }, + ); + const body = await resp.json() as any; + expect(resp.status, `expected 200, got ${resp.status}: ${JSON.stringify(body)}`).toBe(200); + expect(body.project).toMatchObject({ id: projectId, visibility: 'team' }); + // The reconciliation guard is scoped to the 'personal' direction only — + // it must not even consult the catalog for a 'team' request. + expect(teamProjectCatalog.list).not.toHaveBeenCalled(); + } finally { + await close(routeServer.server); + } + }); +}); diff --git a/apps/daemon/tests/routes/projects.test.ts b/apps/daemon/tests/routes/projects.test.ts index ece3e60fb42..61c468b903a 100644 --- a/apps/daemon/tests/routes/projects.test.ts +++ b/apps/daemon/tests/routes/projects.test.ts @@ -428,17 +428,28 @@ describe('GET /api/projects/:id resolvedDir', () => { const writeResp = await fetch(`${baseUrl}/api/projects/${projectId}/files`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'nested/demo/index.html', content: '

nested ok

' }), + body: JSON.stringify({ + name: 'nested/demo/index.html', + content: '

nested ok

', + }), }); expect(writeResp.status).toBe(200); - const rawResp = await fetch(`${baseUrl}/api/projects/${projectId}/raw/nested/demo/index.html`, { + const rawResp = await fetch( + `${baseUrl}/api/projects/${projectId}/raw/nested/demo/index.html?workspaceId=ws-cover&workspaceMemberId=wm-cover`, + { headers: { Origin: 'null' }, - }); + }, + ); expect(rawResp.status).toBe(200); expect(rawResp.headers.get('content-type')).toContain('text/html'); expect(rawResp.headers.get('access-control-allow-origin')).toBe('*'); - expect(await rawResp.text()).toContain('

nested ok

'); + const html = await rawResp.text(); + expect(html).toContain('

nested ok

'); + expect(html).toContain( + `/api/projects/${projectId}/raw/fonts/inter.woff2?workspaceId=ws-cover&workspaceMemberId=wm-cover`, + ); + expect(html).not.toContain('../../fonts/inter.woff2'); }); it('rejects non-boolean skipDiscoveryBrief on POST /api/projects', async () => { const projectId = `proj-skip-discovery-bad-${Date.now()}`; diff --git a/apps/daemon/tests/routes/workspace-projects.test.ts b/apps/daemon/tests/routes/workspace-projects.test.ts new file mode 100644 index 00000000000..8c01ee55931 --- /dev/null +++ b/apps/daemon/tests/routes/workspace-projects.test.ts @@ -0,0 +1,2696 @@ +import express from 'express'; +import type http from 'node:http'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { startServer } from '../../src/server.js'; +import { registerProjectRoutes } from '../../src/routes/project/index.js'; +import { projectResourceIdFor } from '../../src/integrations/vela-team-projects.js'; +import type { WorkspaceDirectoryFetchResult } from '../../src/collab/vela-workspace-context.js'; +import { recoverPersistedTeamShareOwnership } from '../../src/collab/persisted-team-share.js'; + +describe('workspace project routes', () => { + let server: http.Server; + let baseUrl: string; + + beforeAll(async () => { + const started = (await startServer({ port: 0, returnServer: true })) as { + url: string; + server: http.Server; + }; + baseUrl = started.url; + server = started.server; + }); + + afterAll(() => new Promise((resolve) => server.close(() => resolve()))); + + const workspaceId = `ws-${Date.now()}`; + + function headers(memberId: string, extra: Record = {}) { + return workspaceHeaders(workspaceId, memberId, extra); + } + + function workspaceHeaders(targetWorkspaceId: string, memberId: string, extra: Record = {}) { + return { + 'content-type': 'application/json', + 'x-od-workspace-id': targetWorkspaceId, + 'x-od-workspace-member-id': memberId, + 'x-od-workspace-role': 'member', + ...extra, + }; + } + function workspacePrincipal(memberId: string, targetWorkspaceId = workspaceId, role: 'owner' | 'admin' | 'member' = 'member') { + return { + memberId, + teamId: targetWorkspaceId, + role, + lifecycleState: 'active' as const, + }; + } + + async function createProject(id: string, name: string) { + const resp = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id, name, skillId: null, designSystemId: null }), + }); + expect(resp.status).toBe(200); + } + + async function createProjectInWorkspace( + id: string, + name: string, + memberId: string, + extra: Record = {}, + ) { + const resp = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: headers(memberId, extra), + body: JSON.stringify({ id, name, skillId: null, designSystemId: null }), + }); + expect(resp.status).toBe(200); + } + + async function list(memberId: string, query = '', extra: Record = {}) { + return listInWorkspace(workspaceId, memberId, query, extra); + } + + async function listInWorkspace( + targetWorkspaceId: string, + memberId: string, + query = '', + extra: Record = {}, + ) { + const resp = await fetch(`${baseUrl}/api/workspaces/${targetWorkspaceId}/projects${query}`, { + headers: workspaceHeaders(targetWorkspaceId, memberId, extra), + }); + if (resp.status !== 200) { + throw new Error(`GET workspace projects failed ${resp.status}: ${await resp.text()}`); + } + return resp.json() as Promise<{ projects: Array }>; + } + + async function waitForWorkspaceProjectSyncState( + memberId: string, + projectId: string, + syncState: string, + extra: Record = {}, + ) { + let project: any; + for (let i = 0; i < 40; i += 1) { + const body = await list(memberId, '?view=all', extra); + project = body.projects.find((item) => item.id === projectId); + if (project?.syncState === syncState) return project; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return project; + } + + it('rejects a project list when the route Workspace conflicts with the explicit request scope', async () => { + const suffix = Date.now(); + const workspaceA = `${workspaceId}-route-a-${suffix}`; + const workspaceB = `${workspaceId}-header-b-${suffix}`; + const projectId = `workspace-route-scope-${suffix}`; + await createProjectInWorkspace( + projectId, + 'Workspace route scope fixture', + 'member-route-a', + { 'x-od-workspace-id': workspaceA }, + ); + + const response = await fetch( + `${baseUrl}/api/workspaces/${workspaceA}/projects?view=all`, + { headers: workspaceHeaders(workspaceB, 'member-header-b') }, + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'WORKSPACE_ACCESS_DENIED' }, + }); + }); + + it('projects legacy rows into a workspace list without assigning ownership to the reader', async () => { + const projectId = `workspace-list-${Date.now()}`; + await createProject(projectId, 'Workspace list fixture'); + + const body = await list('member-list', '?view=all'); + + const project = body.projects.find((item) => item.id === projectId); + expect(project).toMatchObject({ + id: projectId, + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: null, + }); + expect(project.currentUserAccess.canDelete).toBe(false); + }); + + // RED LINE — losing a user's pre-workspace ("legacy") projects across the + // upgrade is data loss. The adoption model must be: every legacy project is + // lazily projected into the personal workspace on first read (regardless of + // how long after the upgrade that read happens), projection is idempotent, + // and a team view SUPPRESSING an ownerless personal row must never translate + // into that row disappearing from the personal workspace. + it('never loses legacy projects across workspace views (upgrade adoption red line)', async () => { + const stamp = Date.now(); + const legacyIds = [0, 1, 2].map((n) => `redline-${stamp}-${n}`); + for (const id of legacyIds) await createProject(id, `Legacy ${id}`); + + // First personal-workspace read after "upgrade": every legacy project is + // adopted, visible, and personal — none skipped, none re-owned. + const first = await list('redline-reader', '?view=all'); + for (const id of legacyIds) { + expect(first.projects.find((item) => item.id === id)).toMatchObject({ + id, + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: null, + }); + } + + // Idempotent: a second read neither drops nor duplicates rows. + const second = await list('redline-reader', '?view=all'); + for (const id of legacyIds) { + expect(second.projects.filter((item) => item.id === id)).toHaveLength(1); + } + + // A TEAM workspace view suppresses ownerless personal rows (they belong to + // the person, not the team)… + const teamWorkspaceId = `${workspaceId}-redline-team`; + const teamResp = await fetch(`${baseUrl}/api/workspaces/${teamWorkspaceId}/projects?view=all`, { + headers: workspaceHeaders(teamWorkspaceId, 'redline-reader', { + 'x-od-workspace-type': 'team', + }), + }); + expect(teamResp.status).toBe(200); + const teamBody = (await teamResp.json()) as { projects: Array }; + for (const id of legacyIds) { + expect(teamBody.projects.find((item) => item.id === id)).toBeUndefined(); + } + + // …but suppression is a FILTER, not a deletion: the personal workspace + // still lists every legacy project afterwards. + const after = await list('redline-reader', '?view=all'); + for (const id of legacyIds) { + expect(after.projects.find((item) => item.id === id)).toMatchObject({ + id, + visibility: 'personal', + }); + } + }); + + // Product ruling (2026-07-21): 「草稿和分享的方案都是和 workspace 绑定的」. A + // project belongs to exactly ONE workspace. This test used to assert the + // opposite — that the same legacy project is projected independently into + // every workspace that reads it — which is precisely the back-fill bug: with + // a row everywhere, every workspace rendered the same 草稿 grid and switching + // workspaces changed nothing. + it('binds a legacy project to the first workspace that adopts it, and only that one', async () => { + const projectId = `workspace-multi-${Date.now()}`; + const workspaceA = `${workspaceId}-a`; + const workspaceB = `${workspaceId}-b`; + await createProject(projectId, 'Multi workspace fixture'); + + const bodyA = await listInWorkspace(workspaceA, 'member-a', '?view=all'); + const bodyB = await listInWorkspace(workspaceB, 'member-b', '?view=all'); + + expect(bodyA.projects.find((item) => item.id === projectId)).toMatchObject({ + id: projectId, + workspaceId: workspaceA, + createdByWorkspaceMemberId: null, + }); + // Workspace B reading the same daemon does NOT get a copy. + expect(bodyB.projects.find((item) => item.id === projectId)).toBeUndefined(); + + // …and adoption is stable: re-reading B does not steal it from A. + const againA = await listInWorkspace(workspaceA, 'member-a', '?view=all'); + expect(againA.projects.some((item) => item.id === projectId)).toBe(true); + }); + + // THE BUG, at the draft grid. A draft created inside workspace A must not + // appear in workspace B's 草稿 — that is the whole product ruling. + it('keeps a draft created in one workspace out of another workspace’s drafts', async () => { + const suffix = Date.now(); + const projectId = `workspace-draft-scope-${suffix}`; + const workspaceA = `${workspaceId}-draft-a-${suffix}`; + const workspaceB = `${workspaceId}-draft-b-${suffix}`; + + // Created THROUGH workspace A's context, so the row records the act. + const createResp = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: workspaceHeaders(workspaceA, 'member-draft-a'), + body: JSON.stringify({ id: projectId, name: 'Draft in A', skillId: null, designSystemId: null }), + }); + expect(createResp.status).toBe(200); + await expect(createResp.json()).resolves.toMatchObject({ + project: { + id: projectId, + workspaceId: workspaceA, + }, + }); + + const draftsA = await listInWorkspace(workspaceA, 'member-draft-a', '?view=drafts'); + expect(draftsA.projects.map((item) => item.id)).toContain(projectId); + + const draftsB = await listInWorkspace(workspaceB, 'member-draft-b', '?view=drafts'); + expect(draftsB.projects.map((item) => item.id)).not.toContain(projectId); + const allB = await listInWorkspace(workspaceB, 'member-draft-b', '?view=all'); + expect(allB.projects.map((item) => item.id)).not.toContain(projectId); + }); + + it('rejects partial or revoked workspace-aware creates without leaving an unbound project', async () => { + const suffix = Date.now(); + const partialId = `workspace-create-partial-${suffix}`; + const partial = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-od-workspace-id': `${workspaceId}-partial`, + }, + body: JSON.stringify({ + id: partialId, + name: 'Must not become unbound', + skillId: null, + designSystemId: null, + }), + }); + expect(partial.status).toBe(400); + expect(await fetch(`${baseUrl}/api/projects/${partialId}`).then((response) => response.status)) + .toBe(404); + + const revokedId = `workspace-create-revoked-${suffix}`; + const revoked = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: workspaceHeaders(`${workspaceId}-revoked`, 'member-revoked', { + 'x-od-workspace-member-status': 'removed', + }), + body: JSON.stringify({ + id: revokedId, + name: 'Must fail closed', + skillId: null, + designSystemId: null, + }), + }); + expect(revoked.status).toBe(403); + expect(await fetch(`${baseUrl}/api/projects/${revokedId}`).then((response) => response.status)) + .toBe(404); + }); + + it('keeps the persisted workspace binding on the project detail read model', async () => { + const suffix = Date.now(); + const projectId = `workspace-detail-scope-${suffix}`; + const workspaceA = `${workspaceId}-detail-a-${suffix}`; + const createResp = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: workspaceHeaders(workspaceA, 'member-detail-a'), + body: JSON.stringify({ + id: projectId, + name: 'Project detail scope fixture', + skillId: null, + designSystemId: null, + }), + }); + expect(createResp.status).toBe(200); + + const detailResp = await fetch(`${baseUrl}/api/projects/${projectId}`, { + headers: workspaceHeaders(workspaceA, 'member-detail-a'), + }); + expect(detailResp.status).toBe(200); + const detail = (await detailResp.json()) as { + project: { id: string; workspaceId?: string | null }; + }; + expect(detail.project).toMatchObject({ + id: projectId, + workspaceId: workspaceA, + }); + }); + + // Adoption must never mint a second row for a project that already has one. + // The narrowed primary key would reject it, so a regression here surfaces as a + // 500 rather than a silent duplicate — but the read path must not get there. + it('does not re-bind a project that already belongs to a workspace', async () => { + const suffix = Date.now(); + const projectId = `workspace-rebind-${suffix}`; + const workspaceA = `${workspaceId}-rebind-a-${suffix}`; + const workspaceB = `${workspaceId}-rebind-b-${suffix}`; + await createProject(projectId, 'Rebind fixture'); + + await listInWorkspace(workspaceA, 'member-rebind-a', '?view=all'); + for (let i = 0; i < 3; i += 1) { + const resp = await fetch(`${baseUrl}/api/workspaces/${workspaceB}/projects?view=all`, { + headers: workspaceHeaders(workspaceB, 'member-rebind-b'), + }); + expect(resp.status).toBe(200); + } + + const stillInA = await listInWorkspace(workspaceA, 'member-rebind-a', '?view=all'); + expect(stillInA.projects.filter((item) => item.id === projectId)).toHaveLength(1); + }); + + it('does not let the first workspace reader become the legacy project owner', async () => { + const projectId = `workspace-owner-read-${Date.now()}`; + await createProject(projectId, 'Ownership read fixture'); + + const firstRead = await list('member-b', '?view=all'); + const afterRead = firstRead.projects.find((item) => item.id === projectId); + expect(afterRead).toMatchObject({ + id: projectId, + createdByWorkspaceMemberId: null, + }); + expect(afterRead.currentUserAccess.canDelete).toBe(false); + + const deleteResp = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/batch-delete`, { + method: 'POST', + headers: headers('member-b'), + body: JSON.stringify({ projectIds: [projectId] }), + }); + expect(deleteResp.status).toBe(403); + + const stillExists = await fetch(`${baseUrl}/api/projects/${projectId}`, { + headers: headers('member-b'), + }); + expect(stillExists.status).toBe(200); + }); + + it('does not expose removed-location projects through workspace project routes', async () => { + const locationId = `workspace-hidden-location-${Date.now()}`; + const projectId = `workspace-hidden-project-${Date.now()}`; + const extDir = await mkdtemp(path.join(tmpdir(), 'od-workspace-hidden-')); + try { + const putLocation = await fetch(`${baseUrl}/api/project-locations`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ locations: [{ id: locationId, name: 'Hidden workspace location', path: extDir }] }), + }); + expect(putLocation.status).toBe(200); + + const createResp = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + id: projectId, + name: 'Hidden workspace project', + skillId: null, + designSystemId: null, + projectLocationId: locationId, + }), + }); + expect(createResp.status).toBe(200); + + const removeLocation = await fetch(`${baseUrl}/api/project-locations`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ locations: [] }), + }); + expect(removeLocation.status).toBe(200); + + const listResp = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects?view=all`, { + headers: headers('member-hidden-location'), + }); + expect(listResp.status).toBe(200); + const listBody = await listResp.json() as { projects: Array }; + expect(listBody.projects.some((item: any) => item.id === projectId)).toBe(false); + + const deleteResp = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/batch-delete`, { + method: 'POST', + headers: headers('member-hidden-location', { 'x-od-workspace-role': 'admin' }), + body: JSON.stringify({ projectIds: [projectId] }), + }); + expect(deleteResp.status).toBe(404); + } finally { + await rm(extDir, { recursive: true, force: true }); + } + }); + + it('rejects workspace project mutations without workspace identity', async () => { + const projectId = `workspace-missing-context-${Date.now()}`; + await createProject(projectId, 'Missing context fixture'); + + const deleteResp = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/batch-delete`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ projectIds: [projectId] }), + }); + + expect(deleteResp.status).toBe(400); + await expect(deleteResp.json()).resolves.toMatchObject({ + error: { + code: 'WORKSPACE_CONTEXT_REQUIRED', + }, + }); + + const stillExists = await fetch(`${baseUrl}/api/projects/${projectId}`); + expect(stillExists.status).toBe(200); + }); + + it('validates workspace project views and applies each accepted view', async () => { + const suffix = Date.now(); + const draftId = `workspace-view-draft-${suffix}`; + const teamId = `workspace-view-team-${suffix}`; + const otherId = `workspace-view-other-${suffix}`; + await createProject(draftId, 'Draft view fixture'); + await list('member-view'); + await createProject(teamId, 'Team view fixture'); + await list('member-view'); + await createProject(otherId, 'Other member view fixture'); + await list('member-other'); + + const moveResp = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/${teamId}/move`, { + method: 'POST', + headers: headers('member-view', { + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'admin', + }), + body: JSON.stringify({ visibility: 'team' }), + }); + expect(moveResp.status).toBe(200); + + const all = await list('member-view', '?view=all'); + const recent = await list('member-view', '?view=recent'); + const drafts = await list('member-view', '?view=drafts'); + const team = await list('member-view', '?view=team'); + + expect(all.projects.some((item) => item.id === draftId)).toBe(true); + expect(recent.projects.map((item) => item.id)).toContain(draftId); + expect(recent.projects.map((item) => item.id)).toContain(otherId); + expect(recent.projects.map((item) => item.id)).toContain(teamId); + // Every fixture project is created through the bare `/api/projects` path, so + // each has a null workspace creator and counts as a local draft for whoever + // views it (legacy local-project compatibility). draftId and otherId are + // still personal, so both surface in drafts; teamId was moved to team, so it + // drops out. Real per-member draft isolation applies once a project carries a + // stamped workspace creator (created within a workspace context, or shared). + expect(drafts.projects.map((item) => item.id)).toContain(draftId); + expect(drafts.projects.map((item) => item.id)).toContain(otherId); + expect(drafts.projects.map((item) => item.id)).not.toContain(teamId); + expect(team.projects.map((item) => item.id)).toContain(teamId); + expect(team.projects.map((item) => item.id)).not.toContain(draftId); + + const invalid = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects?view=personal`, { + headers: headers('member-view'), + }); + expect(invalid.status).toBe(400); + }); + + // A team share recorded against a PERSONAL workspace is self-contradictory: + // B has no standalone team id, so the workspace id IS the team identity and a + // personal workspace has no team plane to act on. Every project-scoped collab + // call the resulting row pins (presence, comments, publish) is answered + // `403 missing_principal` — forever, and silently. The share must fail loudly + // at the moment it is requested instead of persisting an impossible row. + it('refuses a team share requested from a personal workspace', async () => { + const suffix = Date.now(); + const projectId = `workspace-personal-share-${suffix}`; + const personalWorkspaceId = `${workspaceId}-personal-${suffix}`; + await createProject(projectId, 'Personal workspace share fixture'); + + const personalHeaders = workspaceHeaders(personalWorkspaceId, 'member-personal-sharer', { + 'x-od-workspace-type': 'personal', + 'x-od-workspace-role': 'admin', + }); + + const moveResp = await fetch( + `${baseUrl}/api/workspaces/${personalWorkspaceId}/projects/${projectId}/move`, + { + method: 'POST', + headers: personalHeaders, + body: JSON.stringify({ visibility: 'team' }), + }, + ); + expect(moveResp.status).toBe(409); + expect(await moveResp.json()).toMatchObject({ + error: { code: 'WORKSPACE_TEAM_SHARE_REQUIRES_TEAM_WORKSPACE' }, + }); + + const batchResp = await fetch( + `${baseUrl}/api/workspaces/${personalWorkspaceId}/projects/batch-move`, + { + method: 'POST', + headers: personalHeaders, + body: JSON.stringify({ projectIds: [projectId], visibility: 'team' }), + }, + ); + expect(batchResp.status).toBe(409); + + // The row must still be personal — a refused share leaves nothing behind. + const listResp = await fetch( + `${baseUrl}/api/workspaces/${personalWorkspaceId}/projects?view=all`, + { headers: personalHeaders }, + ); + expect(listResp.status).toBe(200); + const body = (await listResp.json()) as { projects: Array }; + const row = body.projects.find((item) => item.id === projectId); + expect(row).toMatchObject({ id: projectId, visibility: 'personal' }); + // …and the UI affordance that offers the impossible action is gone. + expect(row.currentUserAccess.canMoveToTeam).toBe(false); + }); + + it('supports batch operations on explicitly scoped projects without requiring a prior list request', async () => { + const suffix = Date.now(); + const moveProjectId = `workspace-batch-move-${suffix}`; + const deleteProjectId = `workspace-batch-delete-${suffix}`; + const teamHeaders = { + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'admin', + }; + await createProjectInWorkspace(moveProjectId, 'Direct batch move project', 'member-direct', teamHeaders); + await createProjectInWorkspace(deleteProjectId, 'Direct batch delete project', 'member-direct', teamHeaders); + + const moveResp = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/batch-move`, { + method: 'POST', + headers: headers('member-direct', teamHeaders), + body: JSON.stringify({ projectIds: [moveProjectId], visibility: 'team' }), + }); + expect(moveResp.status).toBe(200); + const moved = await moveResp.json() as { projects: Array }; + expect(moved.projects[0]).toMatchObject({ + id: moveProjectId, + visibility: 'team', + syncState: 'synced', + resourceHubResourceId: projectResourceIdFor(moveProjectId, workspacePrincipal('member-direct', workspaceId, 'admin')), + cloudTombstonedAt: null, + createdByWorkspaceMemberId: 'member-direct', + }); + + const invalidMoveResp = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/batch-move`, { + method: 'POST', + headers: headers('member-direct', teamHeaders), + body: JSON.stringify({ projectIds: [deleteProjectId, 123], visibility: 'team' }), + }); + expect(invalidMoveResp.status).toBe(400); + + const afterInvalidMove = await list('member-direct', '?view=all', teamHeaders); + const untouched = afterInvalidMove.projects.find((item: any) => item.id === deleteProjectId); + expect(untouched).toMatchObject({ + visibility: 'personal', + syncState: 'local_only', + resourceHubResourceId: null, + }); + + const invalidDeleteResp = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/batch-delete`, { + method: 'POST', + headers: headers('member-direct', teamHeaders), + body: JSON.stringify({ projectIds: [deleteProjectId, 123] }), + }); + expect(invalidDeleteResp.status).toBe(400); + + const afterInvalidDelete = await fetch(`${baseUrl}/api/projects/${deleteProjectId}`, { + headers: headers('member-direct', teamHeaders), + }); + expect(afterInvalidDelete.status).toBe(200); + const batchShareStatus = await fetch(`${baseUrl}/api/projects/${moveProjectId}/collab/status`, { + headers: headers('member-direct', teamHeaders), + }); + expect(batchShareStatus.status).toBe(200); + const batchShare = await batchShareStatus.json() as { syncState: string; ownerMemberId: string | null }; + expect(['pending_upload', 'synced']).toContain(batchShare.syncState); + expect(batchShare.ownerMemberId).toBe('member-direct'); + const syncedProject = await waitForWorkspaceProjectSyncState( + 'member-direct', + moveProjectId, + 'synced', + teamHeaders, + ); + expect(syncedProject).toMatchObject({ + id: moveProjectId, + syncState: 'synced', + resourceHubResourceId: projectResourceIdFor(moveProjectId, workspacePrincipal('member-direct', workspaceId, 'admin')), + createdByWorkspaceMemberId: 'member-direct', + }); + expect(syncedProject.pendingSyncIntent).toBeUndefined(); + + const moveBackResp = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/${moveProjectId}/move`, { + method: 'POST', + headers: headers('member-direct', teamHeaders), + body: JSON.stringify({ visibility: 'personal' }), + }); + // An admin who shared the project can move it back out of the team; the + // project returns to personal/local-only and drops its resource binding. + expect(moveBackResp.status).toBe(200); + const movedBack = await moveBackResp.json() as { project: any }; + expect(movedBack.project).toMatchObject({ + id: moveProjectId, + visibility: 'personal', + syncState: 'local_only', + resourceHubResourceId: null, + }); + + // It is already personal now, so moving it to personal again is rejected + // (canMoveToPersonal requires the project to currently be team-shared). + const batchMoveBackResp = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/batch-move`, { + method: 'POST', + headers: headers('member-direct', teamHeaders), + body: JSON.stringify({ projectIds: [moveProjectId], visibility: 'personal' }), + }); + expect(batchMoveBackResp.status).toBe(403); + + const deleteResp = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/batch-delete`, { + method: 'POST', + headers: headers('member-direct', teamHeaders), + body: JSON.stringify({ projectIds: [deleteProjectId] }), + }); + expect(deleteResp.status).toBe(200); + + const deleted = await fetch(`${baseUrl}/api/projects/${deleteProjectId}`); + expect(deleted.status).toBe(404); + }); + + it('lets a plain member share their unattributed local project to the team', async () => { + // A lazily-projected local row carries createdByWorkspaceMemberId=null + // (projection never assigns ownership to the reader — see the adoption + // red line above). But the project physically lives only on this user's + // machine, so SHARING it must not require prior attribution: the share + // itself stamps the sharer as owner. A plain member (canShareProjects) + // was 403ed here, which dead-ended every member's own drafts. + const projectId = `workspace-member-share-${Date.now()}`; + await createProject(projectId, 'Member share project'); + + const moveResp = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/${projectId}/move`, { + method: 'POST', + headers: headers('member-plain-sharer', { 'x-od-workspace-type': 'team' }), + body: JSON.stringify({ visibility: 'team' }), + }); + expect(moveResp.status).toBe(200); + const moved = await moveResp.json() as { project: any }; + expect(moved.project).toMatchObject({ + id: projectId, + visibility: 'team', + createdByWorkspaceMemberId: 'member-plain-sharer', + }); + + // Destructive actions stay strict: a DIFFERENT member still cannot + // delete or unshare what this member now owns. + const strangerMove = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/${projectId}/move`, { + method: 'POST', + headers: headers('member-other'), + body: JSON.stringify({ visibility: 'personal' }), + }); + expect(strangerMove.status).toBe(403); + }); + + it('keeps Team shared-project access flags and unshare single-writer for workspace owners', async () => { + const suffix = Date.now(); + const projectOwnerId = `member-project-owner-${suffix}`; + const workspaceOwnerId = `member-workspace-owner-${suffix}`; + const singleProjectId = `workspace-single-unshare-${suffix}`; + const batchProjectId = `workspace-batch-unshare-${suffix}`; + const projectOwnerHeaders = headers(projectOwnerId, { + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'member', + }); + const workspaceOwnerHeaders = headers(workspaceOwnerId, { + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'owner', + }); + + for (const projectId of [singleProjectId, batchProjectId]) { + await createProjectInWorkspace( + projectId, + `Shared by ${projectOwnerId}`, + projectOwnerId, + { 'x-od-workspace-type': 'team' }, + ); + const share = await fetch( + `${baseUrl}/api/workspaces/${workspaceId}/projects/${projectId}/move`, + { + method: 'POST', + headers: projectOwnerHeaders, + body: JSON.stringify({ visibility: 'team' }), + }, + ); + expect(share.status).toBe(200); + } + + const workspaceOwnerList = await list( + workspaceOwnerId, + '?view=team', + { + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'owner', + }, + ); + for (const projectId of [singleProjectId, batchProjectId]) { + const project = workspaceOwnerList.projects.find( + (item: any) => item.id === projectId, + ); + expect(project).toMatchObject({ + visibility: 'team', + createdByWorkspaceMemberId: projectOwnerId, + currentUserAccess: { + canRename: false, + canDelete: false, + canDuplicate: false, + canMoveToPersonal: false, + canRestoreVersion: false, + }, + }); + } + + const singleUnshare = await fetch( + `${baseUrl}/api/workspaces/${workspaceId}/projects/${singleProjectId}/move`, + { + method: 'POST', + headers: workspaceOwnerHeaders, + body: JSON.stringify({ visibility: 'personal' }), + }, + ); + expect(singleUnshare.status).toBe(403); + + const batchUnshare = await fetch( + `${baseUrl}/api/workspaces/${workspaceId}/projects/batch-move`, + { + method: 'POST', + headers: workspaceOwnerHeaders, + body: JSON.stringify({ + projectIds: [batchProjectId], + visibility: 'personal', + }), + }, + ); + expect(batchUnshare.status).toBe(403); + + const projectOwnerList = await list( + projectOwnerId, + '?view=team', + { 'x-od-workspace-type': 'team' }, + ); + for (const projectId of [singleProjectId, batchProjectId]) { + const project = projectOwnerList.projects.find( + (item: any) => item.id === projectId, + ); + expect(project).toMatchObject({ + visibility: 'team', + createdByWorkspaceMemberId: projectOwnerId, + currentUserAccess: { + canRename: true, + canDelete: true, + canDuplicate: true, + canMoveToPersonal: true, + canRestoreVersion: true, + }, + }); + } + }); + + it('stamps the sharing member as owner when a legacy project moves to team', async () => { + const projectId = `workspace-share-owner-${Date.now()}`; + await createProject(projectId, 'Share owner project'); + + const moveResp = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/${projectId}/move`, { + method: 'POST', + headers: headers('member-share-owner', { + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'admin', + }), + body: JSON.stringify({ visibility: 'team' }), + }); + expect(moveResp.status).toBe(200); + const moved = await moveResp.json() as { project: any }; + expect(moved.project).toMatchObject({ + id: projectId, + visibility: 'team', + createdByWorkspaceMemberId: 'member-share-owner', + }); + + const mine = await list('member-share-owner', '?owner=mine'); + expect(mine.projects.map((item) => item.id)).toContain(projectId); + + const others = await list('member-share-owner', '?owner=others'); + expect(others.projects.map((item) => item.id)).not.toContain(projectId); + + const mineTeam = await list('member-share-owner', '?owner=mine&visibility=team'); + expect(mineTeam.projects.map((item) => item.id)).toContain(projectId); + + const othersTeam = await list('member-share-owner', '?owner=others&visibility=team'); + expect(othersTeam.projects.map((item) => item.id)).not.toContain(projectId); + }); + + it('enforces workspace project permissions on direct project and file write routes', async () => { + const projectId = `workspace-direct-write-${Date.now()}`; + await createProject(projectId, 'Direct write project'); + + const ownerHeaders = headers('member-write-owner', { + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'admin', + }); + const moveResp = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/${projectId}/move`, { + method: 'POST', + headers: ownerHeaders, + body: JSON.stringify({ visibility: 'team' }), + }); + expect(moveResp.status).toBe(200); + + const seedResp = await fetch(`${baseUrl}/api/projects/${projectId}/files`, { + method: 'POST', + headers: ownerHeaders, + body: JSON.stringify({ name: 'index.html', content: '

original

' }), + }); + expect(seedResp.status).toBe(200); + + // Workspace governance does not transfer the shared project's single + // writer. Even a Workspace owner remains a read-only viewer when the + // catalog names another member as this project's owner. + const workspaceOwnerHeaders = headers('member-workspace-owner', { + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'owner', + }); + const privilegedWriteResp = await fetch(`${baseUrl}/api/projects/${projectId}/files`, { + method: 'POST', + headers: workspaceOwnerHeaders, + body: JSON.stringify({ name: 'owner-escalation.txt', content: 'must not land' }), + }); + expect(privilegedWriteResp.status).toBe(403); + + const versionResp = await fetch(`${baseUrl}/api/projects/${projectId}/files/index.html/versions`, { + method: 'POST', + headers: ownerHeaders, + body: JSON.stringify({ source: 'manual', label: 'seed' }), + }); + expect(versionResp.status).toBe(200); + const versionBody = await versionResp.json() as { version: { id: string } }; + + const readOnlyHeaders = headers('member-write-viewer'); + const patchResp = await fetch(`${baseUrl}/api/projects/${projectId}`, { + method: 'PATCH', + headers: readOnlyHeaders, + body: JSON.stringify({ name: 'Illicit rename' }), + }); + expect(patchResp.status).toBe(403); + + const duplicateResp = await fetch(`${baseUrl}/api/projects/${projectId}/duplicate`, { + method: 'POST', + headers: readOnlyHeaders, + body: JSON.stringify({ name: 'Illicit duplicate' }), + }); + expect(duplicateResp.status).toBe(403); + + const designSystemCopyResp = await fetch(`${baseUrl}/api/projects/${projectId}/design-system-copy`, { + method: 'POST', + headers: readOnlyHeaders, + body: JSON.stringify({ name: 'Illicit design-system copy' }), + }); + expect(designSystemCopyResp.status).toBe(403); + + const writeResp = await fetch(`${baseUrl}/api/projects/${projectId}/files`, { + method: 'POST', + headers: readOnlyHeaders, + body: JSON.stringify({ name: 'blocked.txt', content: 'blocked' }), + }); + expect(writeResp.status).toBe(403); + + // The multi-file batch route (chat composer paste/drop/picker) is a + // separate handler from the single-file POST above and used to carry no + // enforceWorkspaceProjectMutation call at all — not even the ctx-present + // path this whole test exercises for its siblings. + const uploadForm = new FormData(); + uploadForm.append('files', new Blob(['blocked'], { type: 'text/plain' }), 'blocked-upload.txt'); + const { 'content-type': _uploadContentType, ...uploadHeaders } = readOnlyHeaders; + const uploadResp = await fetch(`${baseUrl}/api/projects/${projectId}/upload`, { + method: 'POST', + headers: uploadHeaders, + body: uploadForm, + }); + expect(uploadResp.status).toBe(403); + + const folderCreateResp = await fetch(`${baseUrl}/api/projects/${projectId}/folders`, { + method: 'POST', + headers: readOnlyHeaders, + body: JSON.stringify({ name: 'blocked-folder' }), + }); + expect(folderCreateResp.status).toBe(403); + + const renameResp = await fetch(`${baseUrl}/api/projects/${projectId}/files/rename`, { + method: 'POST', + headers: readOnlyHeaders, + body: JSON.stringify({ from: 'index.html', to: 'renamed.html' }), + }); + expect(renameResp.status).toBe(403); + + const restoreResp = await fetch(`${baseUrl}/api/projects/${projectId}/files/index.html/versions/${versionBody.version.id}/restore`, { + method: 'POST', + headers: readOnlyHeaders, + body: JSON.stringify({}), + }); + expect(restoreResp.status).toBe(403); + + const deleteResp = await fetch(`${baseUrl}/api/projects/${projectId}/files/index.html`, { + method: 'DELETE', + headers: readOnlyHeaders, + }); + expect(deleteResp.status).toBe(403); + + const rawDeleteResp = await fetch(`${baseUrl}/api/projects/${projectId}/raw/index.html`, { + method: 'DELETE', + headers: readOnlyHeaders, + }); + expect(rawDeleteResp.status).toBe(403); + + const folderDeleteResp = await fetch(`${baseUrl}/api/projects/${projectId}/folders`, { + method: 'DELETE', + headers: readOnlyHeaders, + body: JSON.stringify({ path: 'blocked-folder' }), + }); + expect(folderDeleteResp.status).toBe(403); + + const projectDeleteResp = await fetch(`${baseUrl}/api/projects/${projectId}`, { + method: 'DELETE', + headers: readOnlyHeaders, + }); + expect(projectDeleteResp.status).toBe(403); + + const blockedFile = await fetch(`${baseUrl}/api/projects/${projectId}/raw/blocked.txt`, { + headers: readOnlyHeaders, + }); + expect(blockedFile.status).toBe(404); + const privilegedBlockedFile = await fetch( + `${baseUrl}/api/projects/${projectId}/raw/owner-escalation.txt`, + { headers: ownerHeaders }, + ); + expect(privilegedBlockedFile.status).toBe(404); + const projectResp = await fetch(`${baseUrl}/api/projects/${projectId}`, { + headers: readOnlyHeaders, + }); + const projectBody = await projectResp.json() as { project: { name: string } }; + expect(projectBody.project.name).toBe('Direct write project'); + }); + + // recvqbklNGDqYY — a fully logged-out request (no x-od-workspace-* headers + // at all, exactly what the frontend sends once workspaceContext goes null) + // used to hit the ctx===null branch of enforceWorkspaceProjectMutation and + // be granted the mutation unconditionally, regardless of whether the + // project was actually team-shared. A team-shared project must require + // real workspace identity; an untouched personal/local project must still + // work headerless (the legacy pre-workspace callers this branch exists for). + it('rejects headerless direct-route mutations against a team-shared project, but still allows them for a personal project', async () => { + const suffix = Date.now(); + const teamProjectId = `workspace-headerless-team-${suffix}`; + const personalProjectId = `workspace-headerless-personal-${suffix}`; + await createProject(teamProjectId, 'Headerless team fixture'); + await createProject(personalProjectId, 'Headerless personal fixture'); + + const ownerHeaders = headers('member-headerless-owner', { + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'admin', + }); + const moveResp = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/${teamProjectId}/move`, { + method: 'POST', + headers: ownerHeaders, + body: JSON.stringify({ visibility: 'team' }), + }); + expect(moveResp.status).toBe(200); + + // No x-od-workspace-* headers at all — the post-logout / legacy shape. + const teamPatchResp = await fetch(`${baseUrl}/api/projects/${teamProjectId}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Illicit headerless rename' }), + }); + expect(teamPatchResp.status).toBe(400); + await expect(teamPatchResp.json()).resolves.toMatchObject({ + error: { code: 'WORKSPACE_CONTEXT_REQUIRED' }, + }); + + // A project this daemon never bound to any workspace (or bound personal) + // must keep working for a headerless caller — this is the pre-workspace + // legacy path the null-context branch exists for in the first place. + const personalPatchResp = await fetch(`${baseUrl}/api/projects/${personalProjectId}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Renamed personal fixture' }), + }); + expect(personalPatchResp.status).toBe(200); + + const stillNamed = await fetch(`${baseUrl}/api/projects/${teamProjectId}`, { + headers: ownerHeaders, + }); + const stillNamedBody = await stillNamed.json() as { project: { name: string } }; + expect(stillNamedBody.project.name).toBe('Headerless team fixture'); + }); + + // recvqbjbudBS9r — a duplicated project used to leave the daemon with NO + // `workspace_projects` row at all for the copy: `POST /api/projects/:id/duplicate` + // inserted the new project row but never bound it anywhere. It stayed an + // unbound orphan until whichever workspace's project list was read NEXT + // (`bindUnboundProjectsToPersonalWorkspace` sweeps every orphan into the + // workspace it is reading for), which could be a workspace the user never + // touched. The fix binds the copy into the duplicating request's own + // workspace immediately, so no later read — for ANY workspace — can steal it. + it('binds a duplicated project into the workspace it was duplicated from, not wherever a project list is read next', async () => { + const projectId = `dup-workspace-bind-${Date.now()}`; + await createProject(projectId, 'Duplicate workspace-bind fixture'); + + const ownerHeaders = headers('member-dup-owner', { + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'admin', + }); + const moveResp = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/${projectId}/move`, { + method: 'POST', + headers: ownerHeaders, + body: JSON.stringify({ visibility: 'team' }), + }); + expect(moveResp.status).toBe(200); + + const duplicateResp = await fetch(`${baseUrl}/api/projects/${projectId}/duplicate`, { + method: 'POST', + headers: ownerHeaders, + body: JSON.stringify({ name: 'Duplicate workspace-bind copy' }), + }); + expect(duplicateResp.status).toBe(200); + const duplicateBody = await duplicateResp.json() as { + project: { id: string; workspaceId?: string }; + }; + const targetId = duplicateBody.project.id; + expect(duplicateBody.project.workspaceId).toBe(workspaceId); + + // Read a DIFFERENT workspace's project list first. Before the fix this + // greedily adopted the still-unbound copy (any personal-workspace read + // sweeps every orphan project into itself), so the copy would show up + // here instead of in the workspace it was actually duplicated from. + const otherWorkspaceId = `ws-other-${Date.now()}`; + const otherList = await listInWorkspace(otherWorkspaceId, 'member-other-reader', '?view=all'); + expect(otherList.projects.some((item) => item.id === targetId)).toBe(false); + + // The workspace the duplicate actually happened in has it immediately — + // no dependency on a later list read to adopt it. + const ownList = await list('member-dup-owner', '?view=all'); + expect(ownList.projects.find((item) => item.id === targetId)).toMatchObject({ + id: targetId, + createdByWorkspaceMemberId: 'member-dup-owner', + }); + + const designSystemCopyResp = await fetch( + `${baseUrl}/api/projects/${projectId}/design-system-copy`, + { + method: 'POST', + headers: ownerHeaders, + body: JSON.stringify({ name: 'Design-system workspace-bind copy' }), + }, + ); + expect(designSystemCopyResp.status).toBe(200); + const designSystemCopyBody = await designSystemCopyResp.json() as { + project: { id: string; workspaceId?: string }; + designSystemId: string; + }; + expect(designSystemCopyBody.project.workspaceId).toBe(workspaceId); + expect( + (await list('member-dup-owner', '?view=all')).projects.find( + (item) => item.id === designSystemCopyBody.project.id, + ), + ).toMatchObject({ + id: designSystemCopyBody.project.id, + createdByWorkspaceMemberId: 'member-dup-owner', + }); + + const ownDesignSystemsResp = await fetch(`${baseUrl}/api/design-systems`, { + headers: ownerHeaders, + }); + expect(ownDesignSystemsResp.status).toBe(200); + const ownDesignSystemsBody = await ownDesignSystemsResp.json() as { + designSystems: Array<{ id: string; workspaceId?: string }>; + }; + expect( + ownDesignSystemsBody.designSystems.find( + (item) => item.id === designSystemCopyBody.designSystemId, + ), + ).toMatchObject({ + id: designSystemCopyBody.designSystemId, + workspaceId, + }); + + const otherHeaders = workspaceHeaders(otherWorkspaceId, 'member-other-reader', { + 'x-od-workspace-type': 'team', + }); + const otherDesignSystemsResp = await fetch(`${baseUrl}/api/design-systems`, { + headers: otherHeaders, + }); + expect(otherDesignSystemsResp.status).toBe(200); + const otherDesignSystemsBody = await otherDesignSystemsResp.json() as { + designSystems: Array<{ id: string }>; + }; + expect( + otherDesignSystemsBody.designSystems.some( + (item) => item.id === designSystemCopyBody.designSystemId, + ), + ).toBe(false); + + const ownDirectRead = await fetch( + `${baseUrl}/api/design-systems/${encodeURIComponent(designSystemCopyBody.designSystemId)}`, + { headers: ownerHeaders }, + ); + expect(ownDirectRead.status).toBe(200); + + const crossWorkspaceDirectRead = await fetch( + `${baseUrl}/api/design-systems/${encodeURIComponent(designSystemCopyBody.designSystemId)}`, + { headers: otherHeaders }, + ); + expect(crossWorkspaceDirectRead.status).toBe(403); + + const crossWorkspaceMutation = await fetch( + `${baseUrl}/api/design-systems/${encodeURIComponent(designSystemCopyBody.designSystemId)}`, + { + method: 'PATCH', + headers: otherHeaders, + body: JSON.stringify({ title: 'Cross-workspace overwrite' }), + }, + ); + expect(crossWorkspaceMutation.status).toBe(403); + }); + + // recvqbhor3pai2 — duplicating an already-duplicated project (a "copy of a + // copy") 403'd with WORKSPACE_PROJECT_PERMISSION_DENIED / "workspace project + // mutation is not allowed". Before recvqbjbudBS9r's fix (the test above), + // the first duplicate left NO `workspace_projects` row for the copy, so + // `workspaceProjectMutationAllowed` hit its `if (!row) return false;` guard + // the moment anyone tried to duplicate THAT copy. This test exercises the + // exact reported shape (two duplicates back to back, real owner headers + // matching the bug report's curl repro) end to end to confirm + // `bindDuplicateIntoRequestWorkspace` closes this specific case too — the + // copy's own binding row now exists by the time it is duplicated again. + it('allows duplicating a project that is itself already a duplicate', async () => { + const suffix = Date.now(); + const projectId = `dup-of-dup-source-${suffix}`; + await createProject(projectId, 'Duplicate-of-duplicate fixture'); + + const ownerHeaders = headers('member-dup-of-dup-owner', { + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'owner', + }); + const moveResp = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/${projectId}/move`, { + method: 'POST', + headers: ownerHeaders, + body: JSON.stringify({ visibility: 'team' }), + }); + expect(moveResp.status).toBe(200); + + // First duplicate: source -> copy1 (mirrors the "Mobile App Copy" project + // in the bug report, which was itself a duplicate). + const firstDuplicateResp = await fetch(`${baseUrl}/api/projects/${projectId}/duplicate`, { + method: 'POST', + headers: ownerHeaders, + body: JSON.stringify({ name: 'Duplicate-of-duplicate copy 1' }), + }); + expect(firstDuplicateResp.status).toBe(200); + const firstDuplicateBody = (await firstDuplicateResp.json()) as { project: { id: string } }; + const copy1Id = firstDuplicateBody.project.id; + + // Second duplicate: duplicate the COPY itself — this is exactly what the + // report's curl reproduced against and got "workspace project mutation is + // not allowed" for. + const secondDuplicateResp = await fetch(`${baseUrl}/api/projects/${copy1Id}/duplicate`, { + method: 'POST', + headers: ownerHeaders, + body: JSON.stringify({ name: 'Duplicate-of-duplicate copy 2' }), + }); + expect(secondDuplicateResp.status).toBe(200); + const secondDuplicateBody = (await secondDuplicateResp.json()) as { project: { id: string } }; + const copy2Id = secondDuplicateBody.project.id; + + const ownList = await list('member-dup-of-dup-owner', '?view=all'); + expect(ownList.projects.find((item) => item.id === copy2Id)).toMatchObject({ + id: copy2Id, + createdByWorkspaceMemberId: 'member-dup-of-dup-owner', + }); + }); + + // recvqbhor3pai2 (remaining gap) — `bindDuplicateIntoRequestWorkspace`'s own + // doc comment admits a headerless duplicate (no `x-od-workspace-*` headers — + // a legitimate legacy/pre-context caller, e.g. the web client's + // `workspaceContext` has not resolved yet on the very first click) leaves + // the copy permanently UNBOUND, "same as before" its fix. Before + // `reconcileUnboundProjectBeforeMutation`, the first LATER mutation that DID + // carry real headers — duplicating that same still-unbound copy again once + // the client's workspace context settled — hit + // `workspaceResourceMutationAllowed`'s `if (!row) return false;` guard and + // 403'd with "workspace project mutation is not allowed", even though no + // other workspace had ever claimed the project. This reproduces the exact + // reported shape end to end and confirms the copy gets claimed into the + // duplicating member's own workspace instead of staying stuck. + it('allows duplicating a copy that a prior headerless duplicate left unbound', async () => { + const suffix = Date.now(); + const projectId = `dup-unbound-source-${suffix}`; + await createProject(projectId, 'Duplicate-of-unbound-copy fixture'); + + // First duplicate: no workspace headers at all (legacy / pre-context + // caller). Source is itself unbound, so this is allowed today — but it + // leaves the COPY unbound too. + const firstDuplicateResp = await fetch(`${baseUrl}/api/projects/${projectId}/duplicate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Duplicate-of-unbound-copy copy 1' }), + }); + expect(firstDuplicateResp.status).toBe(200); + const firstDuplicateBody = (await firstDuplicateResp.json()) as { project: { id: string } }; + const copy1Id = firstDuplicateBody.project.id; + + // Second duplicate: this time with real workspace headers, as if the + // client's workspace context has since resolved — exactly what the + // report's repro (open the copy, "···" → duplicate again) exercised. + const memberHeaders = headers('member-dup-unbound-owner', { 'x-od-workspace-role': 'owner' }); + const secondDuplicateResp = await fetch(`${baseUrl}/api/projects/${copy1Id}/duplicate`, { + method: 'POST', + headers: memberHeaders, + body: JSON.stringify({ name: 'Duplicate-of-unbound-copy copy 2' }), + }); + expect(secondDuplicateResp.status).toBe(200); + const secondDuplicateBody = (await secondDuplicateResp.json()) as { project: { id: string } }; + const copy2Id = secondDuplicateBody.project.id; + + // The reconciliation claimed copy1 (the source of the second duplicate) + // into the duplicating member's own workspace rather than leaving it — or + // copy2 — unbound. + const ownList = await list('member-dup-unbound-owner', '?view=all'); + expect(ownList.projects.find((item) => item.id === copy1Id)).toMatchObject({ + id: copy1Id, + createdByWorkspaceMemberId: 'member-dup-unbound-owner', + }); + expect(ownList.projects.find((item) => item.id === copy2Id)).toMatchObject({ + id: copy2Id, + createdByWorkspaceMemberId: 'member-dup-unbound-owner', + }); + }); + + it('blocks direct project and file writes when the workspace is locked', async () => { + const projectId = `workspace-direct-locked-${Date.now()}`; + await createProject(projectId, 'Locked direct write project'); + + const ownerHeaders = headers('member-locked-owner', { + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'admin', + }); + const moveResp = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/${projectId}/move`, { + method: 'POST', + headers: ownerHeaders, + body: JSON.stringify({ visibility: 'team' }), + }); + expect(moveResp.status).toBe(200); + + const lockedHeaders = headers('member-locked-owner', { + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'admin', + 'x-od-workspace-lifecycle-state': 'locked', + }); + const patchResp = await fetch(`${baseUrl}/api/projects/${projectId}`, { + method: 'PATCH', + headers: lockedHeaders, + body: JSON.stringify({ name: 'Locked rename' }), + }); + expect(patchResp.status).toBe(403); + + const duplicateResp = await fetch(`${baseUrl}/api/projects/${projectId}/duplicate`, { + method: 'POST', + headers: lockedHeaders, + body: JSON.stringify({ name: 'Locked duplicate' }), + }); + expect(duplicateResp.status).toBe(403); + + const writeResp = await fetch(`${baseUrl}/api/projects/${projectId}/files`, { + method: 'POST', + headers: lockedHeaders, + body: JSON.stringify({ name: 'locked.txt', content: 'locked' }), + }); + expect(writeResp.status).toBe(403); + + const uploadForm = new FormData(); + uploadForm.append('files', new Blob(['locked'], { type: 'text/plain' }), 'locked-upload.txt'); + const { 'content-type': _uploadContentType, ...uploadHeaders } = lockedHeaders; + const uploadResp = await fetch(`${baseUrl}/api/projects/${projectId}/upload`, { + method: 'POST', + headers: uploadHeaders, + body: uploadForm, + }); + expect(uploadResp.status).toBe(403); + }); + + it('rejects member batch-delete for unknown legacy ownership and allows privileged delete', async () => { + const suffix = Date.now(); + const memberProjectId = `workspace-delete-member-${suffix}`; + const adminProjectId = `workspace-delete-admin-${suffix}`; + await createProject(memberProjectId, 'Member project'); + await list('member-a'); + await createProject(adminProjectId, 'Admin project'); + await list('member-admin'); + + const memberResp = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/batch-delete`, { + method: 'POST', + headers: headers('member-a'), + body: JSON.stringify({ projectIds: [memberProjectId] }), + }); + expect(memberResp.status).toBe(403); + + const memberStillExists = await fetch(`${baseUrl}/api/projects/${memberProjectId}`, { + headers: headers('member-a'), + }); + expect(memberStillExists.status).toBe(200); + + const adminResp = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/batch-delete`, { + method: 'POST', + headers: headers('member-admin', { 'x-od-workspace-role': 'admin' }), + body: JSON.stringify({ projectIds: [adminProjectId] }), + }); + expect(adminResp.status).toBe(200); + const deleted = await adminResp.json() as { deletedProjectIds: string[] }; + expect(deleted.deletedProjectIds).toEqual([adminProjectId]); + + const adminGone = await fetch(`${baseUrl}/api/projects/${adminProjectId}`); + expect(adminGone.status).toBe(404); + }); + + // A project has ONE workspace, so deleting it from that workspace deletes it + // outright — there is no second projection left holding it alive. This used to + // assert the opposite (that workspace B still listed it), which only held + // because the back-fill had put a copy of every project in every workspace. + it('deletes the project outright when its one workspace deletes it', async () => { + const suffix = Date.now(); + const projectId = `workspace-delete-shared-${suffix}`; + const workspaceA = `${workspaceId}-delete-a-${suffix}`; + const workspaceB = `${workspaceId}-delete-b-${suffix}`; + await createProject(projectId, 'Shared delete fixture'); + + const bodyA = await listInWorkspace(workspaceA, 'member-delete-a', '?view=all'); + expect(bodyA.projects.some((item) => item.id === projectId)).toBe(true); + const bodyB = await listInWorkspace(workspaceB, 'member-delete-b', '?view=all'); + expect(bodyB.projects.some((item) => item.id === projectId)).toBe(false); + + const deleteResp = await fetch(`${baseUrl}/api/workspaces/${workspaceA}/projects/batch-delete`, { + method: 'POST', + headers: workspaceHeaders(workspaceA, 'member-delete-a', { 'x-od-workspace-role': 'admin' }), + body: JSON.stringify({ projectIds: [projectId] }), + }); + expect(deleteResp.status).toBe(200); + + const baseProject = await fetch(`${baseUrl}/api/projects/${projectId}`); + expect(baseProject.status).toBe(404); + }); + + it('blocks deleting team-visible projects until the unshare seam exists', async () => { + const projectId = `workspace-delete-team-${Date.now()}`; + await createProject(projectId, 'Team delete fixture'); + + const moveResp = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/${projectId}/move`, { + method: 'POST', + headers: headers('member-delete-team', { + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'admin', + }), + body: JSON.stringify({ visibility: 'team' }), + }); + expect(moveResp.status).toBe(200); + + const deleteResp = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/batch-delete`, { + method: 'POST', + headers: headers('member-delete-team', { + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'admin', + }), + body: JSON.stringify({ projectIds: [projectId] }), + }); + + expect(deleteResp.status).toBe(403); + await expect(deleteResp.json()).resolves.toMatchObject({ + error: { + code: 'PROJECT_UNSHARE_UNSUPPORTED', + }, + }); + + const stillExists = await fetch(`${baseUrl}/api/projects/${projectId}`, { + headers: headers('member-delete-team', { + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'admin', + }), + }); + expect(stillExists.status).toBe(200); + }); + + it('fails batch-delete when project directory cleanup fails', async () => { + const projectId = `workspace-delete-cleanup-fails-${Date.now()}`; + const dbDeleteProject = vi.fn(); + const removeProjectDir = vi.fn(async () => { + throw new Error('cleanup failed'); + }); + const stageProjectDirsForDelete = vi.fn(async () => { + throw new Error('cleanup failed'); + }); + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, workspaceProjectRouteDeps({ + workspaceId, + projectId, + dbDeleteProject, + removeProjectDir, + stageProjectDirsForDelete, + countWorkspaceProjectRefs: vi.fn(() => 1), + })); + const routeServer = await listen(app); + try { + const deleteResp = await fetch(`${routeServer.url}/api/workspaces/${workspaceId}/projects/batch-delete`, { + method: 'POST', + headers: headers('member-cleanup-fail'), + body: JSON.stringify({ projectIds: [projectId] }), + }); + expect(deleteResp.status).toBe(400); + expect(stageProjectDirsForDelete).toHaveBeenCalledWith('projects', [projectId], 'id'); + expect(removeProjectDir).not.toHaveBeenCalled(); + expect(dbDeleteProject).not.toHaveBeenCalled(); + } finally { + await close(routeServer.server); + } + }); + + it('merges Vela team-project catalog entries as read-only member-discovery projects', async () => { + const localProjectId = `workspace-local-${Date.now()}`; + const remoteProjectId = `workspace-remote-${Date.now()}`; + const remoteResourceId = `project-remote-${remoteProjectId}`; + const teamProjectCatalog = { + list: vi.fn(async () => [ + { + id: `catalog-${remoteProjectId}`, + workspaceId, + projectId: remoteProjectId, + resourceId: remoteResourceId, + ownerMemberId: 'member-owner', + displayName: 'Remote shared project', + syncState: 'synced', + lastSyncedVersionId: 'version-1', + createdAt: new Date(10).toISOString(), + updatedAt: new Date(20).toISOString(), + access: { + canView: true, + canComment: true, + canEdit: true, + frozen: false, + }, + }, + ]), + upsert: vi.fn(), + }; + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, workspaceProjectRouteDeps({ + workspaceId, + projectId: localProjectId, + dbDeleteProject: vi.fn(), + removeProjectDir: vi.fn(), + teamProjectCatalog, + })); + const routeServer = await listen(app); + try { + const resp = await fetch(`${routeServer.url}/api/workspaces/${workspaceId}/projects?view=team`, { + headers: headers('member-viewer', { 'x-od-workspace-type': 'team' }), + }); + expect(resp.status).toBe(200); + const body = await resp.json() as { projects: Array }; + expect(teamProjectCatalog.list).toHaveBeenCalledWith({ + memberId: 'member-viewer', + teamId: workspaceId, + role: 'member', + lifecycleState: 'active', + }); + expect(body.projects).toHaveLength(1); + expect(body.projects[0]).toMatchObject({ + id: remoteResourceId, + name: 'Remote shared project', + visibility: 'team', + resourceState: 'active', + createdByWorkspaceMemberId: 'member-owner', + resourceHubResourceId: remoteResourceId, + syncState: 'synced', + currentUserAccess: { + canOpen: true, + canRename: false, + canDelete: false, + canMoveToPersonal: false, + canRestoreVersion: false, + canExport: true, + }, + }); + expect(body.projects[0].project.id).toBe(remoteProjectId); + expect(body.projects[0].project.metadata).toEqual({ + sharedProjectPlaceholderAt: 20, + }); + } finally { + await close(routeServer.server); + } + }); + + it.each([ + ['syncing', 'pending_upload'], + ['failed', 'sync_failed'], + ] as const)( + 'uses the catalog title without persisting a foreign mirror as locally owned (%s)', + async (remoteSyncState, expectedSyncState) => { + const projectId = `workspace-materialized-placeholder-${Date.now()}`; + const adminMemberId = 'member-admin-viewer'; + const ownerMemberId = 'member-project-owner'; + const resourceId = `project-resource-${projectId}`; + const rebindWorkspaceProject = vi.fn(); + const teamProjectCatalog = { + list: vi.fn(async () => [ + { + id: `catalog-wrong-workspace-${projectId}`, + workspaceId: 'ws-other', + projectId, + resourceId, + ownerMemberId, + displayName: 'Wrong workspace title', + syncState: 'synced', + lastSyncedVersionId: 'version-wrong-workspace', + createdAt: new Date(1).toISOString(), + updatedAt: new Date(2).toISOString(), + access: { + canView: true, + canComment: true, + canEdit: false, + frozen: false, + }, + }, + { + id: `catalog-${projectId}`, + workspaceId, + projectId, + resourceId, + ownerMemberId, + displayName: 'Owner project title', + syncState: remoteSyncState, + lastSyncedVersionId: 'version-1', + createdAt: new Date(10).toISOString(), + updatedAt: new Date(20).toISOString(), + access: { + canView: true, + canComment: true, + canEdit: false, + frozen: false, + }, + }, + ]), + upsert: vi.fn(), + }; + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, workspaceProjectRouteDeps({ + workspaceId, + projectId, + dbDeleteProject: vi.fn(), + removeProjectDir: vi.fn(), + teamProjectCatalog, + rebindWorkspaceProject, + workspaceRowOverrides: { + name: '共享项目', + visibility: 'team', + workspaceVisibility: 'team', + resourceHubResourceId: resourceId, + createdByWorkspaceMemberId: null, + updatedByWorkspaceMemberId: adminMemberId, + syncState: 'synced', + }, + })); + const routeServer = await listen(app); + try { + const resp = await fetch(`${routeServer.url}/api/workspaces/${workspaceId}/projects?view=team`, { + headers: headers(adminMemberId, { + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'admin', + }), + }); + expect(resp.status).toBe(200); + const body = await resp.json() as { projects: Array }; + expect(body.projects).toHaveLength(1); + expect(body.projects[0]).toMatchObject({ + id: projectId, + name: 'Owner project title', + createdByWorkspaceMemberId: ownerMemberId, + updatedByWorkspaceMemberId: adminMemberId, + resourceHubResourceId: resourceId, + currentUserAccess: { + canRename: false, + canDelete: false, + canMoveToPersonal: false, + }, + project: { + id: projectId, + name: 'Owner project title', + }, + syncState: expectedSyncState, + }); + expect(rebindWorkspaceProject).toHaveBeenCalledWith( + expect.anything(), + projectId, + expect.objectContaining({ + workspaceId, + visibility: 'team', + createdByWorkspaceMemberId: null, + updatedByWorkspaceMemberId: adminMemberId, + resourceHubResourceId: resourceId, + syncState: expectedSyncState, + }), + ); + const persistedPatch = rebindWorkspaceProject.mock.calls[0]?.[2] as { + createdByWorkspaceMemberId?: string | null; + }; + expect(recoverPersistedTeamShareOwnership({ + projectId, + workspaceId, + createdByWorkspaceMemberId: persistedPatch.createdByWorkspaceMemberId ?? null, + updatedByWorkspaceMemberId: adminMemberId, + })).toBeNull(); + expect(teamProjectCatalog.list).toHaveBeenCalledTimes(1); + expect(teamProjectCatalog.list).toHaveBeenCalledWith({ + memberId: adminMemberId, + teamId: workspaceId, + role: 'admin', + lifecycleState: 'active', + }); + } finally { + await close(routeServer.server); + } + }, + ); + + it('does not merge remote team projects into a personal workspace list (isolation)', async () => { + const remoteProjectId = `workspace-personal-leak-${Date.now()}`; + const teamProjectCatalog = { + list: vi.fn(async () => [ + { + id: `catalog-${remoteProjectId}`, + workspaceId, + projectId: remoteProjectId, + resourceId: `project-remote-${remoteProjectId}`, + ownerMemberId: 'member-owner', + displayName: 'Team project that must not leak', + syncState: 'synced', + lastSyncedVersionId: 'version-1', + createdAt: new Date(10).toISOString(), + updatedAt: new Date(20).toISOString(), + access: { canView: true, canComment: true, canEdit: true, frozen: false }, + }, + ]), + upsert: vi.fn(), + }; + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, workspaceProjectRouteDeps({ + workspaceId, + projectId: `workspace-personal-local-${Date.now()}`, + dbDeleteProject: vi.fn(), + removeProjectDir: vi.fn(), + teamProjectCatalog, + })); + const routeServer = await listen(app); + try { + // Personal workspace context (no team type header). The Vela team catalog + // lister is scoped to the active team, so without the workspace-type guard + // the team project would leak into — and duplicate within — the personal + // list. A personal workspace must never fetch or merge team projects. + const resp = await fetch(`${routeServer.url}/api/workspaces/${workspaceId}/projects?view=all`, { + headers: headers('member-personal'), + }); + expect(resp.status).toBe(200); + const body = await resp.json() as { projects: Array }; + expect(body.projects.some((item) => item.id === remoteProjectId)).toBe(false); + expect(teamProjectCatalog.list).not.toHaveBeenCalled(); + } finally { + await close(routeServer.server); + } + }); + + it('keeps remote team-project discovery entries distinct from local-id collisions', async () => { + const collidingProjectId = `workspace-collide-${Date.now()}`; + const remoteA = `resource-a-${collidingProjectId}`; + const remoteB = `resource-b-${collidingProjectId}`; + const teamProjectCatalog = { + list: vi.fn(async () => [ + { + id: `catalog-a-${collidingProjectId}`, + workspaceId, + projectId: collidingProjectId, + resourceId: remoteA, + ownerMemberId: 'member-owner-a', + displayName: 'Remote A', + syncState: 'synced', + lastSyncedVersionId: 'version-a', + createdAt: new Date(10).toISOString(), + updatedAt: new Date(20).toISOString(), + access: { + canView: true, + canComment: true, + canEdit: true, + frozen: false, + }, + }, + { + id: `catalog-b-${collidingProjectId}`, + workspaceId, + projectId: collidingProjectId, + resourceId: remoteB, + ownerMemberId: 'member-owner-b', + displayName: 'Remote B', + syncState: 'synced', + lastSyncedVersionId: 'version-b', + createdAt: new Date(11).toISOString(), + updatedAt: new Date(21).toISOString(), + access: { + canView: true, + canComment: true, + canEdit: true, + frozen: false, + }, + }, + ]), + upsert: vi.fn(), + }; + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, workspaceProjectRouteDeps({ + workspaceId, + projectId: collidingProjectId, + dbDeleteProject: vi.fn(), + removeProjectDir: vi.fn(), + teamProjectCatalog, + })); + const routeServer = await listen(app); + try { + const resp = await fetch(`${routeServer.url}/api/workspaces/${workspaceId}/projects?view=team`, { + headers: headers('member-viewer', { 'x-od-workspace-type': 'team' }), + }); + expect(resp.status).toBe(200); + const body = await resp.json() as { projects: Array }; + expect(body.projects.map((project: any) => project.id)).toEqual( + expect.arrayContaining([remoteA, remoteB]), + ); + expect(new Set(body.projects.map((project: any) => project.id)).size).toBe(body.projects.length); + expect(body.projects.every((project: any) => project.project.id === collidingProjectId)).toBe(true); + } finally { + await close(routeServer.server); + } + }); + + // RED LINE — "move back to 仅自己" must stick. The move route deletes the hub + // catalog row in the same request, but the team catalog is read through a + // stale-while-revalidate cache, so the next list can still carry the row that + // was just removed. The move also nulls `resourceHubResourceId` — the key the + // remote merge dedupes on — so before the fix that stale row came back as a + // `visibility: 'team'` card and the project re-shared itself a moment after + // the user unshared it, with no way to undo (a remote summary is never + // `canMoveToPersonal`). The local `cloudTombstonedAt` is the truth here. + it('does not resurrect a project the member just unshared from a stale team catalog', async () => { + const projectId = `workspace-unshare-tombstone-${Date.now()}`; + const memberId = 'member-unshare-tombstone'; + const staleResourceId = projectResourceIdFor(projectId, workspacePrincipal(memberId, workspaceId, 'admin')); + // The catalog still reports the project as shared — exactly what the SWR + // cache serves for a few seconds after the hub row has been deleted. + const teamProjectCatalog = { + list: vi.fn(async () => [ + { + id: `catalog-${projectId}`, + workspaceId, + projectId, + resourceId: staleResourceId, + ownerMemberId: memberId, + displayName: 'Just unshared', + syncState: 'synced', + lastSyncedVersionId: 'version-1', + createdAt: new Date(10).toISOString(), + updatedAt: new Date(20).toISOString(), + access: { canView: true, canComment: true, canEdit: true, frozen: false }, + }, + ]), + upsert: vi.fn(), + }; + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, workspaceProjectRouteDeps({ + workspaceId, + projectId, + dbDeleteProject: vi.fn(), + removeProjectDir: vi.fn(), + teamProjectCatalog, + // The state the move route leaves behind after a successful unshare. + workspaceRowOverrides: { + workspaceVisibility: 'personal', + resourceHubResourceId: null, + cloudTombstonedAt: 1_700_000_000_000, + createdByWorkspaceMemberId: memberId, + updatedByWorkspaceMemberId: memberId, + }, + })); + const routeServer = await listen(app); + try { + const resp = await fetch(`${routeServer.url}/api/workspaces/${workspaceId}/projects?view=all`, { + headers: headers(memberId, { 'x-od-workspace-type': 'team', 'x-od-workspace-role': 'admin' }), + }); + expect(resp.status).toBe(200); + const body = await resp.json() as { projects: Array }; + const entries = body.projects.filter((item: any) => item.project?.id === projectId); + expect(entries).toHaveLength(1); + expect(entries[0].visibility).toBe('personal'); + // The stale catalog row must not come back as a second, team-visible card. + expect(body.projects.some((item: any) => item.id === staleResourceId)).toBe(false); + } finally { + await close(routeServer.server); + } + }); + + // The tombstone gate must stay owner-scoped: unsharing my own copy cannot + // hide a teammate's share of the same project id. + it('still shows a teammate share of a project id the reader has tombstoned', async () => { + const projectId = `workspace-unshare-teammate-${Date.now()}`; + const memberId = 'member-unshare-teammate'; + const teammateResourceId = `resource-teammate-${projectId}`; + const teamProjectCatalog = { + list: vi.fn(async () => [ + { + id: `catalog-${projectId}`, + workspaceId, + projectId, + resourceId: teammateResourceId, + ownerMemberId: 'member-someone-else', + displayName: 'Teammate share', + syncState: 'synced', + lastSyncedVersionId: 'version-1', + createdAt: new Date(10).toISOString(), + updatedAt: new Date(20).toISOString(), + access: { canView: true, canComment: true, canEdit: true, frozen: false }, + }, + ]), + upsert: vi.fn(), + }; + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, workspaceProjectRouteDeps({ + workspaceId, + projectId, + dbDeleteProject: vi.fn(), + removeProjectDir: vi.fn(), + teamProjectCatalog, + workspaceRowOverrides: { + workspaceVisibility: 'personal', + resourceHubResourceId: null, + cloudTombstonedAt: 1_700_000_000_000, + createdByWorkspaceMemberId: memberId, + updatedByWorkspaceMemberId: memberId, + }, + })); + const routeServer = await listen(app); + try { + const resp = await fetch(`${routeServer.url}/api/workspaces/${workspaceId}/projects?view=all`, { + headers: headers(memberId, { 'x-od-workspace-type': 'team', 'x-od-workspace-role': 'admin' }), + }); + expect(resp.status).toBe(200); + const body = await resp.json() as { projects: Array }; + expect(body.projects.some((item: any) => item.id === teammateResourceId)).toBe(true); + } finally { + await close(routeServer.server); + } + }); + + it('includes remote team-project catalog entries in owner-scoped lists', async () => { + const localProjectId = `workspace-local-owner-${Date.now()}`; + const remoteProjectId = `workspace-remote-owner-${Date.now()}`; + const remoteResourceId = `project-remote-${remoteProjectId}`; + const teamProjectCatalog = { + list: vi.fn(async () => [ + { + id: `catalog-${remoteProjectId}`, + workspaceId, + projectId: remoteProjectId, + resourceId: remoteResourceId, + ownerMemberId: 'member-owner', + displayName: 'Remote owned project', + syncState: 'synced', + lastSyncedVersionId: 'version-1', + createdAt: new Date(10).toISOString(), + updatedAt: new Date(20).toISOString(), + access: { + canView: true, + canComment: true, + canEdit: true, + frozen: false, + }, + }, + ]), + upsert: vi.fn(), + }; + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, workspaceProjectRouteDeps({ + workspaceId, + projectId: localProjectId, + dbDeleteProject: vi.fn(), + removeProjectDir: vi.fn(), + teamProjectCatalog, + })); + const routeServer = await listen(app); + try { + const resp = await fetch(`${routeServer.url}/api/workspaces/${workspaceId}/projects?owner=others`, { + headers: headers('member-viewer', { 'x-od-workspace-type': 'team' }), + }); + expect(resp.status).toBe(200); + const body = await resp.json() as { projects: Array }; + expect(teamProjectCatalog.list).toHaveBeenCalled(); + expect(body.projects.some((item: any) => item.id === remoteResourceId)).toBe(true); + } finally { + await close(routeServer.server); + } + }); + + it('fails workspace project listing when the remote team catalog is unavailable', async () => { + const projectId = `workspace-catalog-fails-${Date.now()}`; + const teamProjectCatalog = { + list: vi.fn(async () => { + throw new Error('catalog unavailable'); + }), + upsert: vi.fn(), + }; + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, workspaceProjectRouteDeps({ + workspaceId, + projectId, + dbDeleteProject: vi.fn(), + removeProjectDir: vi.fn(), + teamProjectCatalog, + })); + const routeServer = await listen(app); + try { + const resp = await fetch(`${routeServer.url}/api/workspaces/${workspaceId}/projects?view=team`, { + headers: headers('member-viewer', { 'x-od-workspace-type': 'team' }), + }); + expect(resp.status).toBe(502); + await expect(resp.json()).resolves.toMatchObject({ + error: { + code: 'TEAM_PROJECT_CATALOG_UNAVAILABLE', + }, + }); + + teamProjectCatalog.list.mockClear(); + const personalResp = await fetch(`${routeServer.url}/api/workspaces/${workspaceId}/projects?visibility=personal`, { + headers: headers('member-viewer', { 'x-od-workspace-type': 'team' }), + }); + expect(personalResp.status).toBe(200); + await expect(personalResp.json()).resolves.toMatchObject({ + projects: [ + { + id: projectId, + visibility: 'personal', + }, + ], + }); + expect(teamProjectCatalog.list).not.toHaveBeenCalled(); + + const personalOwnerResp = await fetch(`${routeServer.url}/api/workspaces/${workspaceId}/projects?owner=mine&visibility=personal`, { + headers: headers('member-viewer', { 'x-od-workspace-type': 'team' }), + }); + expect(personalOwnerResp.status).toBe(200); + expect(teamProjectCatalog.list).not.toHaveBeenCalled(); + } finally { + await close(routeServer.server); + } + }); + + // Acceptance #53: a project the user had just shared did not show up in + // 全部项目 for ~17s. The client refetches as soon as the move responds, but + // that read was served the pre-move list out of the daemon's SWR cache, so + // the new row waited for a later poll — up to 60s once SSE lowers the + // client's cadence. The move has to drop the cache it just invalidated. + it('drops the cached team-project catalog after a visibility change', async () => { + const projectId = `workspace-share-invalidate-${Date.now()}`; + const invalidateTeamProjectCatalog = vi.fn(); + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, workspaceProjectRouteDeps({ + workspaceId, + projectId, + dbDeleteProject: vi.fn(), + removeProjectDir: vi.fn(), + collabSync: { + requestTeamShare: vi.fn(async () => ({ version: 1 })), + requestTeamUnshare: vi.fn(async () => {}), + invalidateTeamProjectCatalog, + }, + })); + const routeServer = await listen(app); + try { + const moveResp = await fetch(`${routeServer.url}/api/workspaces/${workspaceId}/projects/${projectId}/move`, { + method: 'POST', + headers: headers('member-share-principal', { + 'x-od-workspace-role': 'admin', + 'x-od-workspace-lifecycle-state': 'active', + }), + body: JSON.stringify({ visibility: 'team' }), + }); + expect(moveResp.status).toBe(200); + expect(invalidateTeamProjectCatalog).toHaveBeenCalled(); + } finally { + await close(routeServer.server); + } + }); + + // The invalidation is an optimization layered on top of a write that already + // landed. A seam that throws must not turn a successful share into a failure. + it('still reports the move as succeeded when catalog invalidation throws', async () => { + const projectId = `workspace-share-invalidate-throws-${Date.now()}`; + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, workspaceProjectRouteDeps({ + workspaceId, + projectId, + dbDeleteProject: vi.fn(), + removeProjectDir: vi.fn(), + collabSync: { + requestTeamShare: vi.fn(async () => ({ version: 1 })), + requestTeamUnshare: vi.fn(async () => {}), + invalidateTeamProjectCatalog: vi.fn(() => { + throw new Error('cache seam exploded'); + }), + }, + })); + const routeServer = await listen(app); + try { + const moveResp = await fetch(`${routeServer.url}/api/workspaces/${workspaceId}/projects/${projectId}/move`, { + method: 'POST', + headers: headers('member-share-principal', { + 'x-od-workspace-role': 'admin', + 'x-od-workspace-lifecycle-state': 'active', + }), + body: JSON.stringify({ visibility: 'team' }), + }); + expect(moveResp.status).toBe(200); + } finally { + await close(routeServer.server); + } + }); + + it('passes the authorized workspace principal into the team-share sync seam', async () => { + const projectId = `workspace-share-principal-${Date.now()}`; + const requestTeamShare = vi.fn(async () => ({ version: 1 })); + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, workspaceProjectRouteDeps({ + workspaceId, + projectId, + dbDeleteProject: vi.fn(), + removeProjectDir: vi.fn(), + collabSync: { requestTeamShare }, + })); + const routeServer = await listen(app); + try { + const moveResp = await fetch(`${routeServer.url}/api/workspaces/${workspaceId}/projects/${projectId}/move`, { + method: 'POST', + headers: headers('member-share-principal', { + 'x-od-workspace-role': 'admin', + 'x-od-workspace-lifecycle-state': 'active', + }), + body: JSON.stringify({ visibility: 'team' }), + }); + expect(moveResp.status).toBe(200); + expect(requestTeamShare).toHaveBeenCalledWith(projectId, { + memberId: 'member-share-principal', + teamId: workspaceId, + role: 'admin', + lifecycleState: 'active', + }); + } finally { + await close(routeServer.server); + } + }); + + it('does not mark workspace projects as team-visible when durable team share publishing fails', async () => { + const projectId = `workspace-share-rejected-${Date.now()}`; + const requestTeamShare = vi.fn(async () => { + throw new Error('resource hub unavailable'); + }); + const updateWorkspaceProject = vi.fn(); + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, workspaceProjectRouteDeps({ + workspaceId, + projectId, + dbDeleteProject: vi.fn(), + removeProjectDir: vi.fn(), + collabSync: { requestTeamShare }, + updateWorkspaceProject, + })); + const routeServer = await listen(app); + try { + const moveResp = await fetch(`${routeServer.url}/api/workspaces/${workspaceId}/projects/${projectId}/move`, { + method: 'POST', + headers: headers('member-share-rejected', { + 'x-od-workspace-role': 'admin', + 'x-od-workspace-lifecycle-state': 'active', + }), + body: JSON.stringify({ visibility: 'team' }), + }); + expect(moveResp.status).toBe(400); + expect(requestTeamShare).toHaveBeenCalledWith(projectId, { + memberId: 'member-share-rejected', + teamId: workspaceId, + role: 'admin', + lifecycleState: 'active', + }); + expect(updateWorkspaceProject).toHaveBeenCalledTimes(2); + expect(updateWorkspaceProject.mock.calls[0]?.[3]).toMatchObject({ + visibility: 'team', + syncState: 'pending_upload', + }); + expect(updateWorkspaceProject.mock.calls[1]?.[3]).toMatchObject({ + visibility: 'personal', + syncState: 'local_only', + resourceHubResourceId: null, + }); + updateWorkspaceProject.mockClear(); + requestTeamShare.mockClear(); + + const batchResp = await fetch(`${routeServer.url}/api/workspaces/${workspaceId}/projects/batch-move`, { + method: 'POST', + headers: headers('member-share-rejected', { + 'x-od-workspace-role': 'admin', + 'x-od-workspace-lifecycle-state': 'active', + }), + body: JSON.stringify({ projectIds: [projectId], visibility: 'team' }), + }); + expect(batchResp.status).toBe(400); + expect(updateWorkspaceProject).toHaveBeenCalledTimes(2); + expect(updateWorkspaceProject.mock.calls[0]?.[3]).toMatchObject({ + visibility: 'team', + syncState: 'pending_upload', + }); + expect(updateWorkspaceProject.mock.calls[1]?.[3]).toMatchObject({ + visibility: 'personal', + syncState: 'local_only', + resourceHubResourceId: null, + }); + } finally { + await close(routeServer.server); + } + }); + + it('blocks moving frozen team projects back to personal', async () => { + const projectId = `workspace-frozen-${Date.now()}`; + await createProject(projectId, 'Frozen project'); + await list('member-frozen'); + + const moveToTeam = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/${projectId}/move`, { + method: 'POST', + headers: headers('member-frozen', { + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'admin', + }), + body: JSON.stringify({ visibility: 'team' }), + }); + expect(moveToTeam.status).toBe(200); + const shareStatus = await fetch(`${baseUrl}/api/projects/${projectId}/collab/status`, { + headers: headers('member-frozen', { + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'admin', + }), + }); + expect(shareStatus.status).toBe(200); + const share = await shareStatus.json() as { syncState: string; ownerMemberId: string | null }; + expect(['pending_upload', 'synced']).toContain(share.syncState); + expect(share.ownerMemberId).toBe('member-frozen'); + + const lockedList = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects?view=team`, { + headers: headers('member-frozen', { 'x-od-workspace-lifecycle-state': 'locked' }), + }); + expect(lockedList.status).toBe(200); + const lockedBody = await lockedList.json() as { projects: Array }; + const frozen = lockedBody.projects.find((item: any) => item.id === projectId); + expect(frozen.resourceState).toBe('frozen'); + expect(frozen.currentUserAccess.canMoveToPersonal).toBe(false); + expect(frozen.currentUserAccess.canDuplicate).toBe(false); + + const moveToPersonal = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/${projectId}/move`, { + method: 'POST', + headers: headers('member-frozen', { 'x-od-workspace-lifecycle-state': 'locked' }), + body: JSON.stringify({ visibility: 'personal' }), + }); + expect(moveToPersonal.status).toBe(403); + }); + + it('derives sharing authority from verified role instead of caller-supplied permission bits', async () => { + const projectId = `workspace-share-permission-${Date.now()}`; + await createProjectInWorkspace( + projectId, + 'Share permission project', + 'member-share-permission', + { + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'admin', + }, + ); + + const bodyResp = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects?visibility=personal`, { + headers: headers('member-share-permission', { + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'admin', + }), + }); + expect(bodyResp.status).toBe(200); + const body = await bodyResp.json() as { projects: Array }; + const project = body.projects.find((item: any) => item.id === projectId); + expect(project.currentUserAccess.canRename).toBe(true); + expect(project.currentUserAccess.canMoveToTeam).toBe(true); + + const restrictedList = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects?visibility=personal`, { + headers: headers('member-share-permission', { + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'admin', + 'x-od-workspace-can-share-projects': 'false', + }), + }); + expect(restrictedList.status).toBe(200); + const restrictedBody = await restrictedList.json() as { projects: Array }; + const restrictedProject = restrictedBody.projects.find((item: any) => item.id === projectId); + expect(restrictedProject.currentUserAccess.canRename).toBe(true); + expect(restrictedProject.currentUserAccess.canMoveToTeam).toBe(false); + + const moveResp = await fetch(`${baseUrl}/api/workspaces/${workspaceId}/projects/${projectId}/move`, { + method: 'POST', + headers: headers('member-share-permission', { + 'x-od-workspace-type': 'team', + 'x-od-workspace-role': 'admin', + 'x-od-workspace-can-share-projects': 'false', + }), + body: JSON.stringify({ visibility: 'team' }), + }); + expect(moveResp.status).toBe(200); + }); +}); + +function workspaceProjectRouteDeps({ + workspaceId, + projectId, + dbDeleteProject, + removeProjectDir, + stageProjectDirsForDelete, + deleteWorkspaceProject, + countWorkspaceProjectRefs, + teamProjectCatalog, + collabSync, + updateWorkspaceProject, + rebindWorkspaceProject, + workspaceRowOverrides, +}: { + workspaceId: string; + projectId: string; + dbDeleteProject: ReturnType; + removeProjectDir: ReturnType; + stageProjectDirsForDelete?: ReturnType; + deleteWorkspaceProject?: ReturnType; + countWorkspaceProjectRefs?: ReturnType; + teamProjectCatalog?: unknown; + collabSync?: unknown; + updateWorkspaceProject?: ReturnType; + rebindWorkspaceProject?: ReturnType; + workspaceRowOverrides?: Record; +}) { + const now = 1; + const project = { + id: projectId, + name: 'Cleanup failure project', + skillId: null, + designSystemId: null, + pendingPrompt: null, + metadataJson: null, + createdAt: now, + updatedAt: now, + }; + const workspaceRow = { + ...project, + workspaceProjectId: projectId, + workspaceId, + workspaceVisibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId: 'member-cleanup-fail', + updatedByWorkspaceMemberId: 'member-cleanup-fail', + resourceHubResourceId: null, + cloudTombstonedAt: null, + syncState: 'local_only', + workspaceVersion: 1, + workspaceCreatedAt: now, + workspaceUpdatedAt: now, + ...workspaceRowOverrides, + }; + const noop = vi.fn(); + return { + db: { + transaction: (fn: (ids: string[]) => void) => fn, + }, + design: {}, + http: { + createSseResponse: noop, + sendApiError: (res: any, status: number, code: string, message: string) => + res.status(status).json({ error: { code, message } }), + }, + paths: { + DESIGN_SYSTEMS_DIR: '', + PROJECTS_DIR: 'projects', + SKILLS_DIR: '', + BRANDS_DIR: '', + USER_DESIGN_SYSTEMS_DIR: '', + }, + projectStore: { + insertProject: noop, + validateLinkedDirs: () => ({ dirs: [] }), + getProject: (_db: unknown, requestedProjectId: string) => + requestedProjectId === projectId ? project : null, + updateProject: noop, + dbDeleteProject, + removeProjectDir, + stageProjectDirsForDelete: stageProjectDirsForDelete ?? vi.fn(async () => ({ + rollback: vi.fn(async () => {}), + commit: vi.fn(async () => {}), + })), + deleteWorkspaceProject: deleteWorkspaceProject ?? noop, + countWorkspaceProjectRefs: countWorkspaceProjectRefs ?? vi.fn(() => 1), + ensureWorkspaceProject: () => workspaceRow, + getWorkspaceProject: () => workspaceRow, + // A project belongs to one workspace, so the routes look its binding up by + // project id alone (see collab/workspace-project-home.ts). + getWorkspaceProjectByProjectId: () => workspaceRow, + listWorkspaceProjectBindings: () => new Map([[projectId, workspaceId]]), + listWorkspaceProjects: () => [workspaceRow], + updateWorkspaceProject: updateWorkspaceProject ?? noop, + rebindWorkspaceProject: rebindWorkspaceProject ?? noop, + }, + projectFiles: { + writeProjectFile: noop, + readProjectFile: noop, + ensureProject: noop, + listFiles: () => [], + listTabs: () => [], + setTabs: noop, + resolveProjectDir: () => '', + }, + conversations: { insertConversation: noop }, + templates: { + getTemplate: noop, + listTemplates: () => [], + deleteTemplate: noop, + insertTemplate: noop, + findTemplateByNameAndProject: noop, + updateTemplate: noop, + }, + status: { + listLatestProjectRunStatuses: () => new Map(), + listProjectsAwaitingInput: () => new Set(), + normalizeProjectDisplayStatus: (status: string) => status, + composeProjectDisplayStatus: (status: unknown) => status, + listProjects: () => [], + }, + events: { + subscribeFileEvents: noop, + activeProjectEventSinks: new Map(), + }, + ids: { randomId: () => 'id' }, + telemetry: { reportFinalizedMessage: noop }, + appConfig: { readAppConfig: vi.fn(async () => ({})), writeAppConfig: noop }, + agents: {}, + validation: { + validateProjectDesignSystemId: async () => ({ ok: true, id: null }), + validateProjectSkillId: async () => ({ ok: true, id: null }), + }, + collabSync: collabSync ?? { requestTeamShare: noop }, + teamProjectCatalog, + } as unknown as Parameters[1]; +} + +async function listen(app: express.Express): Promise<{ server: http.Server; url: string }> { + const server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('server did not bind to a TCP port'); + return { + server, + url: `http://127.0.0.1:${address.port}`, + }; +} + +async function close(server: http.Server): Promise { + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); +} + +describe('GET /api/projects/:id/workspace-scope route bootstrap', () => { + const projectId = 'route-bootstrap-project-a'; + const workspaceId = 'route-bootstrap-workspace-a'; + const memberId = 'route-bootstrap-member-a'; + const activeMembership = { + workspaceId, + workspaceName: 'Workspace A', + workspaceType: 'team' as const, + workspaceMemberId: memberId, + role: 'member' as const, + memberStatus: 'active' as const, + lifecycleState: 'active' as const, + }; + + async function startBootstrapRoute(options: { + directory?: () => Promise; + resourceState?: string; + unbound?: boolean; + } = {}) { + const deps = workspaceProjectRouteDeps({ + workspaceId, + projectId, + dbDeleteProject: vi.fn(), + removeProjectDir: vi.fn(), + workspaceRowOverrides: { + workspaceVisibility: 'team', + ...(options.resourceState ? { resourceState: options.resourceState } : {}), + }, + }) as any; + if (options.unbound) { + deps.projectStore.getWorkspaceProject = () => null; + deps.projectStore.getWorkspaceProjectByProjectId = () => null; + } + deps.fetchWorkspaceDirectory = + options.directory + ?? (async () => ({ ok: true, items: [activeMembership] })); + deps.authorizeProjectRequest = vi.fn(async ( + req: express.Request, + res: express.Response, + ) => { + if (options.unbound) return true; + const claimedWorkspaceId = req.get('x-od-workspace-id'); + const claimedMemberId = req.get('x-od-workspace-member-id'); + if (!claimedWorkspaceId || !claimedMemberId) { + res.status(400).json({ + error: { code: 'WORKSPACE_CONTEXT_INCOMPLETE' }, + }); + return false; + } + if (claimedWorkspaceId !== workspaceId || claimedMemberId !== memberId) { + res.status(403).json({ + error: { code: 'WORKSPACE_PROJECT_PERMISSION_DENIED' }, + }); + return false; + } + return true; + }); + deps.http.sendApiError = ( + res: express.Response, + status: number, + code: string, + message: string, + details?: Record, + ) => res.status(status).json({ error: { code, message, ...details } }); + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, deps); + return listen(app); + } + + it('returns exact A scope headerlessly while keeping project content behind explicit A headers', async () => { + const routeServer = await startBootstrapRoute(); + try { + const scope = await fetch( + `${routeServer.url}/api/projects/${projectId}/workspace-scope`, + ); + expect(scope.status).toBe(200); + await expect(scope.json()).resolves.toMatchObject({ + scope: { + kind: 'team', + projectId, + workspaceId, + context: { + workspaceId, + workspaceMemberId: memberId, + }, + }, + }); + + const headerlessDetail = await fetch( + `${routeServer.url}/api/projects/${projectId}`, + ); + expect(headerlessDetail.status).toBe(400); + const headerlessFiles = await fetch( + `${routeServer.url}/api/projects/${projectId}/files`, + ); + expect(headerlessFiles.status).not.toBe(200); + + const scopedDetail = await fetch( + `${routeServer.url}/api/projects/${projectId}`, + { + headers: { + 'x-od-workspace-id': workspaceId, + 'x-od-workspace-member-id': memberId, + }, + }, + ); + expect(scopedDetail.status).toBe(200); + } finally { + await close(routeServer.server); + } + }); + + it('keeps partial and wrong explicit claims on the ordinary fail-closed gate', async () => { + const routeServer = await startBootstrapRoute(); + try { + const partial = await fetch( + `${routeServer.url}/api/projects/${projectId}/workspace-scope`, + { headers: { 'x-od-workspace-id': workspaceId } }, + ); + expect(partial.status).toBe(400); + const wrong = await fetch( + `${routeServer.url}/api/projects/${projectId}/workspace-scope`, + { + headers: { + 'x-od-workspace-id': workspaceId, + 'x-od-workspace-member-id': 'wrong-member', + }, + }, + ); + expect(wrong.status).toBe(403); + } finally { + await close(routeServer.server); + } + }); + + it('does not disclose scope for nonmembers, removed/deleted memberships, or deleted resources', async () => { + const deniedCases = [ + { + directory: async () => ({ + ok: true, + items: [{ ...activeMembership, workspaceId: 'workspace-b' }], + }), + }, + { + directory: async () => ({ + ok: true, + items: [{ ...activeMembership, memberStatus: 'removed' as const }], + }), + }, + { + directory: async () => ({ + ok: true, + items: [{ ...activeMembership, lifecycleState: 'deleted' as const }], + }), + }, + { + resourceState: 'deleted', + }, + ]; + for (const denied of deniedCases) { + const routeServer = await startBootstrapRoute(denied); + try { + const response = await fetch( + `${routeServer.url}/api/projects/${projectId}/workspace-scope`, + ); + expect(response.status).toBe(403); + const text = await response.text(); + expect(text).not.toContain(workspaceId); + expect(text).not.toContain(memberId); + } finally { + await close(routeServer.server); + } + } + }); + + it('returns retryable 503 on directory outage and 404 for a missing project', async () => { + const routeServer = await startBootstrapRoute({ + directory: async () => { + throw new Error('directory down'); + }, + }); + try { + const outage = await fetch( + `${routeServer.url}/api/projects/${projectId}/workspace-scope`, + ); + expect(outage.status).toBe(503); + await expect(outage.json()).resolves.toMatchObject({ + error: { + code: 'WORKSPACE_DIRECTORY_UNAVAILABLE', + retryable: true, + }, + }); + const missing = await fetch( + `${routeServer.url}/api/projects/missing-project/workspace-scope`, + ); + expect(missing.status).toBe(404); + } finally { + await close(routeServer.server); + } + }); + + it('keeps locked/frozen project reads available but read-only', async () => { + const routeServer = await startBootstrapRoute({ + resourceState: 'frozen', + directory: async () => ({ + ok: true, + items: [{ ...activeMembership, lifecycleState: 'locked' as const }], + }), + }); + try { + const response = await fetch( + `${routeServer.url}/api/projects/${projectId}/workspace-scope`, + ); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + scope: { + kind: 'team', + workspaceId, + context: { + lifecycleState: 'locked', + permissions: { + canShareProjects: false, + canWriteSyncedFiles: false, + }, + }, + }, + }); + } finally { + await close(routeServer.server); + } + }); + + it('preserves signed-out headerless scope and detail for an unbound local project', async () => { + const routeServer = await startBootstrapRoute({ + unbound: true, + directory: async () => { + throw new Error('signed out'); + }, + }); + try { + const scope = await fetch( + `${routeServer.url}/api/projects/${projectId}/workspace-scope`, + ); + expect(scope.status).toBe(200); + await expect(scope.json()).resolves.toEqual({ + scope: { + kind: 'unbound', + projectId, + workspaceId: null, + context: null, + }, + }); + expect( + (await fetch(`${routeServer.url}/api/projects/${projectId}`)).status, + ).toBe(200); + } finally { + await close(routeServer.server); + } + }); +}); diff --git a/apps/daemon/tests/routine-routes.test.ts b/apps/daemon/tests/routine-routes.test.ts index 0f8d40c0748..d8cba53bba3 100644 --- a/apps/daemon/tests/routine-routes.test.ts +++ b/apps/daemon/tests/routine-routes.test.ts @@ -7,8 +7,11 @@ import path from 'node:path'; import { closeDatabase, getRoutine, + ensureWorkspaceProject, insertProject, + insertRoutine, insertRoutineRun, + listRoutines, openDatabase, } from '../src/db.js'; import { registerRoutineRoutes } from '../src/routes/routine.js'; @@ -42,7 +45,9 @@ describe('routine routes', () => { vi.restoreAllMocks(); }); - function buildApp() { + function buildApp(options: { + fetchWorkspaceDirectory?: () => Promise; + } = {}) { const db = openDatabase(tempDir, { dataDir: tempDir }); const nextRunAt = vi.fn(() => new Date('2026-05-13T01:00:00.000Z')); const rescheduleOne = vi.fn(); @@ -79,11 +84,64 @@ describe('routine routes', () => { unschedule, }, }, + ...(options.fetchWorkspaceDirectory + ? { fetchWorkspaceDirectory: options.fetchWorkspaceDirectory } + : {}), } as any); return { app, db, nextRunAt, rescheduleOne, runNow, unschedule }; } + function seedRoutine( + db: any, + input: { + id: string; + projectMode?: 'create_each_run' | 'reuse'; + projectId?: string | null; + workspaceScope?: { workspaceId: string; workspaceMemberId: string } | null; + }, + ) { + const now = Date.now(); + insertRoutine(db, { + id: input.id, + name: input.id, + prompt: `Run ${input.id}`, + scheduleKind: 'daily', + scheduleValue: '09:00', + scheduleJson: JSON.stringify({ kind: 'daily', time: '09:00', timezone: 'UTC' }), + projectMode: input.projectMode ?? 'create_each_run', + projectId: input.projectId ?? null, + skillId: null, + agentId: null, + contextJson: JSON.stringify( + input.workspaceScope ? { workspaceScope: input.workspaceScope } : {}, + ), + enabled: true, + createdAt: now, + updatedAt: now, + }); + } + + function directoryItems() { + return [{ + workspaceId: 'workspace-a', + workspaceName: 'A', + workspaceType: 'team' as const, + workspaceMemberId: 'member-a', + role: 'owner' as const, + memberStatus: 'active' as const, + lifecycleState: 'active' as const, + }, { + workspaceId: 'workspace-b', + workspaceName: 'B', + workspaceType: 'team' as const, + workspaceMemberId: 'member-b', + role: 'owner' as const, + memberStatus: 'active' as const, + lifecycleState: 'active' as const, + }]; + } + it('lists and fetches built-in automation templates', async () => { const { app } = buildApp(); const { server, port } = await listen(app); @@ -117,6 +175,220 @@ describe('routine routes', () => { } }); + it('partitions routine REST reads by persisted scope and blocks B before A mutations', async () => { + const { app, db, rescheduleOne, runNow, unschedule } = buildApp({ + fetchWorkspaceDirectory: async () => ({ ok: true, items: directoryItems() }), + }); + seedRoutine(db, { + id: 'routine-a', + workspaceScope: { + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }, + }); + seedRoutine(db, { id: 'legacy-unbound' }); + const { server, port } = await listen(app); + const headersA = { + 'x-od-workspace-id': 'workspace-a', + 'x-od-workspace-member-id': 'member-a', + }; + const headersB = { + 'x-od-workspace-id': 'workspace-b', + 'x-od-workspace-member-id': 'member-b', + }; + try { + const listA = await fetch(`http://127.0.0.1:${port}/api/routines`, { + headers: headersA, + }); + expect(listA.status).toBe(200); + const listAJson = await listA.json() as { + routines: Array<{ id: string; context: any }>; + }; + expect(listAJson.routines.map((routine) => routine.id).sort()).toEqual([ + 'legacy-unbound', + 'routine-a', + ]); + expect( + listAJson.routines.find((routine) => routine.id === 'routine-a')?.context + .workspaceScope, + ).toEqual({ + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }); + + const listB = await fetch(`http://127.0.0.1:${port}/api/routines`, { + headers: headersB, + }); + expect(listB.status).toBe(200); + await expect(listB.json()).resolves.toMatchObject({ + routines: [{ id: 'legacy-unbound' }], + }); + + const attempts: Array<[string, RequestInit]> = [ + ['/api/routines/routine-a', { headers: headersB }], + ['/api/routines/routine-a', { + method: 'PATCH', + headers: { ...headersB, 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'B must not rename A' }), + }], + ['/api/routines/routine-a/runs?limit=10', { headers: headersB }], + ['/api/routines/routine-a/runs/missing/crystallize', { + method: 'POST', + headers: headersB, + }], + ['/api/routines/routine-a', { method: 'DELETE', headers: headersB }], + ]; + for (const [path, init] of attempts) { + const response = await fetch(`http://127.0.0.1:${port}${path}`, init); + expect(response.status, path).toBe(403); + } + + const runResponse = await fetch( + `http://127.0.0.1:${port}/api/routines/routine-a/run`, + { method: 'POST', headers: headersB }, + ); + expect(runResponse.status).toBe(202); + expect(getRoutine(db, 'routine-a')).toMatchObject({ + name: 'routine-a', + enabled: true, + }); + expect(rescheduleOne).not.toHaveBeenCalled(); + expect(runNow).toHaveBeenCalledWith('routine-a'); + expect(unschedule).not.toHaveBeenCalled(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it.each([ + { + label: 'removed membership', + expectedStatus: 403, + directory: { + ok: true as const, + items: [{ + ...directoryItems()[0], + memberStatus: 'removed' as const, + }], + }, + }, + { + label: 'authority outage', + expectedStatus: 503, + directory: { ok: false as const, items: [] }, + }, + ])('fails $label before scoped routine REST side effects', async ({ + directory, + expectedStatus, + }) => { + const { app, db, rescheduleOne, runNow, unschedule } = buildApp({ + fetchWorkspaceDirectory: async () => directory, + }); + seedRoutine(db, { + id: 'routine-a', + workspaceScope: { + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }, + }); + const { server, port } = await listen(app); + const headers = { + 'x-od-workspace-id': 'workspace-a', + 'x-od-workspace-member-id': 'member-a', + }; + try { + const attempts: Array<[string, RequestInit]> = [ + ['/api/routines', { headers }], + ['/api/routines/routine-a', { headers }], + ['/api/routines/routine-a', { + method: 'PATCH', + headers: { ...headers, 'content-type': 'application/json' }, + body: JSON.stringify({ enabled: false }), + }], + ['/api/routines/routine-a/runs', { headers }], + ['/api/routines/routine-a/runs/missing/crystallize', { + method: 'POST', + headers, + }], + ['/api/routines/routine-a', { method: 'DELETE', headers }], + ]; + for (const [path, init] of attempts) { + const response = await fetch(`http://127.0.0.1:${port}${path}`, init); + expect(response.status, path).toBe(expectedStatus); + } + + const runResponse = await fetch( + `http://127.0.0.1:${port}/api/routines/routine-a/run`, + { method: 'POST', headers }, + ); + expect(runResponse.status).toBe(202); + expect(getRoutine(db, 'routine-a')).toMatchObject({ + enabled: true, + name: 'routine-a', + }); + expect(rescheduleOne).not.toHaveBeenCalled(); + expect(runNow).toHaveBeenCalledWith('routine-a'); + expect(unschedule).not.toHaveBeenCalled(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('derives reuse routine REST authority from the target project binding', async () => { + const { app, db } = buildApp({ + fetchWorkspaceDirectory: async () => ({ ok: true, items: directoryItems() }), + }); + const now = Date.now(); + insertProject(db, { + id: 'project-a', + name: 'Project A', + createdAt: now, + updatedAt: now, + }); + ensureWorkspaceProject(db, { + projectId: 'project-a', + workspaceId: 'workspace-a', + visibility: 'team', + createdByWorkspaceMemberId: 'member-a', + }); + seedRoutine(db, { + id: 'reuse-a', + projectMode: 'reuse', + projectId: 'project-a', + }); + const { server, port } = await listen(app); + try { + const denied = await fetch(`http://127.0.0.1:${port}/api/routines/reuse-a`, { + headers: { + 'x-od-workspace-id': 'workspace-b', + 'x-od-workspace-member-id': 'member-b', + }, + }); + expect(denied.status).toBe(403); + + const allowed = await fetch(`http://127.0.0.1:${port}/api/routines/reuse-a`, { + headers: { + 'x-od-workspace-id': 'workspace-a', + 'x-od-workspace-member-id': 'member-a', + }, + }); + expect(allowed.status).toBe(200); + await expect(allowed.json()).resolves.toMatchObject({ + routine: { + id: 'reuse-a', + context: { + workspaceScope: { + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }, + }, + }, + }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it('creates a reuse-mode routine and includes the computed next run', async () => { const { app, db, rescheduleOne } = buildApp(); const now = Date.now(); @@ -186,6 +458,254 @@ describe('routine routes', () => { } }); + it('persists a verified create-each-run Workspace scope without consulting B', async () => { + const fetchWorkspaceDirectory = vi.fn(async () => ({ + ok: true, + items: [{ + workspaceId: 'workspace-a', + workspaceName: 'A', + workspaceType: 'team', + workspaceMemberId: 'member-a', + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + }, { + workspaceId: 'workspace-b', + workspaceName: 'B', + workspaceType: 'team', + workspaceMemberId: 'member-b', + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + }], + })); + const { app, db } = buildApp({ fetchWorkspaceDirectory }); + const { server, port } = await listen(app); + try { + const res = await fetch(`http://127.0.0.1:${port}/api/routines`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-od-workspace-id': 'workspace-a', + 'x-od-workspace-member-id': 'member-a', + }, + body: JSON.stringify({ + name: 'A digest', + prompt: 'Summarize A.', + schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' }, + target: { mode: 'create_each_run' }, + context: { + workspaceScope: { + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }, + }, + enabled: true, + }), + }); + + expect(res.status).toBe(201); + const json = await res.json() as { + routine: { + id: string; + context: { + workspaceScope: { workspaceId: string; workspaceMemberId: string }; + }; + }; + }; + expect(json.routine.context.workspaceScope).toEqual({ + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }); + expect(JSON.parse(getRoutine(db, json.routine.id)?.contextJson ?? '{}')).toMatchObject({ + workspaceScope: { + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }, + }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('preserves scoped identity when an authorized patch omits workspaceScope', async () => { + const fetchWorkspaceDirectory = vi.fn(async () => ({ + ok: true, + items: [{ + workspaceId: 'workspace-a', + workspaceName: 'A', + workspaceType: 'team', + workspaceMemberId: 'member-a', + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + }], + })); + const { app, db } = buildApp({ fetchWorkspaceDirectory }); + const { server, port } = await listen(app); + try { + const create = await fetch(`http://127.0.0.1:${port}/api/routines`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-od-workspace-id': 'workspace-a', + 'x-od-workspace-member-id': 'member-a', + }, + body: JSON.stringify({ + name: 'A digest', + prompt: 'Summarize A.', + schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' }, + target: { mode: 'create_each_run' }, + context: { + workspaceScope: { + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }, + }, + }), + }); + const created = await create.json() as { routine: { id: string } }; + expect(create.status).toBe(201); + expect(fetchWorkspaceDirectory).toHaveBeenCalledTimes(1); + + const patch = await fetch( + `http://127.0.0.1:${port}/api/routines/${created.routine.id}`, + { + method: 'PATCH', + headers: { + 'content-type': 'application/json', + 'x-od-workspace-id': 'workspace-a', + 'x-od-workspace-member-id': 'member-a', + }, + body: JSON.stringify({ + context: { connectorIds: ['github'] }, + }), + }, + ); + + expect(patch.status).toBe(200); + expect(fetchWorkspaceDirectory).toHaveBeenCalledTimes(2); + expect(JSON.parse(getRoutine(db, created.routine.id)?.contextJson ?? '{}')).toEqual({ + connectorIds: ['github'], + workspaceScope: { + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }, + }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('does not persist a scoped routine when authority is unavailable', async () => { + const { app, db } = buildApp({ + fetchWorkspaceDirectory: async () => ({ ok: false, items: [] }), + }); + const { server, port } = await listen(app); + try { + const res = await fetch(`http://127.0.0.1:${port}/api/routines`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-od-workspace-id': 'workspace-a', + 'x-od-workspace-member-id': 'member-a', + }, + body: JSON.stringify({ + name: 'A digest', + prompt: 'Summarize A.', + schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' }, + target: { mode: 'create_each_run' }, + context: { + workspaceScope: { + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }, + }, + }), + }); + + expect(res.status).toBe(503); + expect(listRoutines(db)).toHaveLength(0); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('drops shell Workspace scope for reuse routines because the project binding is authoritative', async () => { + const fetchWorkspaceDirectory = vi.fn(async () => ({ + ok: true, + items: [], + })); + const { app, db } = buildApp({ fetchWorkspaceDirectory }); + const now = Date.now(); + insertProject(db, { + id: 'project-a', + name: 'Project A', + createdAt: now, + updatedAt: now, + }); + const { server, port } = await listen(app); + try { + const res = await fetch(`http://127.0.0.1:${port}/api/routines`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-od-workspace-id': 'workspace-b', + 'x-od-workspace-member-id': 'member-b', + }, + body: JSON.stringify({ + name: 'Project A digest', + prompt: 'Summarize Project A.', + schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' }, + target: { mode: 'reuse', projectId: 'project-a' }, + context: { + connectorIds: ['github'], + workspaceScope: { + workspaceId: 'workspace-b', + workspaceMemberId: 'member-b', + }, + }, + }), + }); + + expect(res.status).toBe(201); + const json = await res.json() as { + routine: { id: string; context: Record }; + }; + expect(json.routine.context).toEqual({ connectorIds: ['github'] }); + expect(JSON.parse(getRoutine(db, json.routine.id)?.contextJson ?? '{}')) + .toEqual({ connectorIds: ['github'] }); + expect(fetchWorkspaceDirectory).not.toHaveBeenCalled(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('rejects malformed explicit scope instead of silently creating an unbound routine', async () => { + const { app, db } = buildApp(); + const { server, port } = await listen(app); + try { + const res = await fetch(`http://127.0.0.1:${port}/api/routines`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Broken scope', + prompt: 'Do not persist.', + schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' }, + target: { mode: 'create_each_run' }, + context: { + workspaceScope: { workspaceId: 'workspace-a' }, + }, + }), + }); + + expect(res.status).toBe(400); + expect(listRoutines(db)).toHaveLength(0); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it('patches enabled state and target mode, then reschedules the routine', async () => { const { app, db, rescheduleOne } = buildApp(); const now = Date.now(); diff --git a/apps/daemon/tests/routines.test.ts b/apps/daemon/tests/routines.test.ts index 9bad64f71df..bac3b0d031b 100644 --- a/apps/daemon/tests/routines.test.ts +++ b/apps/daemon/tests/routines.test.ts @@ -485,6 +485,46 @@ describe('RoutineService scheduled run idempotency', () => { }); }); + it('preserves persisted Workspace scope for execution without a membership re-check', async () => { + const persistence = new SharedRoutinePersistence([ + fixtureRoutine({ + context: { + workspaceScope: { + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }, + }, + }), + ]); + const service = new RoutineService(persistence); + const sideEffects = { + projects: 0, + conversations: 0, + agentRuns: 0, + }; + + service.setRunHandler(async ({ routine }) => { + expect(routine.context.workspaceScope).toEqual({ + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + }); + sideEffects.projects += 1; + sideEffects.conversations += 1; + sideEffects.agentRuns += 1; + return handlerStart('agent-run-1'); + }); + + await expect(service.runNow('routine-1')).resolves.toMatchObject({ + agentRunId: 'agent-run-1', + }); + expect(sideEffects).toEqual({ + projects: 1, + conversations: 1, + agentRuns: 1, + }); + expect(persistence.runs).toHaveLength(1); + }); + it('returns prepared IDs from successful manual runs', async () => { const persistence = new SharedRoutinePersistence([fixtureRoutine()]); const service = new RoutineService(persistence); diff --git a/apps/daemon/tests/run-cli.test.ts b/apps/daemon/tests/run-cli.test.ts index f37afe24e3d..9608dfb567a 100644 --- a/apps/daemon/tests/run-cli.test.ts +++ b/apps/daemon/tests/run-cli.test.ts @@ -16,6 +16,7 @@ interface CapturedRequest { method: string; url: string; body: string; + headers: http.IncomingHttpHeaders; } interface StubServer { @@ -43,6 +44,7 @@ async function startRunStubServer(resumable: boolean): Promise { method: req.method ?? '', url: req.url ?? '', body: raw, + headers: req.headers, }; requests.push(captured); res.setHeader('content-type', 'application/json'); @@ -60,12 +62,60 @@ async function startRunStubServer(resumable: boolean): Promise { return; } + if ( + captured.method === 'GET' + && (captured.url === '/api/runs' || captured.url === '/api/runs?projectId=project-1') + ) { + res.statusCode = 200; + res.end(JSON.stringify({ runs: [] })); + return; + } + + if ( + captured.method === 'GET' + && captured.url === '/api/runs/run-1/result-package' + ) { + res.statusCode = 200; + res.end(JSON.stringify({ run: { id: 'run-1', status: 'completed' } })); + return; + } + + if ( + captured.method === 'POST' + && captured.url === '/api/runs/run-1/cancel' + ) { + res.statusCode = 200; + res.end(JSON.stringify({ ok: true })); + return; + } + if (captured.method === 'POST' && captured.url === '/api/runs') { res.statusCode = 200; res.end(JSON.stringify({ runId: 'run-2' })); return; } + if (captured.method === 'POST' && captured.url === '/api/import/folder') { + res.statusCode = 200; + res.end(JSON.stringify({ + project: { id: 'imported-project' }, + conversationId: 'imported-conversation', + })); + return; + } + + if ( + captured.method === 'GET' + && (captured.url === '/api/runs/run-1/events' || captured.url === '/api/runs/run-2/events') + ) { + res.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + }); + res.end('event: end\ndata: {"status":"completed"}\n\n'); + return; + } + res.statusCode = 404; res.end(JSON.stringify({ error: { code: 'unexpected-request', message: captured.url } })); }); @@ -133,6 +183,10 @@ describe('od run CLI', () => { expect(JSON.parse(stub.requests[1]!.body).message).toContain( 'The previous turn was interrupted by a transient failure.', ); + for (const request of stub.requests) { + expect(request.headers['x-od-workspace-id']).toBeUndefined(); + expect(request.headers['x-od-workspace-member-id']).toBeUndefined(); + } }); it('refuses to continue a run without a safe recoverable native session', async () => { @@ -153,4 +207,119 @@ describe('od run CLI', () => { 'GET /api/runs/run-1', ]); }); + + it('forwards explicit Workspace scope through continue status and creation requests', async () => { + stub = await startRunStubServer(true); + + const result = await runCli([ + 'run', + 'continue', + 'run-1', + '--workspace', + 'team-workspace', + '--workspace-member', + 'creator-member', + '--daemon-url', + stub.baseUrl, + ]); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(stub.requests).toHaveLength(2); + for (const request of stub.requests) { + expect(request.headers['x-od-workspace-id']).toBe('team-workspace'); + expect(request.headers['x-od-workspace-member-id']).toBe('creator-member'); + } + }); + + it.each([ + { + label: 'list', + args: ['run', 'list', '--json'], + requests: ['GET /api/runs'], + }, + { + label: 'project list', + args: ['run', 'list', '--project', 'project-1', '--json'], + requests: ['GET /api/runs?projectId=project-1'], + }, + { + label: 'info', + args: ['run', 'info', 'run-1'], + requests: ['GET /api/runs/run-1'], + }, + { + label: 'result package', + args: ['run', 'result-package', 'run-1', '--json'], + requests: ['GET /api/runs/run-1/result-package'], + }, + { + label: 'cancel', + args: ['run', 'cancel', 'run-1'], + requests: ['POST /api/runs/run-1/cancel'], + }, + { + label: 'redesign', + args: ['run', 'redesign', '--project', 'project-1', '--json'], + requests: ['POST /api/runs'], + }, + { + label: 'redesign import and start', + args: ['run', 'redesign', '--path', DAEMON_ROOT, '--json'], + requests: ['POST /api/import/folder', 'POST /api/runs'], + }, + { + label: 'start and follow', + args: ['run', 'start', '--project', 'project-1', '--follow'], + requests: ['POST /api/runs', 'GET /api/runs/run-2/events'], + }, + { + label: 'watch', + args: ['run', 'watch', 'run-1'], + requests: ['GET /api/runs/run-1/events'], + }, + ])('forwards explicit Workspace scope for $label requests', async ({ args, requests }) => { + stub = await startRunStubServer(true); + + const result = await runCli([ + ...args, + '--workspace', + 'team-workspace', + '--workspace-member', + 'creator-member', + '--daemon-url', + stub.baseUrl, + ]); + + expect(result.code, result.stderr).toBe(0); + expect(stub.requests.map((request) => `${request.method} ${request.url}`)).toEqual(requests); + for (const request of stub.requests) { + expect(request.headers['x-od-workspace-id']).toBe('team-workspace'); + expect(request.headers['x-od-workspace-member-id']).toBe('creator-member'); + } + }); + + it('keeps no-scope run creation and streaming requests headerless', async () => { + stub = await startRunStubServer(true); + + const result = await runCli([ + 'run', + 'start', + '--project', + 'project-1', + '--follow', + '--daemon-url', + stub.baseUrl, + ]); + + expect(result.code, result.stderr).toBe(0); + expect(stub.requests.map((request) => `${request.method} ${request.url}`)).toEqual([ + 'POST /api/runs', + 'GET /api/runs/run-2/events', + ]); + for (const request of stub.requests) { + expect(request.headers['x-od-workspace-id']).toBeUndefined(); + expect(request.headers['x-od-workspace-member-id']).toBeUndefined(); + } + }); }); diff --git a/apps/daemon/tests/run-create-workspace-gate.test.ts b/apps/daemon/tests/run-create-workspace-gate.test.ts new file mode 100644 index 00000000000..c01c7096225 --- /dev/null +++ b/apps/daemon/tests/run-create-workspace-gate.test.ts @@ -0,0 +1,922 @@ +// Run creation is a billing-address boundary, not a local project-file +// mutation gate. The persisted `workspace_projects` row supplies the exact +// Team or Personal Workspace id to Vela/AMR. Headerless local callers are +// valid; an explicitly supplied pair is checked only for a Workspace mismatch. +// Membership, balance, and subscription eligibility remain backend decisions. + +import http from 'node:http'; +import express from 'express'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + closeDatabase, + ensureWorkspaceProject, + getWorkspaceProject, + getWorkspaceProjectByProjectId, + insertProject, + openDatabase, +} from '../src/db.js'; +import { createAuthorizeProjectRequest } from '../src/collab/project-request-authority.js'; +import { createEnforceWorkspaceProjectMutation } from '../src/routes/project/index.js'; +import { workspaceContextFromDirectoryItem } from '../src/collab/vela-workspace-context.js'; +import { registerRunRoutes } from '../src/routes/runs.js'; +import { connectorService } from '../src/connectors/service.js'; + +let server: http.Server | null = null; +let tempDir: string | null = null; + +afterEach(async () => { + if (server) { + const toClose = server; + server = null; + await new Promise((resolve) => toClose.close(() => resolve())); + } + closeDatabase(); + if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; +}); + +const TEAM_PROJECT = 'p-team-run'; +const PERSONAL_PROJECT = 'p-personal-run'; +const UNBOUND_PROJECT = 'p-unbound-run'; +const WORKSPACE_ID = 'ws-run-gate'; +const OWNER_MEMBER_ID = 'member-owner-run'; + +function sendApiError(res: any, status: number, code: string, message: string) { + return res.status(status).json({ error: { code, message } }); +} + +function workspaceHeaders(memberId: string, role: 'owner' | 'admin' | 'member') { + return { + 'x-od-workspace-id': WORKSPACE_ID, + 'x-od-workspace-member-id': memberId, + 'x-od-workspace-role': role, + }; +} + +// A minimal in-memory ChatRunService stub. It deliberately does not spawn a +// process, but it does preserve enough run state to exercise the complete +// create -> status/events -> cancel HTTP lifecycle. +function createRunsServiceStub() { + const runs = new Map(); + let seq = 0; + return { + create(meta: any) { + const run = { + id: `run-${++seq}`, + projectId: typeof meta.projectId === 'string' ? meta.projectId : null, + // Deliberately left unset: with no conversationId, the handler's + // post-response `detectSkillPluginCandidateOnRunSuccess` branch + // (gated on `run.projectId && run.conversationId`) never fires, so + // this stub does not need a real on-disk project directory. + conversationId: typeof meta.conversationId === 'string' ? meta.conversationId : null, + assistantMessageId: typeof meta.assistantMessageId === 'string' ? meta.assistantMessageId : null, + agentId: typeof meta.agentId === 'string' ? meta.agentId : null, + workspaceScope: meta.workspaceScope, + status: 'queued', + createdAt: Date.now(), + updatedAt: Date.now(), + events: [], + clients: new Set(), + }; + runs.set(run.id, run); + return run; + }, + get: (id: string) => runs.get(id) ?? null, + list: (filters: { projectId?: unknown } = {}) => + Array.from(runs.values()).filter( + (run) => + typeof filters.projectId !== 'string' + || run.projectId === filters.projectId, + ), + statusBody: (run: any) => ({ ...run }), + stream: (run: any, req: any, res: any) => { + res.status(req.method === 'GET' ? 200 : 202).json({ runId: run.id }); + }, + // Intentionally does NOT invoke `starter` — this test only asserts on the + // HTTP response to POST /api/runs, not on real agent-process spawning. + start: (run: any) => run, + wait: async () => ({ status: 'succeeded' }), + cancel: async (run: any) => { + run.status = 'canceled'; + run.updatedAt = Date.now(); + return { ...run }; + }, + isTerminal: (status: string) => status === 'succeeded' || status === 'failed' || status === 'canceled', + }; +} + +async function startServer(opts?: { + /** + * Legacy mutation-gate seam retained by RegisterRunRoutes for compatibility. + * Run creation deliberately ignores its membership verdict: only the + * persisted binding and an optional explicit Workspace mismatch matter. + */ + enforceWorkspaceProjectMutation?: ( + req: any, + res: any, + sendError: any, + getWp: any, + getWpByProjectId: any, + dbArg: any, + projectId: string, + capability: any, + ) => Promise; + isAmrSignedIn?: () => boolean | Promise; + verifyWorkspaceRequestAuthority?: (req: any) => Promise; + teamProjectResourceState?: 'active' | 'deleted'; +}) { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-run-ws-gate-')); + const db = openDatabase(tempDir); + const now = Date.now(); + for (const id of [TEAM_PROJECT, PERSONAL_PROJECT, UNBOUND_PROJECT]) { + insertProject(db, { id, name: id, createdAt: now, updatedAt: now }); + } + ensureWorkspaceProject(db, { + projectId: TEAM_PROJECT, + workspaceId: WORKSPACE_ID, + visibility: 'team', + resourceState: opts?.teamProjectResourceState ?? 'active', + createdByWorkspaceMemberId: OWNER_MEMBER_ID, + }); + ensureWorkspaceProject(db, { + projectId: PERSONAL_PROJECT, + workspaceId: WORKSPACE_ID, + visibility: 'personal', + createdByWorkspaceMemberId: OWNER_MEMBER_ID, + }); + // UNBOUND_PROJECT deliberately gets no `workspace_projects` row — the + // "legacy / never claimed" control case the gate must leave alone + // (personal / solo usage with no workspace at all). + + const verifyWorkspaceRequestAuthority = + opts?.verifyWorkspaceRequestAuthority ?? + (async (req: any) => { + const workspaceId = req.get('x-od-workspace-id'); + const memberId = req.get('x-od-workspace-member-id'); + if (!workspaceId || !memberId) { + return { + ok: false, + status: 400, + code: 'WORKSPACE_CONTEXT_REQUIRED', + message: 'an explicit workspace context is required', + }; + } + return { + ok: true, + context: workspaceContextFromDirectoryItem({ + workspaceId, + workspaceName: workspaceId, + workspaceType: 'team', + workspaceMemberId: memberId, + role: memberId === OWNER_MEMBER_ID ? 'owner' : 'member', + memberStatus: 'active', + lifecycleState: 'active', + }), + }; + }); + + const app = express(); + app.use(express.json()); + registerRunRoutes(app, { + db, + design: { + runs: createRunsServiceStub(), + analytics: { capture: () => {} }, + getAppVersion: () => 'test', + }, + http: { + createSseResponse: () => ({ send() {}, end() {}, cleanup() {} }), + sendApiError, + }, + paths: { PROJECTS_DIR: tempDir, RUNTIME_DATA_DIR: tempDir }, + agents: { + detectAgents: async () => [], + getAgentDef: () => null, + }, + chat: { startChatRun: async () => undefined }, + lifecycle: { isDaemonShuttingDown: () => false }, + plugins: { + connectorService, + detectSkillPluginCandidateOnRunSuccess: () => {}, + firePipelineForRun: () => {}, + loadPluginRegistryView: async () => ({} as any), + renderPluginBriefTemplate: (template: string) => template, + }, + telemetry: { + reportRunCompletionTelemetryFallback: () => {}, + resolveRunProjectKindForAnalytics: () => null, + runArtifactBaselines: { take: () => undefined }, + runRetryEventsForAnalytics: () => [], + }, + messages: { + pinAssistantMessageOnRunCreate: () => {}, + reconcileAssistantMessageOnRunEnd: () => {}, + }, + enforceWorkspaceProjectMutation: + opts?.enforceWorkspaceProjectMutation ?? + createEnforceWorkspaceProjectMutation(async (req: any) => { + const workspaceId = req.get('x-od-workspace-id'); + const memberId = req.get('x-od-workspace-member-id'); + if (!workspaceId || !memberId) { + return { + ok: false, + status: 400, + code: 'WORKSPACE_CONTEXT_REQUIRED', + message: 'an explicit workspace context is required', + }; + } + return { + ok: true, + context: workspaceContextFromDirectoryItem({ + workspaceId, + workspaceName: workspaceId, + workspaceType: 'team', + workspaceMemberId: memberId, + role: memberId === OWNER_MEMBER_ID ? 'owner' : 'member', + memberStatus: 'active', + lifecycleState: 'active', + }), + }; + }), + amrWorkspaceScope: { + isSignedIn: opts?.isAmrSignedIn ?? (() => false), + verifyWorkspaceRequestAuthority, + }, + authorizeProjectRequest: createAuthorizeProjectRequest({ + db, + getWorkspaceProject: (dbArg: unknown, workspaceId: string, projectId: string) => + getWorkspaceProject( + dbArg as ReturnType, + workspaceId, + projectId, + ), + getWorkspaceProjectByProjectId: (dbArg: unknown, projectId: string) => + getWorkspaceProjectByProjectId( + dbArg as ReturnType, + projectId, + ), + verifyWorkspaceRequestAuthority, + sendApiError, + }), + projectStore: { + getWorkspaceProject, + getWorkspaceProjectByProjectId, + ensureWorkspaceProject: (dbArg: any, input: any) => + ensureWorkspaceProject(dbArg, input), + }, + } as any); + const created = http.createServer(app); + server = created; + await new Promise((resolve) => created.listen(0, resolve)); + const address = created.address(); + const port = typeof address === 'object' && address ? address.port : 0; + return `http://127.0.0.1:${port}`; +} + +describe('POST /api/runs — workspace mutation gate', () => { + it('allows a headerless local run against a personal bound project so billing uses the persisted binding', async () => { + const baseUrl = await startServer(); + const resp = await fetch(`${baseUrl}/api/runs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ projectId: PERSONAL_PROJECT, agentId: 'claude', message: 'hi' }), + }); + expect(resp.status).toBe(202); + const payload = (await resp.json()) as { runId: string }; + expect(typeof payload.runId).toBe('string'); + }); + + it.each(['/api/runs', '/api/chat'])( + 'persists the exact project binding on the run before spawning through %s', + async (route) => { + const baseUrl = await startServer(); + const createResponse = await fetch(`${baseUrl}${route}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + projectId: PERSONAL_PROJECT, + agentId: 'amr', + message: 'pin billing scope', + }), + }); + expect(createResponse.status).toBe(202); + const { runId } = (await createResponse.json()) as { runId: string }; + + const statusResponse = await fetch(`${baseUrl}/api/runs/${runId}`, { + headers: workspaceHeaders(OWNER_MEMBER_ID, 'owner'), + }); + expect(statusResponse.status).toBe(200); + await expect(statusResponse.json()).resolves.toMatchObject({ + workspaceScope: { + schemaVersion: 1, + projectId: PERSONAL_PROJECT, + workspaceId: WORKSPACE_ID, + source: 'persisted_project_binding', + }, + }); + }, + ); + + it.each(['/api/runs', '/api/chat'])( + 'allows the shared-project owner to create a run through %s', + async (route) => { + const baseUrl = await startServer(); + const resp = await fetch(`${baseUrl}${route}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...workspaceHeaders(OWNER_MEMBER_ID, 'owner'), + }, + body: JSON.stringify({ + projectId: TEAM_PROJECT, + agentId: 'claude', + message: 'hi', + }), + }); + expect(resp.status).toBe(202); + const payload = (await resp.json()) as { runId: string }; + expect(typeof payload.runId).toBe('string'); + }, + ); + + it.each(['/api/runs', '/api/chat'])( + 'requires an explicit owner identity for a shared Team project through %s', + async (route) => { + const baseUrl = await startServer(); + const resp = await fetch(`${baseUrl}${route}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + projectId: TEAM_PROJECT, + agentId: 'claude', + message: 'headerless shared-project mutation', + }), + }); + + expect(resp.status).toBe(400); + await expect(resp.json()).resolves.toMatchObject({ + error: { code: 'WORKSPACE_CONTEXT_REQUIRED' }, + }); + }, + ); + + it.each(['/api/runs', '/api/chat'])( + 'rejects a stale direct run against a revoked Team mirror through %s', + async (route) => { + const baseUrl = await startServer({ + teamProjectResourceState: 'deleted', + }); + const resp = await fetch(`${baseUrl}${route}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...workspaceHeaders(OWNER_MEMBER_ID, 'owner'), + }, + body: JSON.stringify({ + projectId: TEAM_PROJECT, + agentId: 'claude', + message: 'must not run stale mirror bytes', + }), + }); + + expect(resp.status).toBe(403); + await expect(resp.json()).resolves.toMatchObject({ + error: { code: 'WORKSPACE_PROJECT_PERMISSION_DENIED' }, + }); + }, + ); + + it.each(['/api/runs', '/api/chat'])( + 'rejects a Team workspace owner who is not the shared-project owner through %s', + async (route) => { + const workspaceOwnerId = 'member-workspace-owner'; + const baseUrl = await startServer({ + verifyWorkspaceRequestAuthority: async () => ({ + ok: true, + context: workspaceContextFromDirectoryItem({ + workspaceId: WORKSPACE_ID, + workspaceName: 'Team', + workspaceType: 'team', + workspaceMemberId: workspaceOwnerId, + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + }), + }), + }); + const resp = await fetch(`${baseUrl}${route}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...workspaceHeaders(workspaceOwnerId, 'owner'), + }, + body: JSON.stringify({ + projectId: TEAM_PROJECT, + agentId: 'claude', + message: 'must not mutate another member shared project', + }), + }); + + expect(resp.status).toBe(403); + await expect(resp.json()).resolves.toMatchObject({ + error: { code: 'WORKSPACE_PROJECT_PERMISSION_DENIED' }, + }); + }, + ); + + it('still allows a headerless run creation against a never-claimed (legacy) project', async () => { + const baseUrl = await startServer(); + const resp = await fetch(`${baseUrl}/api/runs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ projectId: UNBOUND_PROJECT, agentId: 'claude', message: 'hi' }), + }); + expect(resp.status).toBe(202); + const payload = (await resp.json()) as { runId: string }; + expect(typeof payload.runId).toBe('string'); + }); + + it('still allows a headerless run creation with no projectId at all (scratch / non-project usage)', async () => { + const baseUrl = await startServer(); + const resp = await fetch(`${baseUrl}/api/runs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentId: 'claude', message: 'hi' }), + }); + expect(resp.status).toBe(202); + const payload = (await resp.json()) as { runId: string }; + expect(typeof payload.runId).toBe('string'); + }); +}); + +describe('Workspace-bound run lifecycle authority', () => { + it('keeps headerless local CLI/MCP lifecycle operations working for representative non-AMR runtimes', async () => { + const baseUrl = await startServer(); + const bodies = [ + { projectId: TEAM_PROJECT, agentId: 'claude', message: 'claude run' }, + { projectId: TEAM_PROJECT, agentId: 'codex', message: 'codex run' }, + { projectId: TEAM_PROJECT, agentId: 'opencode', message: 'opencode run' }, + { + projectId: TEAM_PROJECT, + agentId: 'byok-opencode', + model: 'test-model', + message: 'byok run', + byokProvider: { + protocol: 'openai', + apiKey: 'test-key', + baseUrl: 'https://example.test/v1', + }, + }, + ]; + + for (const body of bodies) { + const createResponse = await fetch(`${baseUrl}/api/runs`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...workspaceHeaders(OWNER_MEMBER_ID, 'owner'), + }, + body: JSON.stringify(body), + }); + expect(createResponse.status).toBe(202); + const { runId } = (await createResponse.json()) as { runId: string }; + + const statusResponse = await fetch(`${baseUrl}/api/runs/${runId}`); + expect(statusResponse.status).toBe(200); + await expect(statusResponse.json()).resolves.toMatchObject({ + id: runId, + agentId: body.agentId, + projectId: TEAM_PROJECT, + }); + + const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`); + expect(eventsResponse.status).toBe(200); + await expect(eventsResponse.json()).resolves.toMatchObject({ runId }); + + const cancelResponse = await fetch(`${baseUrl}/api/runs/${runId}/cancel`, { + method: 'POST', + }); + expect(cancelResponse.status).toBe(200); + await expect(cancelResponse.json()).resolves.toMatchObject({ + ok: true, + run: { id: runId, status: 'canceled' }, + }); + } + }); + + it('does not bypass exact Workspace authority for AMR run status, events, or cancel', async () => { + const baseUrl = await startServer(); + const createResponse = await fetch(`${baseUrl}/api/runs`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...workspaceHeaders(OWNER_MEMBER_ID, 'owner'), + }, + body: JSON.stringify({ + projectId: TEAM_PROJECT, + agentId: 'amr', + message: 'cloud run', + }), + }); + expect(createResponse.status).toBe(202); + const { runId } = (await createResponse.json()) as { runId: string }; + + for (const [path, method] of [ + [`/api/runs/${runId}`, 'GET'], + [`/api/runs/${runId}/events`, 'GET'], + [`/api/runs/${runId}/cancel`, 'POST'], + ] as const) { + const response = await fetch(`${baseUrl}${path}`, { method }); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'WORKSPACE_CONTEXT_REQUIRED' }, + }); + } + + const exactHeaders = workspaceHeaders(OWNER_MEMBER_ID, 'owner'); + expect( + (await fetch(`${baseUrl}/api/runs/${runId}`, { + headers: exactHeaders, + })).status, + ).toBe(200); + expect( + (await fetch(`${baseUrl}/api/runs/${runId}/events`, { + headers: exactHeaders, + })).status, + ).toBe(200); + expect( + (await fetch(`${baseUrl}/api/runs/${runId}/cancel`, { + method: 'POST', + headers: exactHeaders, + })).status, + ).toBe(200); + }); + + it('still validates an explicitly asserted Workspace identity for a non-AMR run', async () => { + const baseUrl = await startServer(); + const createResponse = await fetch(`${baseUrl}/api/runs`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...workspaceHeaders(OWNER_MEMBER_ID, 'owner'), + }, + body: JSON.stringify({ + projectId: TEAM_PROJECT, + agentId: 'claude', + message: 'local run', + }), + }); + const { runId } = (await createResponse.json()) as { runId: string }; + + const response = await fetch(`${baseUrl}/api/runs/${runId}`, { + headers: { 'x-od-workspace-id': WORKSPACE_ID }, + }); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'WORKSPACE_CONTEXT_REQUIRED' }, + }); + }); + + it('fails closed when a historical run record has no reliable agentId', async () => { + const baseUrl = await startServer(); + const createResponse = await fetch(`${baseUrl}/api/runs`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...workspaceHeaders(OWNER_MEMBER_ID, 'owner'), + }, + body: JSON.stringify({ + projectId: TEAM_PROJECT, + message: 'historical run without runtime identity', + }), + }); + expect(createResponse.status).toBe(202); + const { runId } = (await createResponse.json()) as { runId: string }; + + const response = await fetch(`${baseUrl}/api/runs/${runId}`); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'WORKSPACE_CONTEXT_REQUIRED' }, + }); + }); + + it.each([ + ['AMR is inserted first', ['amr', 'claude']], + ['non-AMR is inserted first', ['claude', 'amr']], + ])( + 'lists only non-AMR runs for a headerless caller when %s, independent of representative order', + async (_label, agentIds) => { + const baseUrl = await startServer(); + for (const agentId of agentIds) { + const createResponse = await fetch(`${baseUrl}/api/runs`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...workspaceHeaders(OWNER_MEMBER_ID, 'owner'), + }, + body: JSON.stringify({ + projectId: TEAM_PROJECT, + agentId, + message: `${agentId} run`, + }), + }); + expect(createResponse.status).toBe(202); + } + + const headerlessResponse = await fetch( + `${baseUrl}/api/runs?projectId=${TEAM_PROJECT}`, + ); + expect(headerlessResponse.status).toBe(200); + const headerlessBody = (await headerlessResponse.json()) as { + runs: Array<{ agentId: string }>; + }; + expect(headerlessBody.runs.map((run) => run.agentId)).toEqual(['claude']); + + const exactResponse = await fetch( + `${baseUrl}/api/runs?projectId=${TEAM_PROJECT}`, + { headers: workspaceHeaders(OWNER_MEMBER_ID, 'owner') }, + ); + expect(exactResponse.status).toBe(200); + const exactBody = (await exactResponse.json()) as { + runs: Array<{ agentId: string }>; + }; + expect(exactBody.runs.map((run) => run.agentId)).toEqual(agentIds); + }, + ); +}); + +describe('POST /api/runs — delegates membership and balance eligibility to Vela/AMR', () => { + it('does not let an ambient directory verdict override the persisted project billing binding', async () => { + const baseUrl = await startServer({ + enforceWorkspaceProjectMutation: createEnforceWorkspaceProjectMutation(async () => ({ + ok: false, + status: 403, + code: 'WORKSPACE_ACCESS_DENIED', + message: 'the requested workspace is not available to this member', + })), + }); + const resp = await fetch(`${baseUrl}/api/runs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...workspaceHeaders(OWNER_MEMBER_ID, 'owner') }, + body: JSON.stringify({ projectId: TEAM_PROJECT, agentId: 'claude', message: 'hi' }), + }); + expect(resp.status).toBe(202); + const payload = (await resp.json()) as { runId: string }; + expect(typeof payload.runId).toBe('string'); + }); + + it('allows the same headers when the authoritative directory confirms ownership', async () => { + const baseUrl = await startServer({ + enforceWorkspaceProjectMutation: createEnforceWorkspaceProjectMutation(async () => ({ + ok: true, + context: workspaceContextFromDirectoryItem({ + workspaceId: WORKSPACE_ID, + workspaceName: WORKSPACE_ID, + workspaceType: 'team', + workspaceMemberId: OWNER_MEMBER_ID, + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + }), + })), + }); + const resp = await fetch(`${baseUrl}/api/runs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...workspaceHeaders(OWNER_MEMBER_ID, 'owner') }, + body: JSON.stringify({ projectId: TEAM_PROJECT, agentId: 'claude', message: 'hi' }), + }); + expect(resp.status).toBe(202); + const payload = (await resp.json()) as { runId: string }; + expect(typeof payload.runId).toBe('string'); + }); +}); + +describe('POST /api/runs — one-time Personal adoption for signed-in AMR', () => { + it.each(['/api/runs', '/api/chat'])( + 'transactionally binds an unbound historical project to the exact verified Personal Workspace through %s', + async (route) => { + const verifyWorkspaceRequestAuthority = vi.fn(async () => ({ + ok: true, + context: workspaceContextFromDirectoryItem({ + workspaceId: WORKSPACE_ID, + workspaceName: 'Personal', + workspaceType: 'personal', + workspaceMemberId: OWNER_MEMBER_ID, + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + }), + })); + const baseUrl = await startServer({ + isAmrSignedIn: () => true, + verifyWorkspaceRequestAuthority, + }); + + const response = await fetch(`${baseUrl}${route}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...workspaceHeaders(OWNER_MEMBER_ID, 'owner'), + 'x-od-workspace-type': 'personal', + }, + body: JSON.stringify({ + projectId: UNBOUND_PROJECT, + agentId: 'amr', + message: 'migrate and run', + }), + }); + + expect(response.status).toBe(202); + expect(verifyWorkspaceRequestAuthority).toHaveBeenCalledTimes(1); + expect( + getWorkspaceProjectByProjectId(openDatabase(tempDir!), UNBOUND_PROJECT), + ).toMatchObject({ + workspaceId: WORKSPACE_ID, + visibility: 'personal', + createdByWorkspaceMemberId: null, + }); + }, + ); + + it.each(['/api/runs', '/api/chat'])( + 'refuses a signed-in AMR run through %s when an unbound project has no explicit Personal identity', + async (route) => { + const verifyWorkspaceRequestAuthority = vi.fn(); + const baseUrl = await startServer({ + isAmrSignedIn: () => true, + verifyWorkspaceRequestAuthority, + }); + + const response = await fetch(`${baseUrl}${route}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + projectId: UNBOUND_PROJECT, + agentId: 'amr', + message: 'must not use account wallet', + }), + }); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'AMR_WORKSPACE_SCOPE_REQUIRED' }, + }); + expect(verifyWorkspaceRequestAuthority).not.toHaveBeenCalled(); + expect( + getWorkspaceProjectByProjectId(openDatabase(tempDir!), UNBOUND_PROJECT), + ).toBeUndefined(); + }, + ); + + it('never adopts an unbound historical project into a Team Workspace', async () => { + const verifyWorkspaceRequestAuthority = vi.fn(async () => ({ + ok: true, + context: workspaceContextFromDirectoryItem({ + workspaceId: WORKSPACE_ID, + workspaceName: 'Team', + workspaceType: 'team', + workspaceMemberId: OWNER_MEMBER_ID, + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + }), + })); + const baseUrl = await startServer({ + isAmrSignedIn: () => true, + verifyWorkspaceRequestAuthority, + }); + + const response = await fetch(`${baseUrl}/api/runs`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...workspaceHeaders(OWNER_MEMBER_ID, 'owner'), + 'x-od-workspace-type': 'team', + }, + body: JSON.stringify({ + projectId: UNBOUND_PROJECT, + agentId: 'amr', + message: 'must stay personal', + }), + }); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'AMR_PERSONAL_WORKSPACE_REQUIRED' }, + }); + expect( + getWorkspaceProjectByProjectId(openDatabase(tempDir!), UNBOUND_PROJECT), + ).toBeUndefined(); + }); + + it('fails closed without binding when the exact Personal authority is unavailable', async () => { + const verifyWorkspaceRequestAuthority = vi.fn(async () => ({ + ok: false, + status: 503, + code: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + message: 'workspace membership authority is temporarily unavailable', + retryable: true, + })); + const baseUrl = await startServer({ + isAmrSignedIn: () => true, + verifyWorkspaceRequestAuthority, + }); + + const response = await fetch(`${baseUrl}/api/runs`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...workspaceHeaders(OWNER_MEMBER_ID, 'owner'), + 'x-od-workspace-type': 'personal', + }, + body: JSON.stringify({ + projectId: UNBOUND_PROJECT, + agentId: 'amr', + message: 'do not guess', + }), + }); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + error: { code: 'WORKSPACE_AUTHORITY_UNAVAILABLE' }, + }); + expect(verifyWorkspaceRequestAuthority).toHaveBeenCalledTimes(1); + expect( + getWorkspaceProjectByProjectId(openDatabase(tempDir!), UNBOUND_PROJECT), + ).toBeUndefined(); + }); + + it.each(['/api/runs', '/api/chat'])( + 'does not fetch authority, bind, or refuse unbound non-AMR and BYOK runs through %s', + async (route) => { + const isAmrSignedIn = vi.fn(() => true); + const verifyWorkspaceRequestAuthority = vi.fn(); + const baseUrl = await startServer({ + isAmrSignedIn, + verifyWorkspaceRequestAuthority, + }); + + for (const body of [ + { projectId: UNBOUND_PROJECT, agentId: 'claude', message: 'local cli' }, + { projectId: UNBOUND_PROJECT, agentId: 'codex', message: 'local cli' }, + { projectId: UNBOUND_PROJECT, agentId: 'opencode', message: 'local cli' }, + { + projectId: UNBOUND_PROJECT, + agentId: 'byok-opencode', + model: 'test-model', + message: 'byok', + byokProvider: { + protocol: 'openai', + apiKey: 'test-key', + baseUrl: 'https://example.test/v1', + }, + }, + ]) { + const response = await fetch(`${baseUrl}${route}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + expect(response.status).toBe(202); + } + + expect(isAmrSignedIn).not.toHaveBeenCalled(); + expect(verifyWorkspaceRequestAuthority).not.toHaveBeenCalled(); + expect( + getWorkspaceProjectByProjectId(openDatabase(tempDir!), UNBOUND_PROJECT), + ).toBeUndefined(); + }, + ); + + it('does not fetch authority, bind, or synchronously refuse an unlogged AMR run', async () => { + const verifyWorkspaceRequestAuthority = vi.fn(); + const baseUrl = await startServer({ + isAmrSignedIn: () => false, + verifyWorkspaceRequestAuthority, + }); + + const response = await fetch(`${baseUrl}/api/runs`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...workspaceHeaders(OWNER_MEMBER_ID, 'owner'), + 'x-od-workspace-type': 'personal', + }, + body: JSON.stringify({ + projectId: UNBOUND_PROJECT, + agentId: 'amr', + message: 'auth guard remains the owner', + }), + }); + + expect(response.status).toBe(202); + expect(verifyWorkspaceRequestAuthority).not.toHaveBeenCalled(); + expect( + getWorkspaceProjectByProjectId(openDatabase(tempDir!), UNBOUND_PROJECT), + ).toBeUndefined(); + }); +}); diff --git a/apps/daemon/tests/run-failure-classification.test.ts b/apps/daemon/tests/run-failure-classification.test.ts index 86a20bae2c6..663b8b73169 100644 --- a/apps/daemon/tests/run-failure-classification.test.ts +++ b/apps/daemon/tests/run-failure-classification.test.ts @@ -367,6 +367,22 @@ describe('classifyRunFailure', () => { retryable: true, user_action: 'retry', }); + expect( + classify( + 'AGENT_EXECUTION_FAILED', + 'json-rpc id 4: opencode event stream: {"type":"session.error","properties":{"error":{"data":{"message":"\\"[code=upstream_error] stream idle timeout: no data received within configured window\\""}}}}', + [errorEvent( + 'AGENT_EXECUTION_FAILED', + 'json-rpc id 4: opencode event stream: {"type":"session.error","properties":{"error":{"data":{"message":"\\"[code=upstream_error] stream idle timeout: no data received within configured window\\""}}}}', + true, + )], + ), + ).toMatchObject({ + failure_category: 'upstream_unavailable', + failure_detail: 'stream_disconnected', + retryable: true, + user_action: 'retry', + }); expect( classify( 'AGENT_EXECUTION_FAILED', @@ -1101,6 +1117,21 @@ describe('classifyRunFailure — signal and interrupt attribution', () => { user_action: 'reduce_context', }); + expect( + classify( + 'AGENT_EXECUTION_FAILED', + 'json-rpc id 4: opencode event stream: {"properties":{"error":{"data":{"message":"[code=request_too_large] request body exceeds configured limit"}}}}', + ), + ).toMatchObject({ + failure_category: 'prompt_too_large', + // main 把带 [code=request_too_large] 的上游错误单独归到 request_too_large, + // 与「上下文放不下」的 prompt_too_large 区分开(分类仍是 prompt_too_large)。 + failure_detail: 'request_too_large', + failure_stage: 'prompt_send', + retryable: false, + user_action: 'reduce_context', + }); + expect(classify('AGENT_EXECUTION_FAILED', 'Codex CLI was not found. Please update or reinstall OpenAI Codex.')).toMatchObject({ failure_category: 'process_exit', failure_detail: 'cli_not_installed', diff --git a/apps/daemon/tests/run-retry-runtime.test.ts b/apps/daemon/tests/run-retry-runtime.test.ts index 0b5bb4456e2..eac707f744f 100644 --- a/apps/daemon/tests/run-retry-runtime.test.ts +++ b/apps/daemon/tests/run-retry-runtime.test.ts @@ -195,6 +195,146 @@ describe('same-run retry runtime', () => { expect(fatalCloseDiagnostics).toHaveLength(1); }); + it('retries AMR when protocol heartbeats arrive forever without first output', async () => { + binDir = await mkdtemp(path.join(os.tmpdir(), 'od-run-retry-amr-first-output-bin-')); + const fakeVela = await writeHeartbeatStallingVela( + binDir, + 'vela-first-output-then-success', + 1, + 250, + ); + + delete process.env.POSTHOG_KEY; + delete process.env.POSTHOG_HOST; + delete process.env.LANGFUSE_PUBLIC_KEY; + delete process.env.LANGFUSE_SECRET_KEY; + delete process.env.LANGFUSE_BASE_URL; + delete process.env.OPEN_DESIGN_TELEMETRY_RELAY_URL; + process.env.VELA_RUNTIME_KEY = `fake-runtime-key-${randomUUID()}`; + process.env.VELA_LINK_URL = 'https://amr-link.open-design.ai/v1'; + // The heartbeats keep both legacy inactivity watchdogs alive. Only the + // absolute first-output deadline may terminate attempt 0. + process.env.OD_CHAT_RUN_FIRST_OUTPUT_TIMEOUT_MS = '100'; + process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS = STALL_WATCHDOG_TIMEOUT_MS; + process.env.OD_ACP_STAGE_TIMEOUT_MS = STALL_WATCHDOG_TIMEOUT_MS; + + started = await startServer({ port: 0, returnServer: true }) as StartedServer; + await putConfig(started.url, { + agentId: 'amr', + agentCliEnv: { amr: { VELA_BIN: fakeVela } }, + telemetry: { metrics: true, content: false, artifactManifest: false }, + privacyDecisionAt: Date.now(), + }); + + const run = await createAndWaitForRun(started.url, 'amr'); + expect(run.status).toBe('succeeded'); + + const events = await readRunEvents(run.eventsLogPath); + expect(events.filter((event) => event.event === 'start')).toHaveLength(2); + expect(events.filter((event) => event.event === 'end')).toHaveLength(1); + expect(events.filter((event) => + event.event === 'agent' && event.data.label === 'waiting_for_first_output', + )).toHaveLength(2); + expect(events.find((event) => event.event === 'run_retry_attempted')?.data).toMatchObject({ + failure_category: 'timeout', + failure_detail: 'inactivity_timeout', + failure_stage: 'first_token_wait', + retry_reason: 'transient_failure', + }); + expect(events.find((event) => event.event === 'run_retry_finished')?.data).toMatchObject({ + retry_result: 'success', + }); + }); + + it('retries when title-only ACP text is followed by heartbeat-only stalling', async () => { + binDir = await mkdtemp(path.join(os.tmpdir(), 'od-run-retry-amr-title-only-bin-')); + const fakeVela = await writeTitleOnlyVela(binDir, 'vela-title-only-stall', true); + configureAmrFirstOutputEnv(); + + started = await startServer({ port: 0, returnServer: true }) as StartedServer; + await putConfig(started.url, { + agentId: 'amr', + agentCliEnv: { amr: { VELA_BIN: fakeVela } }, + telemetry: { metrics: true, content: false, artifactManifest: false }, + privacyDecisionAt: Date.now(), + }); + + const run = await createAndWaitForRun(started.url, 'amr', { + titleGeneration: { enabled: true }, + }); + expect(run.status).toBe('succeeded'); + const events = await readRunEvents(run.eventsLogPath); + expect(events.filter((event) => event.event === 'start')).toHaveLength(2); + expect(events.filter((event) => event.event === 'run_retry_attempted')).toHaveLength(1); + }); + + it('does not retry after a title-only clean ACP result with no usage', async () => { + binDir = await mkdtemp(path.join(os.tmpdir(), 'od-run-retry-amr-title-clean-bin-')); + const fakeVela = await writeTitleOnlyVela(binDir, 'vela-title-only-clean', false); + configureAmrFirstOutputEnv(); + + started = await startServer({ port: 0, returnServer: true }) as StartedServer; + await putConfig(started.url, { + agentId: 'amr', + agentCliEnv: { amr: { VELA_BIN: fakeVela } }, + telemetry: { metrics: true, content: false, artifactManifest: false }, + privacyDecisionAt: Date.now(), + }); + + const run = await createAndWaitForRun(started.url, 'amr', { + titleGeneration: { enabled: true }, + }); + expect(run.status).toBe('succeeded'); + const events = await readRunEvents(run.eventsLogPath); + expect(events.filter((event) => event.event === 'start')).toHaveLength(1); + expect(events.filter((event) => event.event === 'run_retry_attempted')).toHaveLength(0); + }); + + it('fails AMR after both first-output attempts remain heartbeat-only', async () => { + binDir = await mkdtemp(path.join(os.tmpdir(), 'od-run-retry-amr-first-output-fail-bin-')); + const fakeVela = await writeHeartbeatStallingVela( + binDir, + 'vela-first-output-always-stalls', + 2, + ); + + delete process.env.POSTHOG_KEY; + delete process.env.POSTHOG_HOST; + delete process.env.LANGFUSE_PUBLIC_KEY; + delete process.env.LANGFUSE_SECRET_KEY; + delete process.env.LANGFUSE_BASE_URL; + delete process.env.OPEN_DESIGN_TELEMETRY_RELAY_URL; + process.env.VELA_RUNTIME_KEY = `fake-runtime-key-${randomUUID()}`; + process.env.VELA_LINK_URL = 'https://amr-link.open-design.ai/v1'; + process.env.OD_CHAT_RUN_FIRST_OUTPUT_TIMEOUT_MS = '100'; + process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS = STALL_WATCHDOG_TIMEOUT_MS; + process.env.OD_ACP_STAGE_TIMEOUT_MS = STALL_WATCHDOG_TIMEOUT_MS; + + started = await startServer({ port: 0, returnServer: true }) as StartedServer; + await putConfig(started.url, { + agentId: 'amr', + agentCliEnv: { amr: { VELA_BIN: fakeVela } }, + telemetry: { metrics: true, content: false, artifactManifest: false }, + privacyDecisionAt: Date.now(), + }); + + const run = await createAndWaitForRun(started.url, 'amr'); + expect(run.status).toBe('failed'); + expect(run.error).toContain('without emitting a first output'); + + const events = await readRunEvents(run.eventsLogPath); + expect(events.filter((event) => event.event === 'start')).toHaveLength(2); + expect(events.filter((event) => event.event === 'run_retry_attempted')).toHaveLength(1); + expect(events.filter((event) => event.event === 'run_retry_finished')).toHaveLength(1); + expect(events.find((event) => event.event === 'run_retry_finished')?.data).toMatchObject({ + retry_result: 'failed', + failure_category: 'timeout', + failure_detail: 'inactivity_timeout', + failure_stage: 'first_token_wait', + }); + expect(events.filter((event) => event.event === 'end')).toHaveLength(1); + }); + it('retries a silent first-token stall caught by the inactivity watchdog', async () => { binDir = await mkdtemp(path.join(os.tmpdir(), 'od-run-retry-stall-bin-')); const { bin: fakeClaude, argsLogPath } = await writeStallingClaude(binDir, 'claude-stall'); @@ -513,7 +653,9 @@ function snapshotEnv(): Record { POSTHOG_KEY: process.env.POSTHOG_KEY, POSTHOG_HOST: process.env.POSTHOG_HOST, OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS: process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS, + OD_CHAT_RUN_FIRST_OUTPUT_TIMEOUT_MS: process.env.OD_CHAT_RUN_FIRST_OUTPUT_TIMEOUT_MS, OD_CHAT_RUN_INACTIVITY_KILL_GRACE_MS: process.env.OD_CHAT_RUN_INACTIVITY_KILL_GRACE_MS, + OD_ACP_STAGE_TIMEOUT_MS: process.env.OD_ACP_STAGE_TIMEOUT_MS, VELA_RUNTIME_KEY: process.env.VELA_RUNTIME_KEY, VELA_LINK_URL: process.env.VELA_LINK_URL, }; @@ -526,6 +668,20 @@ function restoreEnv(env: Record): void { } } +function configureAmrFirstOutputEnv(): void { + delete process.env.POSTHOG_KEY; + delete process.env.POSTHOG_HOST; + delete process.env.LANGFUSE_PUBLIC_KEY; + delete process.env.LANGFUSE_SECRET_KEY; + delete process.env.LANGFUSE_BASE_URL; + delete process.env.OPEN_DESIGN_TELEMETRY_RELAY_URL; + process.env.VELA_RUNTIME_KEY = `fake-runtime-key-${randomUUID()}`; + process.env.VELA_LINK_URL = 'https://amr-link.open-design.ai/v1'; + process.env.OD_CHAT_RUN_FIRST_OUTPUT_TIMEOUT_MS = '100'; + process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS = STALL_WATCHDOG_TIMEOUT_MS; + process.env.OD_ACP_STAGE_TIMEOUT_MS = STALL_WATCHDOG_TIMEOUT_MS; +} + async function writeFlakyClaude(dir: string, name: string): Promise { const bin = path.join(dir, name); const counterPath = path.join(dir, `${name}-attempts`); @@ -584,6 +740,68 @@ exec ${JSON.stringify(process.execPath)} ${JSON.stringify(FAKE_VELA)} "$@" return bin; } +async function writeHeartbeatStallingVela( + dir: string, + name: string, + stallAttempts: number, + successfulPromptDelayMs = 0, +): Promise { + const bin = path.join(dir, name); + const counterPath = path.join(dir, `${name}-attempts`); + await writeFile(bin, `#!/bin/sh +unset FAKE_VELA_STALL_AFTER_PROMPT +unset FAKE_VELA_PROMPT_RESULT_DELAY_MS +if [ "$1" = "agent" ] && [ "$2" = "run" ]; then + attempts=0 + if [ -f ${JSON.stringify(counterPath)} ]; then + attempts=$(tr -dc '0-9' < ${JSON.stringify(counterPath)}) + fi + echo $((attempts + 1)) > ${JSON.stringify(counterPath)} + if [ "$attempts" -lt ${String(stallAttempts)} ]; then + export FAKE_VELA_STALL_AFTER_PROMPT=1 + elif [ ${String(successfulPromptDelayMs)} -gt 0 ]; then + export FAKE_VELA_PROMPT_RESULT_DELAY_MS=${String(successfulPromptDelayMs)} + fi +fi +exec ${JSON.stringify(process.execPath)} ${JSON.stringify(FAKE_VELA)} "$@" +`, 'utf8'); + await chmod(bin, 0o755); + return bin; +} + +async function writeTitleOnlyVela( + dir: string, + name: string, + stallFirstAttempt: boolean, +): Promise { + const bin = path.join(dir, name); + const counterPath = path.join(dir, `${name}-attempts`); + await writeFile(bin, `#!/bin/sh +unset FAKE_VELA_STALL_AFTER_PROMPT FAKE_VELA_TEXT_BEFORE_STALL +unset FAKE_VELA_OMIT_PROMPT_USAGE FAKE_VELA_STAY_ALIVE_AFTER_PROMPT_MS +if [ "$1" = "agent" ] && [ "$2" = "run" ]; then + attempts=0 + if [ -f ${JSON.stringify(counterPath)} ]; then + attempts=$(tr -dc '0-9' < ${JSON.stringify(counterPath)}) + fi + echo $((attempts + 1)) > ${JSON.stringify(counterPath)} + export FAKE_VELA_TEXT='Generated title' + if [ ${stallFirstAttempt ? '1' : '0'} -eq 1 ] && [ "$attempts" -eq 0 ]; then + export FAKE_VELA_TEXT_BEFORE_STALL=1 + export FAKE_VELA_STALL_AFTER_PROMPT=1 + elif [ ${stallFirstAttempt ? '1' : '0'} -eq 1 ]; then + export FAKE_VELA_TEXT='Recovered titleRecovered answer.' + else + export FAKE_VELA_OMIT_PROMPT_USAGE=1 + export FAKE_VELA_STAY_ALIVE_AFTER_PROMPT_MS=250 + fi +fi +exec ${JSON.stringify(process.execPath)} ${JSON.stringify(FAKE_VELA)} "$@" +`, 'utf8'); + await chmod(bin, 0o755); + return bin; +} + async function writeStreamErrorThenSuccessfulOpenCode(dir: string, name: string): Promise { const bin = path.join(dir, name); const counterPath = path.join(dir, `${name}-attempts`); @@ -763,8 +981,14 @@ async function putConfig(url: string, patch: Record): Promise | string = {}, ): Promise { + const prompt = typeof runOverridesOrPrompt === 'string' + ? runOverridesOrPrompt + : 'please retry a transient runtime failure'; + const runOverrides = typeof runOverridesOrPrompt === 'string' + ? {} + : runOverridesOrPrompt; const projectId = `retry_runtime_${randomUUID()}`; const projectResponse = await fetch(`${url}/api/projects`, { method: 'POST', @@ -778,6 +1002,27 @@ async function createAndWaitForRun( }); expect(projectResponse.status).toBe(200); const projectBody = await projectResponse.json() as { conversationId: string }; + let runWorkspaceHeaders: Record | undefined; + if (agentId === 'amr') { + // AMR Cloud never runs against the generic account wallet. Model these + // retry fixtures after the real historical-project migration: the first + // Personal Workspace list read adopts the otherwise-headerless project, + // and every attempt then receives that persisted exact Workspace id. + const personalWorkspaceId = `retry_personal_${projectId}`; + runWorkspaceHeaders = { + 'x-od-workspace-id': personalWorkspaceId, + 'x-od-workspace-type': 'personal', + 'x-od-workspace-member-id': 'retry-runtime-personal-owner', + 'x-od-workspace-role': 'owner', + }; + const adoptionResponse = await fetch( + `${url}/api/workspaces/${encodeURIComponent(personalWorkspaceId)}/projects?view=all`, + { + headers: runWorkspaceHeaders, + }, + ); + expect(adoptionResponse.status).toBe(200); + } const assistantMessageId = `assistant_retry_${randomUUID()}`; const runResponse = await fetch(`${url}/api/runs`, { method: 'POST', @@ -786,6 +1031,7 @@ async function createAndWaitForRun( 'x-od-analytics-device-id': 'retry-runtime-test', 'x-od-analytics-session-id': 'retry-runtime-session', 'x-od-analytics-client-type': 'web', + ...runWorkspaceHeaders, }, body: JSON.stringify({ projectId, @@ -795,25 +1041,54 @@ async function createAndWaitForRun( agentId, message: prompt, currentPrompt: prompt, + ...runOverrides, }), }); expect(runResponse.status).toBe(202); const body = await runResponse.json() as { runId: string }; - return await waitForRun(url, body.runId); + return await waitForRun(url, body.runId, runWorkspaceHeaders); } -async function waitForRun(url: string, runId: string): Promise { - const startedAt = Date.now(); - while (Date.now() - startedAt < 10_000) { - const response = await fetch(`${url}/api/runs/${encodeURIComponent(runId)}`); - expect(response.status).toBe(200); - const run = await response.json() as RunStatus; - if (run.status === 'failed' || run.status === 'succeeded' || run.status === 'canceled') { - return run; +async function waitForRun( + url: string, + runId: string, + headers?: Record, +): Promise { + // The SSE response ends exactly when the run becomes terminal. Waiting for + // that business signal avoids coupling the spec to subprocess cold-start + // time; the former 3s polling budget passed only after another test had + // pre-warmed the runtime and failed when this heartbeat case ran first. + const eventsResponse = await fetch( + `${url}/api/runs/${encodeURIComponent(runId)}/events`, + headers ? { headers } : {}, + ); + expect(eventsResponse.status).toBe(200); + await eventsResponse.text(); + + const response = await fetch( + `${url}/api/runs/${encodeURIComponent(runId)}`, + headers ? { headers } : {}, + ); + expect(response.status).toBe(200); + const run = await response.json() as RunStatus; + expect(['failed', 'succeeded', 'canceled']).toContain(run.status); + await waitForPersistedRunEnd(run.eventsLogPath); + return run; +} + +async function waitForPersistedRunEnd(file: string): Promise { + for (;;) { + try { + const events = await readRunEvents(file); + if (events.some((event) => event.event === 'end')) return; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; } - await delay(100); + // The SSE terminal signal and JSONL append are separate consumers of the + // same run transition. Poll the observable persisted result rather than + // assuming the file write completed in the same event-loop turn. + await new Promise((resolve) => setTimeout(resolve, 10)); } - throw new Error(`run ${runId} did not finish`); } async function readRunEvents(file: string): Promise { @@ -838,7 +1113,6 @@ function sessionIdArg(args: string[]): string | null { const index = args.indexOf('--session-id'); return index >= 0 ? args[index + 1] ?? null : null; } - function resumeSessionIdArg(args: string[]): string | null { const index = args.indexOf('--resume'); return index >= 0 ? args[index + 1] ?? null : null; diff --git a/apps/daemon/tests/runtimes/chat-run-inactivity-timeout.test.ts b/apps/daemon/tests/runtimes/chat-run-inactivity-timeout.test.ts index fb771c0cf08..77ae0147043 100644 --- a/apps/daemon/tests/runtimes/chat-run-inactivity-timeout.test.ts +++ b/apps/daemon/tests/runtimes/chat-run-inactivity-timeout.test.ts @@ -19,12 +19,14 @@ import { afterEach, describe, expect, it } from 'vitest'; import { assertValidRuntimeDefInactivityTimeoutMs, resolveAcpStageTimeoutMs, + resolveChatRunFirstOutputTimeoutMs, resolveChatRunInactivityTimeoutMs, } from '../../src/server.js'; import { amrAgentDef } from '../../src/runtimes/defs/amr.js'; import { copilotAgentDef } from '../../src/runtimes/defs/copilot.js'; const ENV_KEY = 'OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS'; +const FIRST_OUTPUT_ENV_KEY = 'OD_CHAT_RUN_FIRST_OUTPUT_TIMEOUT_MS'; const TEN_MINUTES_MS = 10 * 60 * 1000; const THIRTY_MINUTES_MS = 30 * 60 * 1000; const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000; @@ -174,6 +176,41 @@ describe('resolveAcpStageTimeoutMs', () => { }); }); +describe('resolveChatRunFirstOutputTimeoutMs', () => { + const originalEnv = process.env[FIRST_OUTPUT_ENV_KEY]; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env[FIRST_OUTPUT_ENV_KEY]; + } else { + process.env[FIRST_OUTPUT_ENV_KEY] = originalEnv; + } + }); + + it('stays disabled for runtimes without a first-output deadline', () => { + delete process.env[FIRST_OUTPUT_ENV_KEY]; + expect(resolveChatRunFirstOutputTimeoutMs()).toBe(0); + }); + + it('uses the runtime default and lets the operator override or disable it', () => { + delete process.env[FIRST_OUTPUT_ENV_KEY]; + expect(resolveChatRunFirstOutputTimeoutMs(120_000)).toBe(120_000); + + process.env[FIRST_OUTPUT_ENV_KEY] = '90000'; + expect(resolveChatRunFirstOutputTimeoutMs(120_000)).toBe(90_000); + + process.env[FIRST_OUTPUT_ENV_KEY] = '0'; + expect(resolveChatRunFirstOutputTimeoutMs(120_000)).toBe(0); + }); + + it('rejects an invalid checked-in runtime default', () => { + delete process.env[FIRST_OUTPUT_ENV_KEY]; + expect(() => resolveChatRunFirstOutputTimeoutMs(-1)).toThrow( + /RuntimeAgentDef\.firstOutputTimeoutMs/, + ); + }); +}); + describe('copilotAgentDef.inactivityTimeoutMs', () => { it('ships a 30-minute inactivity hint so Copilot silent-thinking phases do not trip the default watchdog (#2467)', () => { expect(copilotAgentDef.inactivityTimeoutMs).toBe(THIRTY_MINUTES_MS); @@ -184,6 +221,10 @@ describe('amrAgentDef.inactivityTimeoutMs', () => { it('ships a 30-minute inactivity hint so the outer chat watchdog matches ACP stage timeouts for slow upstream providers', () => { expect(amrAgentDef.inactivityTimeoutMs).toBe(THIRTY_MINUTES_MS); }); + + it('ships a two-minute absolute first-output deadline', () => { + expect(amrAgentDef.firstOutputTimeoutMs).toBe(120_000); + }); }); describe('assertValidRuntimeDefInactivityTimeoutMs (#2579 fast-fail at def-select time)', () => { diff --git a/apps/daemon/tests/runtimes/env-and-detection.test.ts b/apps/daemon/tests/runtimes/env-and-detection.test.ts index ba75870cfc0..5dd44acb026 100644 --- a/apps/daemon/tests/runtimes/env-and-detection.test.ts +++ b/apps/daemon/tests/runtimes/env-and-detection.test.ts @@ -338,7 +338,7 @@ test('spawnEnvForAgent injects the resolved AMR profile after configured env', ( const env = spawnEnvForAgent( 'amr', { - OPEN_DESIGN_AMR_PROFILE: 'test', + OPEN_DESIGN_AMR_PROFILE: 'feature-test', VELA_PROFILE: 'prod', PATH: '/usr/bin', }, @@ -347,8 +347,8 @@ test('spawnEnvForAgent injects the resolved AMR profile after configured env', ( }, ); - assert.equal(env.VELA_PROFILE, 'test'); - assert.equal(env.OPEN_DESIGN_AMR_PROFILE, 'test'); + assert.equal(env.VELA_PROFILE, 'feature-test'); + assert.equal(env.OPEN_DESIGN_AMR_PROFILE, 'feature-test'); assert.equal(env.PATH, '/usr/bin'); }); diff --git a/apps/daemon/tests/runtimes/executables.test.ts b/apps/daemon/tests/runtimes/executables.test.ts index b9d30bb92de..7383f9d6e40 100644 --- a/apps/daemon/tests/runtimes/executables.test.ts +++ b/apps/daemon/tests/runtimes/executables.test.ts @@ -3,7 +3,10 @@ import { relative, resolve } from 'node:path'; import { assert, chmodSync, claude, codex, deepseek, join, minimalAgentDef, mkdirSync, mkdtempSync, resolveAgentExecutable, rmSync, tmpdir, withEnvSnapshot, withPlatform, writeFileSync, } from './helpers/test-helpers.js'; -import { codexAppBundleCandidates } from '../../src/runtimes/executables.js'; +import { + codexAppBundleCandidates, + resolveAmrOpenCodeExecutable, +} from '../../src/runtimes/executables.js'; const fsTest = process.platform === 'win32' ? test.skip : test; @@ -126,6 +129,52 @@ fsTest( }, ); +fsTest( + 'resolveAmrOpenCodeExecutable prefers the selected Vela companion over a PATH wrapper', + () => { + const root = mkdtempSync(join(tmpdir(), 'od-amr-selected-vela-companion-')); + try { + return withEnvSnapshot( + ['PATH', 'OD_AGENT_HOME', 'OD_RESOURCE_ROOT', 'VELA_BIN', 'VELA_OPENCODE_BIN'], + () => { + const selectedBinDir = join(root, 'selected', 'bin'); + const selectedVela = join(selectedBinDir, 'vela'); + const selectedCompanion = join( + selectedBinDir, + 'libexec', + 'opencode', + 'opencode', + ); + const pathBin = join(root, 'path-bin'); + const pathWrapper = join(pathBin, 'opencode'); + mkdirSync(join(selectedBinDir, 'libexec', 'opencode'), { + recursive: true, + }); + mkdirSync(pathBin, { recursive: true }); + writeFileSync(selectedVela, '#!/bin/sh\nexit 0\n'); + writeFileSync(selectedCompanion, '#!/bin/sh\nexit 0\n'); + writeFileSync(pathWrapper, '#!/bin/sh\nexit 0\n'); + chmodSync(selectedVela, 0o755); + chmodSync(selectedCompanion, 0o755); + chmodSync(pathWrapper, 0o755); + process.env.PATH = pathBin; + process.env.OD_AGENT_HOME = join(root, 'empty-home'); + process.env.OD_RESOURCE_ROOT = ''; + process.env.VELA_BIN = selectedVela; + delete process.env.VELA_OPENCODE_BIN; + + assert.equal( + resolveAmrOpenCodeExecutable(process.env), + selectedCompanion, + ); + }, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, +); + fsTest( 'resolveAgentExecutable prefers configured VELA_BIN over packaged built-in Vela', () => { @@ -158,6 +207,36 @@ fsTest( }, ); +fsTest( + 'resolveAgentExecutable honors inherited VELA_BIN before PATH fallback', + () => { + const root = mkdtempSync(join(tmpdir(), 'od-amr-env-bin-precedence-')); + try { + return withEnvSnapshot(['PATH', 'OD_AGENT_HOME', 'OD_RESOURCE_ROOT', 'VELA_BIN'], () => { + const pathBin = join(root, 'path-bin'); + const pathVela = join(pathBin, 'vela'); + const envVela = join(root, 'env', 'vela'); + mkdirSync(pathBin, { recursive: true }); + mkdirSync(join(root, 'env'), { recursive: true }); + writeFileSync(pathVela, '#!/bin/sh\nexit 0\n'); + writeFileSync(envVela, '#!/bin/sh\nexit 0\n'); + chmodSync(pathVela, 0o755); + chmodSync(envVela, 0o755); + process.env.PATH = pathBin; + process.env.OD_AGENT_HOME = join(root, 'empty-home'); + process.env.OD_RESOURCE_ROOT = ''; + process.env.VELA_BIN = envVela; + + const resolved = resolveAgentExecutable(minimalAgentDef({ id: 'amr', bin: 'vela' })); + + assert.equal(resolved, envVela); + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, +); + fsTest( 'resolveAgentExecutable falls back to PATH Vela when packaged built-in Vela is absent', () => { diff --git a/apps/daemon/tests/runtimes/json-event-stream.test.ts b/apps/daemon/tests/runtimes/json-event-stream.test.ts index 9a3f1d88a43..5fd26c3e94b 100644 --- a/apps/daemon/tests/runtimes/json-event-stream.test.ts +++ b/apps/daemon/tests/runtimes/json-event-stream.test.ts @@ -1180,6 +1180,63 @@ test('codex json stream emits thinking status and reasoning token usage', () => ]); }); +test('codex json stream emits thinking deltas from reasoning items (regression: codex thinking had no content to expand)', () => { + const { events, handler } = collectEvents('codex'); + + handler.feed( + JSON.stringify({ type: 'turn.started' }) + '\n' + + JSON.stringify({ + type: 'item.completed', + item: { id: 'item_0', type: 'reasoning', text: '**Scoping the deck**\nPick 10 slides.' }, + }) + '\n' + + JSON.stringify({ + type: 'item.completed', + item: { id: 'item_2', type: 'reasoning', text: '**Choosing a palette**' }, + }) + '\n', + ); + + assert.deepEqual(events, [ + { type: 'status', label: 'thinking' }, + { type: 'thinking_delta', delta: '**Scoping the deck**\nPick 10 slides.' }, + // A new reasoning item starts a new summary paragraph; the web folds all + // thinking deltas into one block, so the parser owns the separation. + { type: 'thinking_delta', delta: '\n\n**Choosing a palette**' }, + ]); +}); + +test('codex json stream emits only the unseen suffix when a reasoning item repeats across lifecycle events', () => { + const { events, handler } = collectEvents('codex'); + + handler.feed( + JSON.stringify({ type: 'item.started', item: { id: 'item_0', type: 'reasoning', text: '' } }) + '\n' + + JSON.stringify({ type: 'item.updated', item: { id: 'item_0', type: 'reasoning', text: 'Reading the brief' } }) + '\n' + + JSON.stringify({ type: 'item.completed', item: { id: 'item_0', type: 'reasoning', text: 'Reading the brief, then drafting.' } }) + '\n', + ); + + assert.deepEqual(events, [ + { type: 'thinking_delta', delta: 'Reading the brief' }, + { type: 'thinking_delta', delta: ', then drafting.' }, + ]); +}); + +test('codex json stream surfaces non-fatal error items as a warning status, not raw noise (skills budget notice)', () => { + const { events, handler } = collectEvents('codex'); + + const message = + 'Skill descriptions were shortened to fit the 2% skills context budget. ' + + 'Codex can still see every skill, but some descriptions are shorter.'; + handler.feed( + JSON.stringify({ type: 'item.completed', item: { id: 'item_0', type: 'error', message } }) + '\n', + ); + + // Must stay a visible non-fatal warning: two sibling runs in the incident + // bundle carried this exact item and completed successfully, so a fatal + // `error` event here would wrongly kill healthy runs. + assert.deepEqual(events, [ + { type: 'status', label: 'warning', detail: message }, + ]); +}); + test('codex json stream preserves line boundaries between assistant message items', () => { const { events, handler } = collectEvents('codex'); diff --git a/apps/daemon/tests/runtimes/open-design-amr-trace-env.test.ts b/apps/daemon/tests/runtimes/open-design-amr-trace-env.test.ts index 42b8e0d6dcd..0610d331b1c 100644 --- a/apps/daemon/tests/runtimes/open-design-amr-trace-env.test.ts +++ b/apps/daemon/tests/runtimes/open-design-amr-trace-env.test.ts @@ -66,6 +66,68 @@ test('openDesignAmrTraceEnv fails fast on invalid AMR trace inputs', () => { ); }); +// Vela's workspace-credit isolation (spec: workspace-scoped wallet and +// credit isolation) attributes an AMR spend by the OPEN_DESIGN_WORKSPACE_ID +// env the daemon forwards to the vela CLI, which the CLI turns into +// `X-Open-Design-Workspace-Id` + `x-vela-workspace-id` request headers. +test('openDesignAmrTraceEnv forwards an exact persisted workspace id for AMR runs', () => { + const env = openDesignAmrTraceEnv({ + agentId: 'amr', + runId: 'run_trace_team', + runAttempt: 0, + workspaceId: ' workspace_team_123 ', + }); + + assert.equal(env.OPEN_DESIGN_WORKSPACE_ID, 'workspace_team_123'); +}); + +test('openDesignAmrTraceEnv forwards a persisted Personal workspace id too', () => { + const env = openDesignAmrTraceEnv({ + agentId: 'amr', + runId: 'run_trace_personal', + runAttempt: 0, + workspaceId: ' workspace_personal_123 ', + }); + assert.equal(env.OPEN_DESIGN_WORKSPACE_ID, 'workspace_personal_123'); +}); + +// Null/undefined/blank means the caller found no persisted binding at all. +// Only that genuinely unbound historical-project case omits the env var. +test('openDesignAmrTraceEnv omits OPEN_DESIGN_WORKSPACE_ID only without a persisted binding', () => { + const withNull = openDesignAmrTraceEnv({ + agentId: 'amr', + runId: 'run_trace_unbound', + runAttempt: 0, + workspaceId: null, + }); + assert.equal('OPEN_DESIGN_WORKSPACE_ID' in withNull, false); + + const withUndefined = openDesignAmrTraceEnv({ + agentId: 'amr', + runId: 'run_trace_unbound_2', + runAttempt: 0, + }); + assert.equal('OPEN_DESIGN_WORKSPACE_ID' in withUndefined, false); + + const withBlank = openDesignAmrTraceEnv({ + agentId: 'amr', + runId: 'run_trace_unbound_3', + runAttempt: 0, + workspaceId: ' ', + }); + assert.equal('OPEN_DESIGN_WORKSPACE_ID' in withBlank, false); +}); + +test('openDesignAmrTraceEnv never forwards workspaceId for non-AMR agents', () => { + const env = openDesignAmrTraceEnv({ + agentId: 'claude', + runId: 'run_trace_123', + runAttempt: 0, + workspaceId: 'workspace_team_123', + }); + assert.deepEqual(env, {}); +}); + test('openDesignAmrTraceEnv forwards only bounded plugin correlation to Vela', () => { const env = openDesignAmrTraceEnv({ agentId: 'amr', diff --git a/apps/daemon/tests/runtimes/project-amr-trace-env.test.ts b/apps/daemon/tests/runtimes/project-amr-trace-env.test.ts new file mode 100644 index 00000000000..23d7df369a6 --- /dev/null +++ b/apps/daemon/tests/runtimes/project-amr-trace-env.test.ts @@ -0,0 +1,147 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + closeDatabase, + ensureWorkspaceProject, + insertProject, + openDatabase, +} from '../../src/db.js'; +import { + openDesignAmrTraceEnvForRun, + pinRunWorkspaceScopeForProject, +} from '../../src/runtimes/project-amr-trace-env.js'; + +let tempDir: string | null = null; + +afterEach(() => { + closeDatabase(); + if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; +}); + +function projectDb(input: { + projectId: string; + workspaceId?: string; + memberId?: string; +}) { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-amr-project-scope-')); + const db = openDatabase(tempDir); + const now = Date.now(); + insertProject(db, { + id: input.projectId, + name: input.projectId, + createdAt: now, + updatedAt: now, + }); + if (input.workspaceId) { + ensureWorkspaceProject(db, { + projectId: input.projectId, + workspaceId: input.workspaceId, + visibility: 'personal', + createdByWorkspaceMemberId: input.memberId ?? null, + }); + } + return db; +} + +describe('openDesignAmrTraceEnvForRun', () => { + it('does not resolve project scope for a non-AMR runtime', async () => { + const db = projectDb({ + projectId: 'project-a', + workspaceId: 'workspace-a', + memberId: 'member-a', + }); + await expect(openDesignAmrTraceEnvForRun({ + agentId: 'claude', + runId: 'run-claude', + runAttempt: 0, + projectId: 'project-a', + })).resolves.toEqual({}); + }); + + it('carries the persisted Team binding into the final AMR spawn environment', async () => { + const db = projectDb({ + projectId: 'project-a', + workspaceId: 'workspace-a', + memberId: 'member-a', + }); + const env = await openDesignAmrTraceEnvForRun({ + agentId: 'amr', + runId: 'run-a', + conversationId: 'conversation-a', + runAttempt: 0, + projectId: 'project-a', + workspaceScope: pinRunWorkspaceScopeForProject(db, 'project-a'), + }); + + expect(env).toMatchObject({ + OPEN_DESIGN_RUN_ID: 'run-a', + OPEN_DESIGN_SESSION_ID: 'conversation-a', + OPEN_DESIGN_WORKSPACE_ID: 'workspace-a', + }); + }); + + it('uses the Team Workspace for a private draft bound to that Team', async () => { + const db = projectDb({ + projectId: 'project-team-draft', + workspaceId: 'workspace-team', + memberId: 'member-team', + }); + const env = await openDesignAmrTraceEnvForRun({ + agentId: 'amr', + runId: 'run-team-draft', + runAttempt: 0, + projectId: 'project-team-draft', + workspaceScope: pinRunWorkspaceScopeForProject(db, 'project-team-draft'), + }); + + expect(env.OPEN_DESIGN_WORKSPACE_ID).toBe('workspace-team'); + }); + + it('passes a persisted Personal Workspace explicitly instead of treating it as unscoped', async () => { + const db = projectDb({ + projectId: 'project-personal', + workspaceId: 'workspace-personal', + memberId: 'member-personal', + }); + const env = await openDesignAmrTraceEnvForRun({ + agentId: 'amr', + runId: 'run-personal', + runAttempt: 0, + projectId: 'project-personal', + workspaceScope: pinRunWorkspaceScopeForProject(db, 'project-personal'), + }); + + expect(env.OPEN_DESIGN_WORKSPACE_ID).toBe('workspace-personal'); + }); + + it('refuses a truly unbound project instead of spawning AMR on the account wallet', async () => { + const db = projectDb({ projectId: 'project-legacy' }); + await expect(openDesignAmrTraceEnvForRun({ + agentId: 'amr', + runId: 'run-legacy', + runAttempt: 0, + projectId: 'project-legacy', + workspaceScope: pinRunWorkspaceScopeForProject(db, 'project-legacy'), + })).rejects.toMatchObject({ + code: 'AMR_WORKSPACE_SCOPE_REQUIRED', + projectId: 'project-legacy', + }); + }); + + it('refuses AMR scratch execution without a Workspace-bound project', async () => { + const db = projectDb({ projectId: 'project-control' }); + await expect(openDesignAmrTraceEnvForRun({ + agentId: 'amr', + runId: 'run-scratch', + runAttempt: 0, + projectId: null, + })).rejects.toMatchObject({ + code: 'AMR_WORKSPACE_SCOPE_REQUIRED', + projectId: null, + }); + }); +}); diff --git a/apps/daemon/tests/runtimes/project-amr-workspace-proof.test.ts b/apps/daemon/tests/runtimes/project-amr-workspace-proof.test.ts new file mode 100644 index 00000000000..fb493b043f8 --- /dev/null +++ b/apps/daemon/tests/runtimes/project-amr-workspace-proof.test.ts @@ -0,0 +1,288 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, expectTypeOf, it } from 'vitest'; + +import { + closeDatabase, + ensureWorkspaceProject, + insertProject, + openDatabase, + rebindWorkspaceProject, +} from '../../src/db.js'; +import { + openDesignAmrTraceEnvForRun, + pinRunWorkspaceScopeForProject, + type ProjectWorkspaceScopeOutcome, +} from '../../src/runtimes/project-amr-trace-env.js'; + +let tempDir: string | null = null; + +afterEach(() => { + closeDatabase(); + if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; +}); + +function seedProject(input: { + projectId: string; + workspaceId?: string; + memberId?: string; +}) { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-amr-persisted-scope-')); + const db = openDatabase(tempDir); + const now = Date.now(); + insertProject(db, { + id: input.projectId, + name: input.projectId, + createdAt: now, + updatedAt: now, + }); + if (input.workspaceId) { + ensureWorkspaceProject(db, { + projectId: input.projectId, + workspaceId: input.workspaceId, + visibility: 'personal', + createdByWorkspaceMemberId: input.memberId ?? null, + }); + } + return db; +} + +describe('AMR persisted project Workspace scope', () => { + it('pins each initial spawn and retry to its own persisted A/B binding', async () => { + const db = seedProject({ + projectId: 'project-a', + workspaceId: 'workspace-a', + memberId: 'member-a', + }); + const now = Date.now(); + insertProject(db, { + id: 'project-b', + name: 'project-b', + createdAt: now, + updatedAt: now, + }); + ensureWorkspaceProject(db, { + projectId: 'project-b', + workspaceId: 'workspace-b', + visibility: 'personal', + createdByWorkspaceMemberId: 'member-b', + }); + + const scopeA = pinRunWorkspaceScopeForProject(db, 'project-a'); + const scopeB = pinRunWorkspaceScopeForProject(db, 'project-b'); + const initialA = await openDesignAmrTraceEnvForRun({ + agentId: 'amr', + runId: 'run-a', + runAttempt: 0, + projectId: 'project-a', + workspaceScope: scopeA, + }); + const retryA = await openDesignAmrTraceEnvForRun({ + agentId: 'amr', + runId: 'run-a', + runAttempt: 1, + projectId: 'project-a', + workspaceScope: scopeA, + }); + const initialB = await openDesignAmrTraceEnvForRun({ + agentId: 'amr', + runId: 'run-b', + runAttempt: 0, + projectId: 'project-b', + workspaceScope: scopeB, + }); + const retryB = await openDesignAmrTraceEnvForRun({ + agentId: 'amr', + runId: 'run-b', + runAttempt: 1, + projectId: 'project-b', + workspaceScope: scopeB, + }); + + expect(initialA.OPEN_DESIGN_WORKSPACE_ID).toBe('workspace-a'); + expect(retryA.OPEN_DESIGN_WORKSPACE_ID).toBe('workspace-a'); + expect(initialB.OPEN_DESIGN_WORKSPACE_ID).toBe('workspace-b'); + expect(retryB.OPEN_DESIGN_WORKSPACE_ID).toBe('workspace-b'); + }); + + it('keeps a run on its authorized Workspace when the project is rebound before retry', async () => { + const db = seedProject({ + projectId: 'project-a', + workspaceId: 'workspace-a', + memberId: 'member-a', + }); + + const workspaceScope = pinRunWorkspaceScopeForProject(db, 'project-a'); + const initial = await openDesignAmrTraceEnvForRun({ + agentId: 'amr', + runId: 'run-a', + runAttempt: 0, + projectId: 'project-a', + workspaceScope, + }); + rebindWorkspaceProject(db, 'project-a', { + workspaceId: 'workspace-b', + updatedAt: Date.now() + 1, + }); + const retry = await openDesignAmrTraceEnvForRun({ + agentId: 'amr', + runId: 'run-a', + runAttempt: 1, + projectId: 'project-a', + workspaceScope, + }); + + expect(initial.OPEN_DESIGN_WORKSPACE_ID).toBe('workspace-a'); + expect(retry.OPEN_DESIGN_WORKSPACE_ID).toBe('workspace-a'); + }); + + it('keeps the verified Workspace when the project is rebound before the first spawn', async () => { + const db = seedProject({ + projectId: 'project-a', + workspaceId: 'workspace-a', + memberId: 'member-a', + }); + + const workspaceScope = pinRunWorkspaceScopeForProject(db, 'project-a'); + rebindWorkspaceProject(db, 'project-a', { + workspaceId: 'workspace-b', + updatedAt: Date.now() + 1, + }); + const initial = await openDesignAmrTraceEnvForRun({ + agentId: 'amr', + runId: 'run-a', + runAttempt: 0, + projectId: 'project-a', + workspaceScope, + }); + + expect(initial.OPEN_DESIGN_WORKSPACE_ID).toBe('workspace-a'); + }); + + it('does not expose membership/current/directory inputs to the billing-scope resolver', async () => { + expectTypeOf[1]>>() + .toEqualTypeOf<{ + onWorkspaceScopeOutcome?: (outcome: ProjectWorkspaceScopeOutcome) => void; + }>(); + const db = seedProject({ + projectId: 'project-a', + workspaceId: 'workspace-a', + memberId: 'member-a', + }); + const outcomes: ProjectWorkspaceScopeOutcome[] = []; + + const workspaceScope = pinRunWorkspaceScopeForProject(db, 'project-a'); + const initial = await openDesignAmrTraceEnvForRun({ + agentId: 'amr', + runId: 'run-a', + runAttempt: 0, + projectId: 'project-a', + workspaceScope, + }, { + onWorkspaceScopeOutcome: (outcome) => outcomes.push(outcome), + }); + const retry = await openDesignAmrTraceEnvForRun({ + agentId: 'amr', + runId: 'run-a', + runAttempt: 1, + projectId: 'project-a', + workspaceScope, + }, { + onWorkspaceScopeOutcome: (outcome) => outcomes.push(outcome), + }); + + expect(initial.OPEN_DESIGN_WORKSPACE_ID).toBe('workspace-a'); + expect(retry.OPEN_DESIGN_WORKSPACE_ID).toBe('workspace-a'); + expect(outcomes).toEqual([0, 1].map(() => ({ + kind: 'resolved_persisted_binding', + projectId: 'project-a', + workspaceId: 'workspace-a', + }))); + }); + + it('passes a persisted Personal Workspace id on initial spawn and retry', async () => { + const db = seedProject({ + projectId: 'project-personal', + workspaceId: 'workspace-personal', + memberId: 'member-personal', + }); + const workspaceScope = pinRunWorkspaceScopeForProject(db, 'project-personal'); + const initial = await openDesignAmrTraceEnvForRun({ + agentId: 'amr', + runId: 'run-personal', + runAttempt: 0, + projectId: 'project-personal', + workspaceScope, + }); + const retry = await openDesignAmrTraceEnvForRun({ + agentId: 'amr', + runId: 'run-personal', + runAttempt: 1, + projectId: 'project-personal', + workspaceScope, + }); + + expect(initial.OPEN_DESIGN_WORKSPACE_ID).toBe('workspace-personal'); + expect(retry.OPEN_DESIGN_WORKSPACE_ID).toBe('workspace-personal'); + }); + + it('refuses an unbound AMR project on both initial spawn and retry', async () => { + const db = seedProject({ projectId: 'project-legacy' }); + const outcomes: ProjectWorkspaceScopeOutcome[] = []; + await expect(openDesignAmrTraceEnvForRun({ + agentId: 'amr', + runId: 'run-legacy', + runAttempt: 0, + projectId: 'project-legacy', + workspaceScope: pinRunWorkspaceScopeForProject(db, 'project-legacy'), + }, { + onWorkspaceScopeOutcome: (outcome) => outcomes.push(outcome), + })).rejects.toMatchObject({ + code: 'AMR_WORKSPACE_SCOPE_REQUIRED', + projectId: 'project-legacy', + }); + await expect(openDesignAmrTraceEnvForRun({ + agentId: 'amr', + runId: 'run-legacy', + runAttempt: 1, + projectId: 'project-legacy', + workspaceScope: pinRunWorkspaceScopeForProject(db, 'project-legacy'), + }, { + onWorkspaceScopeOutcome: (outcome) => outcomes.push(outcome), + })).rejects.toMatchObject({ + code: 'AMR_WORKSPACE_SCOPE_REQUIRED', + projectId: 'project-legacy', + }); + expect(outcomes).toHaveLength(2); + expect(outcomes).toEqual([0, 1].map(() => ({ + kind: 'refused_unbound', + projectId: 'project-legacy', + workspaceId: null, + }))); + }); + + it.each(['claude', 'codex', 'opencode', 'byok-opencode'])( + 'does not add Workspace scope or read project binding for the %s runtime', + async (agentId) => { + const db = seedProject({ + projectId: 'project-a', + workspaceId: 'workspace-a', + memberId: 'member-a', + }); + const outcomes: ProjectWorkspaceScopeOutcome[] = []; + const env = await openDesignAmrTraceEnvForRun({ + agentId, + runId: `run-${agentId}`, + runAttempt: 0, + projectId: 'project-a', + }, { + onWorkspaceScopeOutcome: (outcome) => outcomes.push(outcome), + }); + + expect(env).not.toHaveProperty('OPEN_DESIGN_WORKSPACE_ID'); + expect(outcomes).toEqual([]); + }, + ); +}); diff --git a/apps/daemon/tests/runtimes/runs.test.ts b/apps/daemon/tests/runtimes/runs.test.ts index 17a38aee71b..a385b610ec1 100644 --- a/apps/daemon/tests/runtimes/runs.test.ts +++ b/apps/daemon/tests/runtimes/runs.test.ts @@ -832,6 +832,12 @@ describe('run event log persistence', () => { conversationId: 'c1', assistantMessageId: 'm1', agentId: 'claude', + workspaceScope: { + schemaVersion: 1, + projectId: 'p1', + workspaceId: 'workspace-a', + source: 'persisted_project_binding', + }, }); const statePath = path.join(tmpDir, run.id, 'state.json'); @@ -840,6 +846,12 @@ describe('run event log persistence', () => { id: run.id, status: 'queued', assistantMessageId: 'm1', + workspaceScope: { + schemaVersion: 1, + projectId: 'p1', + workspaceId: 'workspace-a', + source: 'persisted_project_binding', + }, }); runs.setAnalyticsRecovery(run, { diff --git a/apps/daemon/tests/server-bootstrap-regression.test.ts b/apps/daemon/tests/server-bootstrap-regression.test.ts index 19179c60295..0e64121bdf6 100644 --- a/apps/daemon/tests/server-bootstrap-regression.test.ts +++ b/apps/daemon/tests/server-bootstrap-regression.test.ts @@ -335,19 +335,17 @@ describe('bootstrap route regressions', () => { expect(velaProxyUnknownPath.status).toBe(404); expect(await velaProxyUnknownPath.json()).toEqual({ error: 'unknown_amr_api_proxy_path' }); - expect(genuiRunList.status).toBe(200); - expect(await genuiRunList.json()).toEqual({ runId: 'missing-run', surfaces: [] }); + expect(genuiRunList.status).toBe(404); + expect(await genuiRunList.json()).toEqual({ error: 'run not found' }); expect(genuiRunSurfaceMissing.status).toBe(404); - expect(await genuiRunSurfaceMissing.json()).toEqual({ error: 'surface not found' }); + expect(await genuiRunSurfaceMissing.json()).toEqual({ error: 'run not found' }); - expect(devloopIterations.status).toBe(200); - expect(await devloopIterations.json()).toEqual({ runId: 'missing-run', iterations: [] }); + expect(devloopIterations.status).toBe(404); + expect(await devloopIterations.json()).toEqual({ error: 'run not found' }); - expect(replayMissingSnapshot.status).toBe(400); - expect(await replayMissingSnapshot.json()).toEqual({ - error: 'snapshotId is required (runs are in-memory; pass the snapshotId returned by /api/plugins/:id/apply)', - }); + expect(replayMissingSnapshot.status).toBe(404); + expect(await replayMissingSnapshot.json()).toEqual({ error: 'run not found' }); }); it('keeps extracted design-system and template example responses stable', async () => { @@ -457,8 +455,16 @@ describe('bootstrap route regressions', () => { paths, projectFiles: {} as never, projectStore: {} as never, + verifyWorkspaceRequestAuthority: async () => { + throw new Error('unbound fixture must not verify Workspace authority'); + }, + workspaceResources: { + getWorkspaceResource: () => undefined, + getWorkspaceResourceByResourceId: () => undefined, + }, designSystems: { buildUserDesignSystemArchive: async () => null, + canMutateUserDesignSystem: async () => true, createUserDesignSystem: async () => designSystemSummary as never, deleteUserDesignSystem: async () => false, ensureUserDesignSystemWorkspaceProject: async () => null, @@ -475,6 +481,8 @@ describe('bootstrap route regressions', () => { `${id} preview
${body}
`, renderDesignSystemShowcase: (id: string, body: string) => `${id} showcase
${body}
`, + syncUserDesignSystemAssetsFromWorkspace: async () => ({ ok: false, reason: 'not-found' }), + unshareTeamDesignSystemIfShared: async () => false, updateUserDesignSystem: async () => null, updateUserDesignSystemRevisionStatus: async () => null, }, @@ -486,6 +494,9 @@ describe('bootstrap route regressions', () => { }, }); registerStaticResourceRoutes(app, { + // Not exercised: this smoke test only hits GET example/asset routes, + // none of which touch the skill workspace-mutation gate that reads it. + db: {} as any, http: httpDeps, paths, resources: { diff --git a/apps/daemon/tests/should-publish.test.ts b/apps/daemon/tests/should-publish.test.ts new file mode 100644 index 00000000000..bba48bad453 --- /dev/null +++ b/apps/daemon/tests/should-publish.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createShouldPublish } from '../src/collab/should-publish.js'; +import type { ResourceHubPrincipal } from '../src/collab/resource-principal.js'; + +const ACTIVE_OWNER_PRINCIPAL: ResourceHubPrincipal = { + teamId: 't1', + memberId: 'owner-1', + role: 'owner', + lifecycleState: 'active', + workspaceType: 'team', +}; + +describe('createShouldPublish', () => { + it('watches an owned, team-shared project when the owner is an active member', async () => { + const rememberTeamShare = vi.fn(); + const resolveProjectPrincipal = vi.fn(async () => ACTIVE_OWNER_PRINCIPAL); + const shouldPublish = createShouldPublish({ + resolveSharedProjectOwner: async () => 'owner-1', + resolveProjectPrincipal, + rememberTeamShare, + hasUnmaterializedPlaceholder: () => false, + }); + + expect(await shouldPublish('p1')).toEqual(ACTIVE_OWNER_PRINCIPAL); + expect(resolveProjectPrincipal).toHaveBeenCalledWith('p1'); + expect(rememberTeamShare).toHaveBeenCalledTimes(1); + const [projectId, principal] = rememberTeamShare.mock.calls[0] as [string, ResourceHubPrincipal]; + expect(projectId).toBe('p1'); + expect(principal.memberId).toBe('owner-1'); + }); + + it('refuses to watch once exact project scope no longer resolves an active member', async () => { + const rememberTeamShare = vi.fn(); + const shouldPublish = createShouldPublish({ + resolveSharedProjectOwner: async () => 'owner-1', + resolveProjectPrincipal: async () => null, + rememberTeamShare, + hasUnmaterializedPlaceholder: () => false, + }); + + expect(await shouldPublish('p1')).toBe(false); + expect(rememberTeamShare).not.toHaveBeenCalled(); + }); + + it('refuses when the project has no shared-project owner at all', async () => { + const shouldPublish = createShouldPublish({ + resolveSharedProjectOwner: async () => null, + resolveProjectPrincipal: async () => ACTIVE_OWNER_PRINCIPAL, + rememberTeamShare: vi.fn(), + hasUnmaterializedPlaceholder: () => false, + }); + + expect(await shouldPublish('p1')).toBe(false); + }); + + it('refuses when this daemon is not the project owner (a member’s read-only pull)', async () => { + const rememberTeamShare = vi.fn(); + const shouldPublish = createShouldPublish({ + resolveSharedProjectOwner: async () => 'someone-else', + resolveProjectPrincipal: async () => ACTIVE_OWNER_PRINCIPAL, + rememberTeamShare, + hasUnmaterializedPlaceholder: () => false, + }); + + expect(await shouldPublish('p1')).toBe(false); + expect(rememberTeamShare).not.toHaveBeenCalled(); + }); + + it('refuses when the project principal cannot be resolved at all (signed out)', async () => { + const shouldPublish = createShouldPublish({ + resolveSharedProjectOwner: async () => 'owner-1', + resolveProjectPrincipal: async () => null, + rememberTeamShare: vi.fn(), + hasUnmaterializedPlaceholder: () => false, + }); + + expect(await shouldPublish('p1')).toBe(false); + }); + + it('refuses an unmaterialized shared-project placeholder even when the hub names this member as owner (recvqzaDvUU6B3)', async () => { + const rememberTeamShare = vi.fn(); + const resolveSharedProjectOwner = vi.fn(async () => 'owner-1'); + const shouldPublish = createShouldPublish({ + resolveSharedProjectOwner, + resolveProjectPrincipal: async () => ACTIVE_OWNER_PRINCIPAL, + rememberTeamShare, + hasUnmaterializedPlaceholder: (projectId) => projectId === 'placeholder-1', + }); + + expect(await shouldPublish('placeholder-1')).toBe(false); + expect(rememberTeamShare).not.toHaveBeenCalled(); + // The guard answers before any hub round-trip. + expect(resolveSharedProjectOwner).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/daemon/tests/skill-navigation-workspace-authority.test.ts b/apps/daemon/tests/skill-navigation-workspace-authority.test.ts new file mode 100644 index 00000000000..ba63d880483 --- /dev/null +++ b/apps/daemon/tests/skill-navigation-workspace-authority.test.ts @@ -0,0 +1,235 @@ +import express from 'express'; +import type http from 'node:http'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import type { WorkspaceCollabContext } from '@open-design/contracts'; +import { afterEach, describe, expect, it } from 'vitest'; +import { registerStaticResourceRoutes } from '../src/routes/static-resource.js'; + +const servers: http.Server[] = []; +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve) => server.close(() => resolve())), + ), + ); + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +function context( + workspaceId: string, + workspaceMemberId: string, +): WorkspaceCollabContext { + return { + workspaceId, + workspaceName: workspaceId, + workspaceType: 'team', + workspaceMemberId, + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + billingState: 'active', + planId: null, + providerMode: 'platform_credits', + seatSummary: { + seatLimit: 5, + usedSeats: 1, + availableSeats: 4, + isSeatFull: false, + }, + permissions: { + canManageMembers: false, + canManageBilling: false, + canInviteMembers: false, + canManageAutoRecharge: false, + canShareProjects: true, + canWriteSyncedFiles: true, + canViewWorkspaceSettings: true, + canManageSharedResources: false, + }, + } as WorkspaceCollabContext; +} + +async function fixture() { + const root = await mkdtemp(path.join(os.tmpdir(), 'od-skill-navigation-scope-')); + roots.push(root); + const entries = new Map(); + for (const workspaceId of ['workspace-a', 'workspace-b']) { + const dir = path.join(root, workspaceId); + await mkdir(path.join(dir, 'assets'), { recursive: true }); + await writeFile( + path.join(dir, 'example.html'), + `

${workspaceId}

`, + ); + await writeFile(path.join(dir, 'assets', 'secret.txt'), `${workspaceId}-bytes`); + entries.set(workspaceId, { + id: 'same-skill', + name: 'Same skill', + description: workspaceId, + body: `# ${workspaceId}`, + dir, + source: 'user', + }); + } + + const app = express(); + const paths = { + ARTIFACTS_DIR: path.join(root, 'artifacts'), + BRANDS_DIR: path.join(root, 'brands'), + BUNDLED_PETS_DIR: path.join(root, 'pets'), + CRAFT_DIR: path.join(root, 'craft'), + DESIGN_SYSTEMS_DIR: path.join(root, 'design-systems'), + DESIGN_TEMPLATES_DIR: path.join(root, 'design-templates'), + LIBRARY_DIR: path.join(root, 'library'), + OD_BIN: path.join(root, 'od'), + PROJECT_ROOT: root, + PROJECTS_DIR: path.join(root, 'projects'), + PROMPT_TEMPLATES_DIR: path.join(root, 'prompt-templates'), + RUNTIME_DATA_DIR: path.join(root, 'data'), + RUNTIME_DATA_DIR_CANONICAL: path.join(root, 'data'), + SKILLS_DIR: path.join(root, 'skills'), + USER_DESIGN_SYSTEMS_DIR: path.join(root, 'user-design-systems'), + USER_DESIGN_TEMPLATES_DIR: path.join(root, 'user-design-templates'), + USER_SKILLS_DIR: path.join(root, 'user-skills'), + }; + registerStaticResourceRoutes(app, { + db: {} as never, + verifyWorkspaceRequestAuthority: async (req: any) => { + const workspaceId = req.get('x-od-workspace-id')?.trim(); + const workspaceMemberId = req.get('x-od-workspace-member-id')?.trim(); + if (workspaceId === 'workspace-removed') { + return { + ok: false, + status: 403, + code: 'WORKSPACE_ACCESS_DENIED', + message: 'removed', + }; + } + if (workspaceId === 'workspace-outage') { + return { + ok: false, + status: 503, + code: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + message: 'outage', + retryable: true, + }; + } + return { + ok: true, + context: context(workspaceId, workspaceMemberId), + }; + }, + http: { + createSseResponse: () => undefined, + getPublicBaseUrl: () => '', + isLocalSameOrigin: () => true, + requireLocalDaemonRequest: (_req: unknown, _res: unknown, next: () => void) => + next(), + resolvedPortRef: { current: 0 }, + sendApiError: ( + res: express.Response, + status: number, + code: string, + message: string, + options?: { retryable?: boolean }, + ) => + res.status(status).json({ + error: code, + message, + ...(options?.retryable ? { retryable: true } : {}), + }), + sendLiveArtifactRouteError: () => undefined, + sendMulterError: () => undefined, + }, + paths, + resources: { + listAllDesignSystems: async () => [], + resolveWorkspaceScope: async () => null, + listAllSkills: async () => [], + listAllDesignTemplates: async () => [], + listAllSkillLikeEntries: (async ( + options?: { workspaceId?: string | null }, + ) => { + const entry = options?.workspaceId + ? entries.get(options.workspaceId) + : undefined; + return entry ? [entry] : []; + }) as never, + mimeFor: () => 'text/plain', + }, + }); + const server = app.listen(0, '127.0.0.1'); + servers.push(server); + await new Promise((resolve) => server.once('listening', resolve)); + const address = server.address() as { port: number }; + return `http://127.0.0.1:${address.port}`; +} + +describe('Skill example and asset Workspace authority', () => { + it('serves A and B copies of the same id and carries exact scope into nested assets', async () => { + const baseUrl = await fixture(); + const example = await fetch( + `${baseUrl}/api/skills/same-skill/example?workspaceId=workspace-a&workspaceMemberId=member-a`, + ); + + expect(example.status).toBe(200); + const html = await example.text(); + expect(html).toContain('workspace-a'); + expect(html).not.toContain('workspace-b'); + expect(html).toContain( + '/api/skills/same-skill/assets/secret.txt?workspaceId=workspace-a&workspaceMemberId=member-a', + ); + + const [assetA, assetB] = await Promise.all([ + fetch( + `${baseUrl}/api/skills/same-skill/assets/secret.txt?workspaceId=workspace-a&workspaceMemberId=member-a`, + ), + fetch( + `${baseUrl}/api/skills/same-skill/assets/secret.txt?workspaceId=workspace-b&workspaceMemberId=member-b`, + ), + ]); + expect(await assetA.text()).toBe('workspace-a-bytes'); + expect(await assetB.text()).toBe('workspace-b-bytes'); + }); + + it.each([ + ['workspace-removed', 403, 'WORKSPACE_ACCESS_DENIED'], + ['workspace-outage', 503, 'WORKSPACE_AUTHORITY_UNAVAILABLE'], + ] as const)( + 'returns authority failure for %s without serving another Workspace bytes', + async (workspaceId, status, code) => { + const baseUrl = await fixture(); + const response = await fetch( + `${baseUrl}/api/skills/same-skill/assets/secret.txt?workspaceId=${workspaceId}&workspaceMemberId=member-a`, + ); + + expect(response.status).toBe(status); + expect(await response.json()).toMatchObject({ error: code }); + }, + ); + + it('rejects a partial navigation scope before resolving skill bytes', async () => { + const baseUrl = await fixture(); + const response = await fetch( + `${baseUrl}/api/skills/same-skill/assets/secret.txt?workspaceId=workspace-a`, + ); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + error: 'WORKSPACE_CONTEXT_INCOMPLETE', + }); + }); +}); diff --git a/apps/daemon/tests/skill-url-install.test.ts b/apps/daemon/tests/skill-url-install.test.ts new file mode 100644 index 00000000000..67c37a9cff0 --- /dev/null +++ b/apps/daemon/tests/skill-url-install.test.ts @@ -0,0 +1,306 @@ +import { Readable } from 'node:stream'; +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { c as createTar } from 'tar'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + installSkillFromRemoteSource, + isSafeSkillArchivePath, + type SkillArchiveFetcher, +} from '../src/services/skill-installation.js'; + +const tempRoots: string[] = []; + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function tempRoot(prefix: string): Promise { + const root = await mkdtemp(path.join(tmpdir(), prefix)); + tempRoots.push(root); + return root; +} + +async function archiveFrom( + setup: (root: string) => Promise, + entries: string[], +): Promise { + const root = await tempRoot('od-skill-archive-source-'); + await setup(root); + const archiveRoot = await tempRoot('od-skill-archive-file-'); + const archivePath = path.join(archiveRoot, 'skill.tgz'); + await createTar({ cwd: root, file: archivePath, gzip: true }, entries); + return readFile(archivePath); +} + +function archiveFetcher( + archive: Buffer, + capturedUrls: string[] = [], +): SkillArchiveFetcher { + return async (url) => { + capturedUrls.push(url); + return { + ok: true, + status: 200, + statusText: 'OK', + body: Readable.from(archive), + }; + }; +} + +async function skillArchive(wrapper = 'repo-main'): Promise { + return archiveFrom(async (root) => { + const skillRoot = wrapper ? path.join(root, wrapper) : root; + await mkdir(path.join(skillRoot, 'assets'), { recursive: true }); + await writeFile( + path.join(skillRoot, 'SKILL.md'), + '---\nname: remote-skill\ndescription: Remote fixture\n---\n\n# Workflow\n', + ); + await writeFile(path.join(skillRoot, 'assets', 'fixture.txt'), 'asset'); + }, wrapper ? [wrapper] : ['SKILL.md', 'assets']); +} + +describe('installSkillFromRemoteSource', () => { + it('installs github:owner/repo through the codeload archive path', async () => { + const userSkillsRoot = await tempRoot('od-user-skills-'); + const urls: string[] = []; + const result = await installSkillFromRemoteSource( + userSkillsRoot, + 'github:owner/skill-repo', + { fetcher: archiveFetcher(await skillArchive(), urls) }, + ); + + expect(result).toMatchObject({ ok: true, id: 'remote-skill' }); + expect(urls).toEqual(['https://codeload.github.com/owner/skill-repo/tar.gz/HEAD']); + expect( + await readFile(path.join(userSkillsRoot, 'remote-skill', 'assets', 'fixture.txt'), 'utf8'), + ).toBe('asset'); + }); + + it('installs a browser GitHub URL from the repo-named skill in a multi-skill repository', async () => { + const archive = await archiveFrom(async (root) => { + const repositoryRoot = path.join(root, 'taste-skill-main'); + const defaultSkillRoot = path.join(repositoryRoot, 'skills', 'taste-skill'); + const siblingSkillRoot = path.join(repositoryRoot, 'skills', 'other-skill'); + await mkdir(path.join(defaultSkillRoot, 'assets'), { recursive: true }); + await mkdir(siblingSkillRoot, { recursive: true }); + await writeFile( + path.join(defaultSkillRoot, 'SKILL.md'), + '---\nname: design-taste-frontend\ndescription: Default taste skill\n---\n\n# Workflow\n', + ); + await writeFile(path.join(defaultSkillRoot, 'assets', 'fixture.txt'), 'default asset'); + await writeFile( + path.join(siblingSkillRoot, 'SKILL.md'), + '---\nname: other-skill\ndescription: Sibling fixture\n---\n\n# Other workflow\n', + ); + }, ['taste-skill-main']); + const userSkillsRoot = await tempRoot('od-user-skills-'); + const urls: string[] = []; + + const result = await installSkillFromRemoteSource( + userSkillsRoot, + 'https://github.com/leonxlnx/taste-skill', + { fetcher: archiveFetcher(archive, urls) }, + ); + + expect(result).toMatchObject({ ok: true, id: 'design-taste-frontend' }); + expect(urls).toEqual([ + 'https://codeload.github.com/leonxlnx/taste-skill/tar.gz/HEAD', + ]); + await expect( + readFile( + path.join(userSkillsRoot, 'design-taste-frontend', 'assets', 'fixture.txt'), + 'utf8', + ), + ).resolves.toBe('default asset'); + }); + + it('fails closed when a multi-skill repository has no unique repo-named default', async () => { + const archive = await archiveFrom(async (root) => { + for (const name of ['alpha-skill', 'beta-skill']) { + const skillRoot = path.join(root, 'collection-main', 'skills', name); + await mkdir(skillRoot, { recursive: true }); + await writeFile( + path.join(skillRoot, 'SKILL.md'), + `---\nname: ${name}\ndescription: fixture\n---\n\n# Workflow\n`, + ); + } + }, ['collection-main']); + + const result = await installSkillFromRemoteSource( + await tempRoot('od-user-skills-'), + 'https://github.com/owner/collection', + { fetcher: archiveFetcher(archive) }, + ); + + expect(result).toMatchObject({ + ok: false, + code: 'INVALID_MANIFEST', + error: expect.stringContaining('skills/collection/SKILL.md'), + }); + }); + + it('installs an HTTPS .tar.gz archive with SKILL.md at its root', async () => { + const userSkillsRoot = await tempRoot('od-user-skills-'); + const result = await installSkillFromRemoteSource( + userSkillsRoot, + 'https://downloads.example/remote-skill.tar.gz', + { fetcher: archiveFetcher(await skillArchive('')) }, + ); + + expect(result).toMatchObject({ ok: true, id: 'remote-skill' }); + }); + + it.each([ + 'file:///tmp/skill.tgz', + 'http://downloads.example/skill.tgz', + 'https://downloads.example/skill.zip', + 'github:owner/../repo', + 'https://github.com/owner/repo/issues', + 'https://owner@github.com/owner/repo', + 'https://github.com/owner/repo?tab=readme', + 'https://github.com.evil/owner/repo', + ])('rejects an unsafe or unsupported source: %s', async (source) => { + const result = await installSkillFromRemoteSource( + await tempRoot('od-user-skills-'), + source, + { fetcher: archiveFetcher(await skillArchive()) }, + ); + + expect(result).toMatchObject({ ok: false, code: 'BAD_REQUEST' }); + }); + + it('surfaces an understandable network failure', async () => { + const result = await installSkillFromRemoteSource( + await tempRoot('od-user-skills-'), + 'https://downloads.example/missing.tgz', + { + fetcher: async () => ({ + ok: false, + status: 404, + statusText: 'Not Found', + body: null, + }), + }, + ); + + expect(result).toMatchObject({ + ok: false, + code: 'FETCH_FAILED', + error: expect.stringContaining('404 Not Found'), + }); + }); + + it('reuses the plugin downloader SSRF guard for private archive addresses', async () => { + const result = await installSkillFromRemoteSource( + await tempRoot('od-user-skills-'), + 'https://127.0.0.1/internal-skill.tgz', + ); + + expect(result).toMatchObject({ + ok: false, + code: 'FETCH_FAILED', + error: expect.stringMatching(/private address/i), + }); + }); + + it('rejects an archive without SKILL.md', async () => { + const archive = await archiveFrom(async (root) => { + await mkdir(path.join(root, 'repo-main'), { recursive: true }); + await writeFile(path.join(root, 'repo-main', 'README.md'), '# no manifest'); + }, ['repo-main']); + const result = await installSkillFromRemoteSource( + await tempRoot('od-user-skills-'), + 'https://downloads.example/no-manifest.tgz', + { fetcher: archiveFetcher(archive) }, + ); + + expect(result).toMatchObject({ + ok: false, + code: 'INVALID_MANIFEST', + error: expect.stringContaining('SKILL.md'), + }); + }); + + it('refuses a duplicate skill id instead of overwriting it', async () => { + const userSkillsRoot = await tempRoot('od-user-skills-'); + const archive = await skillArchive(); + const first = await installSkillFromRemoteSource( + userSkillsRoot, + 'https://downloads.example/remote-skill.tgz', + { fetcher: archiveFetcher(archive) }, + ); + const second = await installSkillFromRemoteSource( + userSkillsRoot, + 'https://downloads.example/remote-skill.tgz', + { fetcher: archiveFetcher(archive) }, + ); + + expect(first).toMatchObject({ ok: true }); + expect(second).toMatchObject({ + ok: false, + code: 'CONFLICT', + error: expect.stringContaining('already installed'), + }); + }); + + it('detects a duplicate id even when a legacy install uses a different folder name', async () => { + const userSkillsRoot = await tempRoot('od-user-skills-'); + const legacyRoot = path.join(userSkillsRoot, 'legacy-repository-name'); + await mkdir(legacyRoot, { recursive: true }); + await writeFile( + path.join(legacyRoot, 'SKILL.md'), + '---\nname: remote-skill\ndescription: Existing fixture\n---\n\n# Existing\n', + ); + + const result = await installSkillFromRemoteSource( + userSkillsRoot, + 'https://downloads.example/remote-skill.tgz', + { fetcher: archiveFetcher(await skillArchive()) }, + ); + + expect(result).toMatchObject({ + ok: false, + code: 'CONFLICT', + error: expect.stringContaining('already installed'), + }); + }); + + it('rejects archives containing symbolic links', async () => { + const archive = await archiveFrom(async (root) => { + const skillRoot = path.join(root, 'repo-main'); + await mkdir(skillRoot, { recursive: true }); + await writeFile( + path.join(skillRoot, 'SKILL.md'), + '---\nname: linked-skill\ndescription: fixture\n---\nbody\n', + ); + await symlink('/etc/hosts', path.join(skillRoot, 'escape')); + }, ['repo-main']); + const result = await installSkillFromRemoteSource( + await tempRoot('od-user-skills-'), + 'https://downloads.example/linked-skill.tgz', + { fetcher: archiveFetcher(archive) }, + ); + + expect(result).toMatchObject({ + ok: false, + code: 'INVALID_ARCHIVE', + error: expect.stringContaining('link'), + }); + }); +}); + +describe('isSafeSkillArchivePath', () => { + it.each(['../escape', '/absolute', 'safe/../../escape', 'safe\\..\\escape'])( + 'rejects traversal path %s', + (entry) => { + expect(isSafeSkillArchivePath(entry)).toBe(false); + }, + ); + + it('accepts a normal nested archive path', () => { + expect(isSafeSkillArchivePath('repo-main/assets/example.txt')).toBe(true); + }); +}); diff --git a/apps/daemon/tests/skills-workspace-scope.test.ts b/apps/daemon/tests/skills-workspace-scope.test.ts new file mode 100644 index 00000000000..50241848dbe --- /dev/null +++ b/apps/daemon/tests/skills-workspace-scope.test.ts @@ -0,0 +1,298 @@ +// Skill's workspace-isolation onboarding (specs/current/ +// 04-resource-workspace-isolation.md §9.1): skill previously had NO +// persistent attribution at all — no table, no metadata.json, nothing — +// which meant `GET /api/skills` returned one flat list to every workspace +// and `DELETE /api/skills/:id` let ANY caller delete ANY skill regardless of +// who imported it or whether it was a team share. Skill now binds into the +// generic `workspace_resources` table (see db.ts) exactly like plugin does: +// +// - `listSkills`'s workspace filter (skills.ts's `skillVisibleFromWorkspace`) +// — mirrors `listInstalledPlugins`'s one-way "unclaimed visible +// everywhere, claimed elsewhere hidden" rule. +// - `DELETE /api/skills/:id` is gated by the shared +// `enforceWorkspaceResourceMutation` (collab/workspace-resource-mutation.ts), +// the same gate `POST /api/plugins/:id/uninstall` uses — see +// tests/plugins-uninstall-workspace-gate.test.ts for the plugin +// equivalent this file mirrors. +// +// Follows the same "seed the skill folder directly on disk, alongside the +// real running server, then bind it via db.ts" pattern as the plugin test: +// db.ts caches one SQLite instance per resolved data dir, and +// RUNTIME_DATA_DIR / OD_DATA_DIR agree within one vitest file, so this +// reuses the server's own connection instead of racing a second one. + +import type http from 'node:http'; +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { startServer } from '../src/server.js'; +import { + ensureWorkspaceResource, + getWorkspaceResourceByResourceId, + openDatabase, + updateWorkspaceResource, +} from '../src/db.js'; + +let server: http.Server; +let baseUrl: string; +let shutdown: (() => Promise | void) | undefined; +let userSkillsDir: string; + +beforeAll(async () => { + const started = (await startServer({ port: 0, returnServer: true })) as { + url: string; + server: http.Server; + shutdown?: () => Promise | void; + }; + baseUrl = started.url; + server = started.server; + shutdown = started.shutdown; + // Same data root the running server resolved RUNTIME_DATA_DIR from (see + // server.ts's `USER_SKILLS_DIR = path.join(RUNTIME_DATA_DIR, 'skills')`); + // tests/setup.ts pins OD_DATA_DIR to an isolated temp dir before any test + // imports server.ts, and it is already absolute, so resolveDataDir() + // returns it unchanged. + userSkillsDir = path.join(process.env.OD_DATA_DIR!, 'skills'); +}); + +afterAll(async () => { + await Promise.resolve(shutdown?.()); + await new Promise((resolve) => server.close(() => resolve())); +}); + +function workspaceHeaders(memberId: string, role: 'owner' | 'admin' | 'member', workspaceId: string) { + return { + 'x-od-workspace-id': workspaceId, + 'x-od-workspace-member-id': memberId, + 'x-od-workspace-role': role, + }; +} + +async function seedSkillFolder(skillId: string): Promise { + const folder = path.join(userSkillsDir, skillId); + await mkdir(folder, { recursive: true }); + await writeFile( + path.join(folder, 'SKILL.md'), + `---\nname: "${skillId}"\ndescription: "Test skill ${skillId}."\n---\n\nBody for ${skillId}.\n`, + ); + return folder; +} + +function bindSkillToWorkspace(skillId: string, workspaceId: string, createdByWorkspaceMemberId: string) { + const db = openDatabase(process.cwd(), { dataDir: process.env.OD_DATA_DIR! }); + return ensureWorkspaceResource(db, 'skill', workspaceId, skillId, { + visibility: 'personal', + resourceState: 'active', + createdByWorkspaceMemberId, + updatedByWorkspaceMemberId: createdByWorkspaceMemberId, + }); +} + +async function fetchSkillIds(workspaceId?: string): Promise { + const resp = await fetch( + `${baseUrl}/api/skills`, + workspaceId + ? { + headers: { + 'x-od-workspace-id': workspaceId, + 'x-od-workspace-member-id': 'member-owner', + }, + } + : undefined, + ); + const body = (await resp.json()) as { skills: Array<{ id: string }> }; + return body.skills.map((s) => s.id); +} + +describe('GET /api/skills — workspace visibility scope', () => { + it('keeps an unclaimed (unbound) skill visible from every workspace', async () => { + const skillId = `wsscope-unclaimed-${Date.now()}`; + await seedSkillFolder(skillId); + + expect(await fetchSkillIds('ws-scope-a')).toContain(skillId); + expect(await fetchSkillIds('ws-scope-b')).toContain(skillId); + // Also visible with no workspace header at all (unscoped catalog). + expect(await fetchSkillIds()).toContain(skillId); + }); + + it('hides a skill claimed by a different workspace, but shows it from its own', async () => { + const skillId = `wsscope-claimed-${Date.now()}`; + await seedSkillFolder(skillId); + bindSkillToWorkspace(skillId, 'ws-scope-owner', 'member-owner'); + + expect(await fetchSkillIds('ws-scope-owner')).toContain(skillId); + expect(await fetchSkillIds('ws-scope-other')).not.toContain(skillId); + }); + + // spec 04 §10: the symmetric case plugin/design-system already pin — a + // CLAIMED skill must not leak to a caller with no workspace identity at + // all (signed-out client, headerless `curl`), not just to a caller scoped + // to a DIFFERENT workspace. Before this fix, `GET /api/skills` with no + // header fell through to the unfiltered "no scope = everything" branch the + // same way plugin/design-system did — "no scope" must not mean "trust + // everything" (recvqbeDjAsejl / recvqbklNGDqYY). + it('hides a claimed skill from a caller with no workspace header at all', async () => { + const skillId = `wsscope-headerless-${Date.now()}`; + await seedSkillFolder(skillId); + bindSkillToWorkspace(skillId, 'ws-scope-headerless', 'member-owner'); + + expect(await fetchSkillIds()).not.toContain(skillId); + // Still visible from its own workspace, and to any workspace for an + // unclaimed sibling — this fix narrows one branch, not the whole filter. + expect(await fetchSkillIds('ws-scope-headerless')).toContain(skillId); + }); +}); + +describe('DELETE /api/skills/:id — workspace ownership gate', () => { + it('rejects a non-owner, non-privileged member of the same workspace', async () => { + const skillId = `wsgate-member-${Date.now()}`; + const folder = await seedSkillFolder(skillId); + bindSkillToWorkspace(skillId, 'skill-gate-1', 'member-owner'); + + const resp = await fetch(`${baseUrl}/api/skills/${skillId}`, { + method: 'DELETE', + headers: workspaceHeaders('member-other', 'member', 'skill-gate-1'), + }); + + expect(resp.status).toBe(403); + expect(existsSync(folder)).toBe(true); + }); + + it('allows the member who imported the skill to delete it', async () => { + const skillId = `wsgate-self-${Date.now()}`; + const folder = await seedSkillFolder(skillId); + bindSkillToWorkspace(skillId, 'skill-gate-2', 'member-owner'); + + const resp = await fetch(`${baseUrl}/api/skills/${skillId}`, { + method: 'DELETE', + headers: workspaceHeaders('member-owner', 'member', 'skill-gate-2'), + }); + + expect(resp.status).toBe(200); + expect(existsSync(folder)).toBe(false); + // The binding row is cleaned up too — no orphan left behind for a future + // re-import of the same id to find and silently reuse. + const db = openDatabase(process.cwd(), { dataDir: process.env.OD_DATA_DIR! }); + expect(getWorkspaceResourceByResourceId(db, 'skill', skillId)).toBeUndefined(); + }); + + it('allows a workspace admin to delete a skill imported by someone else', async () => { + const skillId = `wsgate-admin-${Date.now()}`; + const folder = await seedSkillFolder(skillId); + bindSkillToWorkspace(skillId, 'skill-gate-3', 'member-owner'); + + const resp = await fetch(`${baseUrl}/api/skills/${skillId}`, { + method: 'DELETE', + headers: workspaceHeaders('member-admin', 'admin', 'skill-gate-3'), + }); + + expect(resp.status).toBe(200); + expect(existsSync(folder)).toBe(false); + }); + + // No retroactive tagging (spec's stated design principle, same rule + // design-systems and plugin already ship): a skill with no + // workspace_resources row — every skill imported before this round shipped + // — stays outside the isolation regime rather than becoming permanently + // un-deletable the moment a caller happens to carry workspace headers. + it('still allows deleting a legacy skill with no workspace binding at all', async () => { + const skillId = `wsgate-legacy-${Date.now()}`; + const folder = await seedSkillFolder(skillId); + + const resp = await fetch(`${baseUrl}/api/skills/${skillId}`, { + method: 'DELETE', + headers: workspaceHeaders('member-someone-else', 'member', 'skill-gate-4'), + }); + + expect(resp.status).toBe(200); + expect(existsSync(folder)).toBe(false); + }); + + it('rejects a headerless caller against a team-visibility skill', async () => { + const skillId = `wsgate-team-${Date.now()}`; + const folder = await seedSkillFolder(skillId); + bindSkillToWorkspace(skillId, 'skill-gate-5', 'member-owner'); + const db = openDatabase(process.cwd(), { dataDir: process.env.OD_DATA_DIR! }); + updateWorkspaceResource(db, 'skill', 'skill-gate-5', skillId, { visibility: 'team' }); + + const resp = await fetch(`${baseUrl}/api/skills/${skillId}`, { method: 'DELETE' }); + + expect(resp.status).toBe(400); + expect(existsSync(folder)).toBe(true); + }); + + // spec 04 §10 fix #3: `enforceWorkspaceResourceMutation`'s null-ctx branch + // used to only refuse a `visibility: 'team'` row, letting a headerless + // caller delete any BOUND-BUT-`personal` skill (`bindSkillToWorkspace` + // above defaults to `visibility: 'personal'`) — a claimed resource is a + // claimed resource regardless of who else it's shared with. + it('rejects a headerless caller against a personal-visibility (but bound) skill too', async () => { + const skillId = `wsgate-personal-headerless-${Date.now()}`; + const folder = await seedSkillFolder(skillId); + bindSkillToWorkspace(skillId, 'skill-gate-6', 'member-owner'); + + const resp = await fetch(`${baseUrl}/api/skills/${skillId}`, { method: 'DELETE' }); + + expect(resp.status).toBe(400); + expect(existsSync(folder)).toBe(true); + }); +}); + +// Skill previously had NO field at all distinguishing a skill materialized +// from a TEAMMATE's team share from one the caller authored themselves — both +// read `source: 'user'`. Design-system (`metadata.json`'s `teamSynced`) and +// plugin (`installed_plugins.source`'s `team:plugin:` prefix) already carried +// this; skill was the one kind missing it entirely, so unsharing a skill +// team-side made the puller's stale copy silently reappear as "Personal" +// instead of just dropping out of the Team scope. `teamSynced` on +// `SkillSummary` closes that gap, sourced from the same `workspace_resources` +// binding `syncSharedTeamSkill`'s `markTeamSynced` (server.ts) already writes +// as `visibility: 'team'` — this test only exercises the READ side (the +// `GET /api/skills` projection), not the write path itself. +describe('GET /api/skills — teamSynced projection', () => { + it('reports teamSynced:true for a skill bound with visibility "team"', async () => { + const skillId = `wsteamsynced-team-${Date.now()}`; + await seedSkillFolder(skillId); + bindSkillToWorkspace(skillId, 'ws-teamsynced-1', 'member-owner'); + const db = openDatabase(process.cwd(), { dataDir: process.env.OD_DATA_DIR! }); + updateWorkspaceResource(db, 'skill', 'ws-teamsynced-1', skillId, { visibility: 'team' }); + + const resp = await fetch(`${baseUrl}/api/skills`, { + headers: workspaceHeaders('member-owner', 'member', 'ws-teamsynced-1'), + }); + const body = (await resp.json()) as { skills: Array<{ id: string; teamSynced?: boolean }> }; + const skill = body.skills.find((s) => s.id === skillId); + + expect(skill?.teamSynced).toBe(true); + }); + + it('omits teamSynced for a personal-visibility bound skill (the sharer\'s own copy)', async () => { + const skillId = `wsteamsynced-personal-${Date.now()}`; + await seedSkillFolder(skillId); + bindSkillToWorkspace(skillId, 'ws-teamsynced-2', 'member-owner'); + + const resp = await fetch(`${baseUrl}/api/skills`, { + headers: workspaceHeaders('member-owner', 'member', 'ws-teamsynced-2'), + }); + const body = (await resp.json()) as { skills: Array<{ id: string; teamSynced?: boolean }> }; + const skill = body.skills.find((s) => s.id === skillId); + + expect(skill).toBeTruthy(); + expect(skill?.teamSynced).toBeFalsy(); + }); + + it('omits teamSynced for an unbound (legacy) skill', async () => { + const skillId = `wsteamsynced-legacy-${Date.now()}`; + await seedSkillFolder(skillId); + + const resp = await fetch(`${baseUrl}/api/skills`, { + headers: workspaceHeaders('member-owner', 'member', 'ws-teamsynced-legacy'), + }); + const body = (await resp.json()) as { skills: Array<{ id: string; teamSynced?: boolean }> }; + const skill = body.skills.find((s) => s.id === skillId); + + expect(skill).toBeTruthy(); + expect(skill?.teamSynced).toBeFalsy(); + }); +}); diff --git a/apps/daemon/tests/static-resource-routes.test.ts b/apps/daemon/tests/static-resource-routes.test.ts index 8f6b9c5b63f..073320f62d7 100644 --- a/apps/daemon/tests/static-resource-routes.test.ts +++ b/apps/daemon/tests/static-resource-routes.test.ts @@ -23,6 +23,10 @@ describe('static resource mutation routes', () => { const app = express(); app.use(express.json({ limit: '4mb' })); registerStaticResourceRoutes(app, { + // Never reached by any subtest in this file — every request either + // 403s on the cross-origin guard or hits a design-system-only route + // before touching the skill workspace-mutation gate that reads it. + db: {} as any, http: { createSseResponse: () => undefined, isLocalSameOrigin, @@ -189,6 +193,10 @@ describe('design system import catalog lookup', () => { const app = express(); app.use(express.json({ limit: '4mb' })); registerStaticResourceRoutes(app, { + // Never reached by any subtest in this file — every request either + // 403s on the cross-origin guard or hits a design-system-only route + // before touching the skill workspace-mutation gate that reads it. + db: {} as any, http: { createSseResponse: () => undefined, isLocalSameOrigin, diff --git a/apps/daemon/tests/team-mirror-read-revocation.test.ts b/apps/daemon/tests/team-mirror-read-revocation.test.ts new file mode 100644 index 00000000000..206f16783bd --- /dev/null +++ b/apps/daemon/tests/team-mirror-read-revocation.test.ts @@ -0,0 +1,151 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type http from 'node:http'; + +import { openDatabase } from '../src/db.js'; +import { startServer } from '../src/server.js'; + +// #2 (team collab): once a project is moved out of the team, a former member's +// pulled local mirror must stop serving its files. The pull gate stamps a +// non-destructive `teamMirrorRevokedAt` flag on the local project; the read +// routes must then refuse to serve it (the bytes stay on disk, so a re-share +// clears the flag and restores access). A member's own local project — which +// never carries the flag — must keep reading normally. +describe('team mirror read revocation', () => { + let server: http.Server; + let baseUrl: string; + + beforeAll(async () => { + const started = (await startServer({ port: 0, returnServer: true })) as { + url: string; + server: http.Server; + }; + baseUrl = started.url; + server = started.server; + }); + + afterAll(() => new Promise((resolve) => server.close(() => resolve()))); + + async function createProject(id: string, metadata?: Record) { + const res = await fetch(`${baseUrl}/api/projects`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id, name: id, skillId: null, designSystemId: null, ...(metadata ? { metadata } : {}) }), + }); + expect(res.status).toBe(200); + return await res.json() as { + conversationId?: string; + }; + } + + async function addIndexHtml(id: string) { + const res = await fetch(`${baseUrl}/api/projects/${id}/files`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'index.html', content: '

mirror

' }), + }); + expect(res.status).toBe(200); + } + + it('serves a normal project but 404s reads of a revoked team mirror', async () => { + const suffix = Date.now(); + const normalId = `mirror-normal-${suffix}`; + const revokedId = `mirror-revoked-${suffix}`; + + const normalProject = await createProject(normalId); + await addIndexHtml(normalId); + // A revoked mirror still has its bytes on disk (addIndexHtml writes them); + // only the read routes must refuse. + const revokedProject = await createProject(revokedId, { + teamMirrorRevokedAt: suffix, + }); + await addIndexHtml(revokedId); + + // The quarantine marker is durable. Restart so the production O(1) + // revoked-project index hydrates from SQLite exactly as a member daemon + // does after observing an unshare in an earlier process. + await new Promise((resolve) => server.close(() => resolve())); + const restarted = (await startServer({ + port: 0, + returnServer: true, + })) as { + url: string; + server: http.Server; + }; + baseUrl = restarted.url; + server = restarted.server; + + // Control: the member's own (unflagged) project reads normally. + expect((await fetch(`${baseUrl}/api/projects/${normalId}/raw/index.html`)).status).toBe(200); + expect((await fetch(`${baseUrl}/api/projects/${normalId}/files`)).status).toBe(200); + expect((await fetch(`${baseUrl}/api/projects/${normalId}/files/index.html`)).status).toBe(200); + + // Revoked team mirror: content, metadata, conversation, status, tabs, + // preview, live-artifact, and SSE entry points all refuse. + expect((await fetch(`${baseUrl}/api/projects/${revokedId}/raw/index.html`)).status).toBe(404); + expect((await fetch(`${baseUrl}/api/projects/${revokedId}/files`)).status).toBe(404); + expect((await fetch(`${baseUrl}/api/projects/${revokedId}/files/index.html`)).status).toBe(404); + const conversationId = revokedProject.conversationId; + expect(conversationId).toBeTruthy(); + const deniedReadUrls = [ + `/api/projects/${revokedId}`, + `/api/projects/${revokedId}/workspace-scope`, + `/api/projects/${revokedId}/tabs`, + `/api/projects/${revokedId}/events`, + `/api/projects/${revokedId}/preview-url`, + `/api/projects/${revokedId}/conversations`, + `/api/projects/${revokedId}/conversations/${conversationId}/messages`, + `/api/projects/${revokedId}/collab/status`, + `/api/live-artifacts?projectId=${revokedId}`, + `/api/live-artifacts/missing/preview?projectId=${revokedId}`, + ]; + for (const url of deniedReadUrls) { + expect( + (await fetch(`${baseUrl}${url}`)).status, + `expected ${url} to deny the revoked mirror`, + ).toBe(404); + } + // Hot-path quarantine checks must use the startup-hydrated in-memory + // index. Detail/files already need one project row for their response; + // revocation must not add a second lookup. Comments need no project row + // at all. Check both normal and revoked projects so the optimization + // cannot accidentally become a revoked-only shortcut. + const dataDir = process.env.OD_DATA_DIR; + if (!dataDir) throw new Error('OD_DATA_DIR is required for this test'); + const db = openDatabase(process.cwd(), { dataDir }); + const prepareSpy = vi.spyOn(db, 'prepare'); + const projectMetadataReads = () => + prepareSpy.mock.calls.filter( + ([sql]) => + typeof sql === 'string' + && /\bFROM projects WHERE id = \?/.test(sql), + ).length; + const expectProjectReads = async ( + url: string, + expectedStatus: number, + expectedReads: number, + ) => { + prepareSpy.mockClear(); + expect((await fetch(`${baseUrl}${url}`)).status).toBe(expectedStatus); + expect(projectMetadataReads(), `unexpected project-row reads for ${url}`) + .toBe(expectedReads); + }; + try { + await expectProjectReads(`/api/projects/${normalId}`, 200, 1); + await expectProjectReads(`/api/projects/${revokedId}`, 404, 1); + await expectProjectReads(`/api/projects/${normalId}/files`, 200, 1); + await expectProjectReads(`/api/projects/${revokedId}/files`, 404, 1); + await expectProjectReads( + `/api/projects/${normalId}/conversations/${normalProject.conversationId}/comments`, + 200, + 0, + ); + await expectProjectReads( + `/api/projects/${revokedId}/conversations/${conversationId}/comments`, + 403, + 0, + ); + } finally { + prepareSpy.mockRestore(); + } + }); +}); diff --git a/apps/daemon/tests/team-resource-routes.test.ts b/apps/daemon/tests/team-resource-routes.test.ts new file mode 100644 index 00000000000..49e013705c0 --- /dev/null +++ b/apps/daemon/tests/team-resource-routes.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import express from 'express'; +import http from 'node:http'; +import { TeamResourceCopyForbiddenError } from '@open-design/contracts'; +import { registerTeamResourceRoutes } from '../src/routes/team-resources.js'; +import { + createDevTeamResourceStateProvider, + enforceTeamResourceCopyAllowed, +} from '../src/collab/team-resource-state.js'; + +let server: http.Server | null = null; + +afterEach(async () => { + if (server) { + const toClose = server; + server = null; + await new Promise((resolve) => toClose.close(() => resolve())); + } +}); + +async function startServer() { + const app = express(); + app.use(express.json()); + registerTeamResourceRoutes(app, { teamResources: createDevTeamResourceStateProvider() }); + server = http.createServer(app); + await new Promise((resolve) => server!.listen(0, resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('server did not bind to a TCP port'); + const base = `http://127.0.0.1:${address.port}`; + return { + async req(route: string, options: { method?: string; body?: unknown } = {}) { + const init: RequestInit = { method: options.method ?? 'GET' }; + if (options.body !== undefined) { + init.headers = { 'content-type': 'application/json' }; + init.body = JSON.stringify(options.body); + } + const response = await fetch(`${base}${route}`, init); + return { status: response.status, body: (await response.json()) as Record }; + }, + }; +} + +describe('team resource routes (D1 state + D3 enforcement)', () => { + it('treats an unregistered resource as personal and allows the copy', async () => { + const api = await startServer(); + expect((await api.req('/api/workspace/resources/plugin/p1/state')).body).toEqual({ scope: 'personal' }); + const check = await api.req('/api/workspace/resources/plugin/p1/copy-check', { method: 'POST' }); + expect(check.status).toBe(200); + expect(check.body.allowed).toBe(true); + }); + + it('allows copying an active team resource', async () => { + const api = await startServer(); + await api.req('/api/workspace/resources/design-system/ds1/state', { + method: 'PUT', + body: { scope: 'team', state: 'active' }, + }); + const check = await api.req('/api/workspace/resources/design-system/ds1/copy-check', { method: 'POST' }); + expect(check.status).toBe(200); + expect(check.body.allowed).toBe(true); + }); + + it('REJECTS copying a frozen team resource with a 403 WORKSPACE_RESOURCE_FROZEN', async () => { + const api = await startServer(); + await api.req('/api/workspace/resources/skill/s1/state', { + method: 'PUT', + body: { scope: 'team', state: 'frozen' }, + }); + expect((await api.req('/api/workspace/resources/skill/s1/state')).body).toEqual({ + scope: 'team', + state: 'frozen', + }); + const check = await api.req('/api/workspace/resources/skill/s1/copy-check', { method: 'POST' }); + expect(check.status).toBe(403); + expect(check.body.error.code).toBe('WORKSPACE_RESOURCE_FROZEN'); + }); + + it('rejects an invalid resource kind', async () => { + const api = await startServer(); + const res = await api.req('/api/workspace/resources/nonsense/x/state'); + expect(res.status).toBe(400); + }); +}); + +describe('enforceTeamResourceCopyAllowed (route-layer guard the copy-out routes call)', () => { + it('passes for an unregistered (personal) resource', async () => { + const provider = createDevTeamResourceStateProvider(); + await expect( + enforceTeamResourceCopyAllowed(provider, { kind: 'plugin', resourceId: 'p1' }), + ).resolves.toBeUndefined(); + }); + + it('throws a coded error for a frozen team resource', async () => { + const provider = createDevTeamResourceStateProvider(); + provider.set?.({ kind: 'plugin', resourceId: 'p1' }, { scope: 'team', state: 'frozen' }); + await expect( + enforceTeamResourceCopyAllowed(provider, { kind: 'plugin', resourceId: 'p1' }), + ).rejects.toBeInstanceOf(TeamResourceCopyForbiddenError); + }); +}); diff --git a/apps/daemon/tests/team-resource-share-list-cache.test.ts b/apps/daemon/tests/team-resource-share-list-cache.test.ts new file mode 100644 index 00000000000..c660648bc50 --- /dev/null +++ b/apps/daemon/tests/team-resource-share-list-cache.test.ts @@ -0,0 +1,503 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import express from 'express'; +import http from 'node:http'; +import { registerTeamResourceShareRoutes } from '../src/routes/team-resource-share.js'; +import { createSwrCache } from '../src/collab/swr-cache.js'; +import { invalidateTeamResourceListingCaches } from '../src/collab/team-resource-list-cache.js'; +import type { + TeamResourceRequestScope, + TeamResourceShareRecord, + TeamResourceShareService, +} from '../src/collab/team-resource-share.js'; + +let server: http.Server | null = null; +const SCOPE: TeamResourceRequestScope = { + principal: { + memberId: 'wm-1', + teamId: 'ws-1', + role: 'owner', + lifecycleState: 'active', + workspaceType: 'team', + }, + canShare: true, +}; +const SCOPE_B: TeamResourceRequestScope = { + principal: { + memberId: 'wm-2', + teamId: 'ws-2', + role: 'member', + lifecycleState: 'active', + workspaceType: 'team', + }, + canShare: true, +}; + +afterEach(async () => { + if (server) { + const toClose = server; + server = null; + await new Promise((resolve) => toClose.close(() => resolve())); + } +}); + +function fakeShare(records: TeamResourceShareRecord[]): { + service: TeamResourceShareService; + calls: () => number; +} { + let calls = 0; + const service = { + async sharedResources() { + calls += 1; + return records; + }, + async share() { + return null; + }, + async unshare() { + return false; + }, + } as unknown as TeamResourceShareService; + return { service, calls: () => calls }; +} + +interface TestServer { + base: string; + get(route: string, headers?: Record): Promise<{ status: number; body: Record }>; + post(route: string, headers?: Record): Promise<{ status: number; body: Record }>; + del(route: string, headers?: Record): Promise<{ status: number; body: Record }>; +} + +type RouteDeps = Parameters[1]; + +async function startServer( + deps: Omit & Partial>, +): Promise { + const app = express(); + app.use(express.json()); + registerTeamResourceShareRoutes(app, { + resolveScope: async () => ({ ok: true, scope: SCOPE }), + ...deps, + }); + server = http.createServer(app); + await new Promise((resolve) => server!.listen(0, resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('server did not bind to a TCP port'); + const base = `http://127.0.0.1:${address.port}`; + const call = async (method: string, route: string, headers?: Record) => { + const response = await fetch(`${base}${route}`, { + method, + ...(headers ? { headers } : {}), + }); + return { status: response.status, body: (await response.json()) as Record }; + }; + return { + base, + get: (route, headers) => call('GET', route, headers), + post: (route, headers) => call('POST', route, headers), + del: (route, headers) => call('DELETE', route, headers), + }; +} + +const record = (id: string): TeamResourceShareRecord => + ({ id, localId: id, version: 1 }) as unknown as TeamResourceShareRecord; + +describe('team resource share /team listing', () => { + it('forwards each request-resolved scope through list, share, and unshare', async () => { + const seen: Array<{ operation: string; workspaceId: string }> = []; + const service = { + async sharedResources(scope: TeamResourceRequestScope) { + seen.push({ operation: 'list', workspaceId: scope.principal.teamId }); + return []; + }, + async share(_id: string, scope: TeamResourceRequestScope) { + seen.push({ operation: 'share', workspaceId: scope.principal.teamId }); + return { version: 1 }; + }, + async unshare(_id: string, scope: TeamResourceRequestScope) { + seen.push({ operation: 'unshare', workspaceId: scope.principal.teamId }); + return true; + }, + } as unknown as TeamResourceShareService; + const req = await startServer({ + basePath: 'skills', + share: service, + resolveScope: async (request) => ({ + ok: true, + scope: request.get('x-test-workspace') === 'ws-2' ? SCOPE_B : SCOPE, + }), + }); + + await req.get('/api/workspace/skills/team'); + await req.post('/api/workspace/skills/a/share', { 'x-test-workspace': 'ws-2' }); + await req.del('/api/workspace/skills/a/share'); + + expect(seen).toEqual([ + { operation: 'list', workspaceId: 'ws-1' }, + { operation: 'share', workspaceId: 'ws-2' }, + { operation: 'unshare', workspaceId: 'ws-1' }, + ]); + }); + + it.each([ + [400, 'WORKSPACE_CONTEXT_REQUIRED'], + [403, 'WORKSPACE_ACCESS_DENIED'], + [503, 'WORKSPACE_AUTHORITY_UNAVAILABLE'], + ] as const)('returns explicit authority failure %s (%s)', async (status, code) => { + const { service, calls } = fakeShare([]); + const req = await startServer({ + basePath: 'plugins', + share: service, + resolveScope: async () => ({ + ok: false, + status, + code, + message: 'scope rejected', + ...(status === 503 ? { retryable: true as const } : {}), + }), + }); + + const response = await req.get('/api/workspace/plugins/team'); + expect(response.status).toBe(status); + expect(response.body).toMatchObject({ + error: code, + message: 'scope rejected', + ...(status === 503 ? { retryable: true } : {}), + }); + expect(calls()).toBe(0); + }); + + it('serves the cached listTeam provider instead of hitting the hub', async () => { + const { service, calls } = fakeShare([record('a'), record('b')]); + let listTeamCalls = 0; + const req = await startServer({ + basePath: 'skills', + share: service, + listTeam: async () => { + listTeamCalls += 1; + return { ids: ['a', 'b'], resources: [record('a'), record('b')] }; + }, + }); + const res = await req.get('/api/workspace/skills/team'); + expect(res.status).toBe(200); + expect(res.body.ids).toEqual(['a', 'b']); + // The cache provider was consulted; the raw hub read was NOT hit on the path. + expect(listTeamCalls).toBe(1); + expect(calls()).toBe(0); + }); + + it('falls back to the direct hub read when no listTeam provider is given', async () => { + const { service, calls } = fakeShare([record('x')]); + const req = await startServer({ basePath: 'plugins', share: service }); + const res = await req.get('/api/workspace/plugins/team'); + expect(res.status).toBe(200); + expect(res.body.ids).toEqual(['x']); + expect(calls()).toBe(1); + }); +}); + +// Acceptance: "分享给团队的设计体系没有在设计体系做状态同步" / "分享到团队完成后团队的 +// 设计体系没增加,要等很久,取消分享到团队也是". The route's response is what makes the +// client refetch `/team` right away; without an invalidate seam that refetch was +// served the pre-change list straight out of the daemon's own 3s SWR cache, so +// the change only became visible on a later poll (up to 60s once SSE lowers the +// client's cadence). These tests wire a REAL `createSwrCache` (the same +// primitive `cachedTeamResourceList` in server.ts uses) through the actual +// route handlers, so they fail if the POST/DELETE handlers stop calling +// `listTeam.invalidate()` on success. +describe('team resource share success invalidates the cached /team listing', () => { + function mutableShare(initial: TeamResourceShareRecord[]) { + let records = initial; + let sharedResourcesCalls = 0; + const service: TeamResourceShareService = { + async sharedResources() { + sharedResourcesCalls += 1; + return records; + }, + async share(id: string) { + records = [...records, record(id)]; + return { version: (records.length) }; + }, + async unshare(id: string) { + const before = records.length; + records = records.filter((r) => r.id !== id); + return records.length < before; + }, + } as unknown as TeamResourceShareService; + return { service, sharedResourcesCalls: () => sharedResourcesCalls }; + } + + // freshMs is generous (3000ms, matching production) precisely so that a + // successful invalidate is the ONLY way the very next read can be fresh — + // if the handler forgot to call it, this test would still be well inside the + // stale window and would observe the pre-mutation list. + const PROD_FRESH_MS = 3000; + + for (const kind of ['design-systems', 'plugins', 'skills'] as const) { + it(`share success (${kind}) makes the very next /team read reflect it, inside freshMs`, async () => { + const { service, sharedResourcesCalls } = mutableShare([record('a')]); + const listTeam = createSwrCache( + async () => { + const resources = await service.sharedResources(SCOPE); + return { ids: resources.map((r) => r.id), resources }; + }, + () => 'ws-1', + PROD_FRESH_MS, + ); + const req = await startServer({ basePath: kind, share: service, listTeam }); + + const before = await req.get(`/api/workspace/${kind}/team`); + expect(before.body.ids).toEqual(['a']); + expect(sharedResourcesCalls()).toBe(1); + + const shareResp = await req.post(`/api/workspace/${kind}/b/share`); + expect(shareResp.status).toBe(200); + expect(shareResp.body).toMatchObject({ shared: true }); + + // No delay, no waiting out freshMs — this is the exact timing the + // client's own post-share refetch relies on. + const after = await req.get(`/api/workspace/${kind}/team`); + expect(after.body.ids).toEqual(['a', 'b']); + expect(sharedResourcesCalls()).toBe(2); + }); + + it(`unshare success (${kind}) makes the very next /team read reflect it, inside freshMs`, async () => { + const { service, sharedResourcesCalls } = mutableShare([record('a'), record('b')]); + const listTeam = createSwrCache( + async () => { + const resources = await service.sharedResources(SCOPE); + return { ids: resources.map((r) => r.id), resources }; + }, + () => 'ws-1', + PROD_FRESH_MS, + ); + const req = await startServer({ basePath: kind, share: service, listTeam }); + + const before = await req.get(`/api/workspace/${kind}/team`); + expect(before.body.ids).toEqual(['a', 'b']); + expect(sharedResourcesCalls()).toBe(1); + + const unshareResp = await req.del(`/api/workspace/${kind}/b/share`); + expect(unshareResp.status).toBe(200); + expect(unshareResp.body).toEqual({ unshared: true }); + + const after = await req.get(`/api/workspace/${kind}/team`); + expect(after.body.ids).toEqual(['a']); + expect(sharedResourcesCalls()).toBe(2); + }); + } + + it('does not invalidate on a share no-op (shared:false — no team identity)', async () => { + const { service, sharedResourcesCalls } = mutableShare([record('a')]); + // Shadow `share` to answer the off-team no-op without mutating anything — + // a real off-team share() never reaches the mutation branch above. + (service as unknown as { share: TeamResourceShareService['share'] }).share = async () => null; + const listTeam = createSwrCache( + async () => { + const resources = await service.sharedResources(SCOPE); + return { ids: resources.map((r) => r.id), resources }; + }, + () => 'ws-1', + 3000, + ); + const req = await startServer({ basePath: 'design-systems', share: service, listTeam }); + + await req.get('/api/workspace/design-systems/team'); + const shareResp = await req.post('/api/workspace/design-systems/b/share'); + expect(shareResp.body).toEqual({ shared: false }); + + // Still inside freshMs and nothing actually changed — the cache is + // untouched, proving the handler did not fire an unconditional invalidate. + await req.get('/api/workspace/design-systems/team'); + expect(sharedResourcesCalls()).toBe(1); + }); + + it('does not invalidate on an unshare no-op (unshared:false)', async () => { + const { service, sharedResourcesCalls } = mutableShare([record('a')]); + (service as unknown as { unshare: TeamResourceShareService['unshare'] }).unshare = async () => false; + const listTeam = createSwrCache( + async () => { + const resources = await service.sharedResources(SCOPE); + return { ids: resources.map((r) => r.id), resources }; + }, + () => 'ws-1', + 3000, + ); + const req = await startServer({ basePath: 'design-systems', share: service, listTeam }); + + await req.get('/api/workspace/design-systems/team'); + const unshareResp = await req.del('/api/workspace/design-systems/nonexistent/share'); + expect(unshareResp.body).toEqual({ unshared: false }); + + await req.get('/api/workspace/design-systems/team'); + expect(sharedResourcesCalls()).toBe(1); + }); + + it('a cache seam that throws on invalidate still reports the share as successful', async () => { + const { service } = mutableShare([record('a')]); + const listTeam = Object.assign( + async () => ({ ids: ['a'], resources: [record('a')] }), + { + invalidate() { + throw new Error('cache seam exploded'); + }, + }, + ); + const req = await startServer({ basePath: 'design-systems', share: service, listTeam }); + + const shareResp = await req.post('/api/workspace/design-systems/b/share'); + expect(shareResp.status).toBe(200); + expect(shareResp.body).toMatchObject({ shared: true }); + }); + + // Pins the two-layer composition `cachedTeamResourceList` uses in server.ts: + // `share.sharedResources()` itself reads through a SECOND, shared SWR cache + // (`sharedTeamResourcesCommand` — the single `vela resource shared --json` + // read all three kinds funnel through). A bare reset of only the outer + // per-kind cache is not enough: the immediate post-share refetch would call + // `sharedResources()` fresh, which would then read the STILL-STALE raw hub + // listing back out of that inner cache for up to its own freshMs. This + // mirrors (does not import — the composition is private to server.ts) the + // exact `cachedTeamResourceList` wiring so a regression there is caught here + // instead of only in a live daemon. + it('the outer per-kind cache cascades invalidate into the shared inner hub-read cache', async () => { + let hubResources = [{ id: 'a' }]; + let hubReadCalls = 0; + const sharedTeamResourcesCommand = createSwrCache( + async () => { + hubReadCalls += 1; + return JSON.stringify({ resources: hubResources }); + }, + () => 'ws-1', + 3000, + ); + + const service: TeamResourceShareService = { + async sharedResources() { + const raw = await sharedTeamResourcesCommand(); + return (JSON.parse(raw).resources as Array<{ id: string }>).map((r) => record(r.id)); + }, + async share(id: string) { + hubResources = [...hubResources, { id }]; + return { version: 1 }; + }, + async unshare() { + return false; + }, + } as unknown as TeamResourceShareService; + + function cachedTeamResourceListLikeServerTs() { + const listing = createSwrCache( + async () => { + const resources = await service.sharedResources(SCOPE); + return { ids: resources.map((r) => r.id), resources }; + }, + () => 'ws-1', + 3000, + ); + const dropListingEntry = listing.invalidate; + return Object.assign(listing, { + invalidate() { + dropListingEntry(); + sharedTeamResourcesCommand.invalidate(); + }, + }); + } + + const listTeam = cachedTeamResourceListLikeServerTs(); + const req = await startServer({ basePath: 'design-systems', share: service, listTeam }); + + const before = await req.get('/api/workspace/design-systems/team'); + expect(before.body.ids).toEqual(['a']); + expect(hubReadCalls).toBe(1); + + const shareResp = await req.post('/api/workspace/design-systems/b/share'); + expect(shareResp.status).toBe(200); + + const after = await req.get('/api/workspace/design-systems/team'); + // If only the outer cache had been dropped, `sharedResources()` would + // still read the pre-share raw listing out of `sharedTeamResourcesCommand` + // (hubReadCalls would stay at 1) and 'b' would be missing here. + expect(after.body.ids).toEqual(['a', 'b']); + expect(hubReadCalls).toBe(2); + }); +}); + +describe('background Team resource reconciliation invalidates the cached /team listing', () => { + it('drops the selected outer plugin listing before the next post-retraction read', async () => { + let records = [record('plugin-retracted')]; + let sharedResourcesCalls = 0; + const service = { + async sharedResources() { + sharedResourcesCalls += 1; + return records; + }, + async share() { + return null; + }, + async unshare() { + return false; + }, + } as unknown as TeamResourceShareService; + const outer = createSwrCache( + async () => { + const resources = await service.sharedResources(SCOPE); + return { ids: resources.map((resource) => resource.id), resources }; + }, + () => 'ws-1', + 3000, + ); + const listTeam = Object.assign( + async (_scope: TeamResourceRequestScope) => outer(), + { invalidate: (_scope: TeamResourceRequestScope) => outer.invalidate() }, + ); + const untouched = { invalidate: (_scope: TeamResourceRequestScope) => {} }; + const req = await startServer({ basePath: 'plugins', share: service, listTeam }); + + const before = await req.get('/api/workspace/plugins/team'); + expect(before.body.ids).toEqual(['plugin-retracted']); + expect(sharedResourcesCalls).toBe(1); + + records = []; + invalidateTeamResourceListingCaches({ + resourceKind: 'plugin', + scope: SCOPE, + providers: { + design_system: untouched, + plugin: listTeam, + skill: untouched, + }, + invalidateSharedCommand: () => {}, + }); + + const after = await req.get('/api/workspace/plugins/team'); + expect(after.body.ids).toEqual([]); + expect(sharedResourcesCalls).toBe(2); + }); + + it('drops every outer kind for an unscoped reconnect or poll pass', () => { + const invalidations: string[] = []; + const provider = (kind: string) => ({ + invalidate(scope: TeamResourceRequestScope) { + invalidations.push(`${kind}:${scope.principal.teamId}`); + }, + }); + + invalidateTeamResourceListingCaches({ + scope: SCOPE, + providers: { + design_system: provider('design_system'), + plugin: provider('plugin'), + skill: provider('skill'), + }, + invalidateSharedCommand: (workspaceId) => + invalidations.push(`shared:${workspaceId}`), + }); + + expect(invalidations).toEqual([ + 'design_system:ws-1', + 'plugin:ws-1', + 'skill:ws-1', + 'shared:ws-1', + ]); + }); +}); diff --git a/apps/daemon/tests/team-resource-share-resync.test.ts b/apps/daemon/tests/team-resource-share-resync.test.ts new file mode 100644 index 00000000000..e29eba1b212 --- /dev/null +++ b/apps/daemon/tests/team-resource-share-resync.test.ts @@ -0,0 +1,196 @@ +// Priority-1 verification for the workspace-team continuous-sync gap: "分享 +// 到团队" was a one-time snapshot because the ONLY UI entry point that calls +// `share()` (team-resource-share.ts) hid itself once a resource was already +// shared. Before touching the frontend, this pins the backend half of that +// fix: `share()` itself has NO "already shared → refuse" guard anywhere in +// the real production path (permission gate → `createVelaCliResourceAdapter` +// → the `POST /api/workspace/:kind/:id/share` route), so calling it again on +// an already-shared resource is a legitimate "push the current directory as +// an update" — exactly what the UI's new "Sync to team" action relies on. +// +// This drives the REAL `createTeamResourceShareService` (not a hand-rolled +// mock of `TeamResourceShareService` — see team-resource-share-list-cache.test +// for that lighter-weight seam) through the REAL `registerTeamResourceShareRoutes` +// HTTP handlers, over a real listening server. Only the outermost `vela +// resource` CLI process invocation is faked — the same injectable seam +// (`CreateTeamResourceShareOptions.run`) production code and every other test +// in this suite already uses instead of a live Vela login/hub. + +import { afterEach, describe, expect, it } from 'vitest'; +import express from 'express'; +import http from 'node:http'; +import { registerTeamResourceShareRoutes } from '../src/routes/team-resource-share.js'; +import { + createTeamResourceShareService, + type TeamResourceRequestScope, +} from '../src/collab/team-resource-share.js'; +import type { ResourceHubPrincipal } from '../src/collab/resource-principal.js'; + +let server: http.Server | null = null; + +afterEach(async () => { + if (server) { + const toClose = server; + server = null; + await new Promise((resolve) => toClose.close(() => resolve())); + } +}); + +async function listen(app: express.Express): Promise { + server = http.createServer(app); + await new Promise((resolve) => server!.listen(0, resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('server did not bind to a TCP port'); + return `http://127.0.0.1:${address.port}`; +} + +const OWNER: ResourceHubPrincipal = { + memberId: 'mem-owner', + teamId: 'team-resync-1', + role: 'member', + lifecycleState: 'active', + workspaceType: 'team', +}; +const SCOPE: TeamResourceRequestScope = { principal: OWNER, canShare: true }; + +/** + * In-memory stand-in for the vela CLI's hub-side resource store — the exact + * seam `createVelaCliResourceAdapter` shells out through (`run(args, + * workspaceId)`), so `push`/`shared` here model the REAL args the adapter + * sends, not a paraphrase of them. `metadata-json` round-trips just like the + * real hub does, so `parseSharedResourceRecords` resolves the local id + * correctly off `metadata.localId` — the same mechanism the real + * `describeResource` callbacks in server.ts feed it. + */ +function fakeHub() { + type Entry = { version: number; dir: string; metadata: Record }; + const entries = new Map(); + const pushCalls: Array<{ resourceId: string; dir: string }> = []; + + const run = async (args: string[]): Promise => { + if (args[0] === 'push') { + // ['push', kind, resourceId, dir, '--ref', 'published', '--json', ...excludes, '--metadata-json', json?] + const resourceId = args[2]!; + const dir = args[3]!; + const metaFlagIndex = args.indexOf('--metadata-json'); + const metadata = metaFlagIndex >= 0 ? (JSON.parse(args[metaFlagIndex + 1]!) as Record) : {}; + const nextVersion = (entries.get(resourceId)?.version ?? 0) + 1; + entries.set(resourceId, { version: nextVersion, dir, metadata }); + pushCalls.push({ resourceId, dir }); + return JSON.stringify({ version: nextVersion, id: `ver-${nextVersion}` }); + } + if (args[0] === 'shared') { + return JSON.stringify({ + resources: [...entries.entries()].map(([id, entry]) => ({ + id, + kind: 'design_system', + deletedAt: null, + ownerMemberId: OWNER.memberId, + metadata: entry.metadata, + publishedVersion: { id: `ver-${entry.version}`, version: entry.version }, + })), + }); + } + throw new Error(`unexpected vela args: ${args.join(' ')}`); + }; + + return { run, entries, pushCalls }; +} + +describe('team resource re-share (the "Sync to team" backend path)', () => { + it('a second share() call on an already-shared resource overwrites the hub version instead of being refused', async () => { + const hub = fakeHub(); + let currentDir = '/tmp/ds-1/v1-original-logo'; + + const share = createTeamResourceShareService({ + kind: 'design_system', + idPrefix: 'ds', + resolveDir: () => currentDir, + describeResource: () => ({ localId: 'user:ds-1', title: 'Ds 1' }), + run: hub.run, + env: { OD_WORKSPACE_CONTEXT_SOURCE: 'vela' }, + }); + + const app = express(); + app.use(express.json()); + registerTeamResourceShareRoutes(app, { + basePath: 'design-systems', + share, + resolveScope: async () => ({ ok: true, scope: SCOPE }), + }); + const base = await listen(app); + + // First share. + const firstResp = await fetch( + `${base}/api/workspace/design-systems/${encodeURIComponent('user:ds-1')}/share`, + { method: 'POST' }, + ); + expect(firstResp.status).toBe(200); + await expect(firstResp.json()).resolves.toMatchObject({ shared: true, version: 1 }); + + // Owner edits locally (new logo/content) — resolveDir now resolves to the + // edited directory, same as a real daemon re-reading the on-disk system. + currentDir = '/tmp/ds-1/v2-new-logo'; + + // Re-share: the UI's "Sync to team" action hits the exact same route with + // no special flag — this must NOT be refused just because it is already + // shared. + const secondResp = await fetch( + `${base}/api/workspace/design-systems/${encodeURIComponent('user:ds-1')}/share`, + { method: 'POST' }, + ); + expect(secondResp.status).toBe(200); + await expect(secondResp.json()).resolves.toMatchObject({ shared: true, version: 2 }); + + // The hub's real state reflects the SECOND push — proves this is a genuine + // overwrite, not a refused/no-op call swallowed into a false "success". + expect(hub.pushCalls).toHaveLength(2); + expect(hub.pushCalls[1]).toMatchObject({ dir: '/tmp/ds-1/v2-new-logo' }); + + // A teammate's own read of the team listing — the actual "does the team + // see the update" acceptance check — sees the LATEST version, not the + // first snapshot. + const teamListingResp = await fetch(`${base}/api/workspace/design-systems/team`); + const teamListing = (await teamListingResp.json()) as { + resources: Array<{ id: string; version?: number }>; + }; + const entry = teamListing.resources.find((r) => r.id === 'user:ds-1'); + expect(entry?.version).toBe(2); + }); + + it('does not require any special "update" flag — the permission gate re-evaluates cleanly on every repeat call', async () => { + const hub = fakeHub(); + let shareChecks = 0; + const share = createTeamResourceShareService({ + kind: 'skill', + idPrefix: 'skill', + resolveDir: () => '/tmp/skill-1', + describeResource: () => ({ localId: 'my-skill' }), + run: hub.run, + env: { OD_WORKSPACE_CONTEXT_SOURCE: 'vela' }, + }); + const resolveScope = async () => { + shareChecks += 1; + return { ok: true as const, scope: SCOPE }; + }; + + const app = express(); + app.use(express.json()); + registerTeamResourceShareRoutes(app, { basePath: 'skills', share, resolveScope }); + const base = await listen(app); + + for (let i = 0; i < 3; i += 1) { + const resp = await fetch( + `${base}/api/workspace/skills/${encodeURIComponent('my-skill')}/share`, + { method: 'POST' }, + ); + expect(resp.status).toBe(200); + await expect(resp.json()).resolves.toMatchObject({ shared: true, version: i + 1 }); + } + + // The permission gate is re-evaluated fresh each time, not cached/bypassed + // after the first successful share. + expect(shareChecks).toBe(3); + expect(hub.pushCalls).toHaveLength(3); + }); +}); diff --git a/apps/daemon/tests/team-resource-share.test.ts b/apps/daemon/tests/team-resource-share.test.ts new file mode 100644 index 00000000000..75ce33de9e9 --- /dev/null +++ b/apps/daemon/tests/team-resource-share.test.ts @@ -0,0 +1,378 @@ +import { describe, expect, it } from 'vitest'; +import { + TeamResourceShareForbiddenError, + createTeamResourceShareService, + parseSharedResourceIds, + parseSharedResourceRecords, + teamResourceRequestScopeFromContext, + teamResourceRequestScopeForWorkspaceId, + type TeamResourceRequestScope, +} from '../src/collab/team-resource-share.js'; +import type { + WorkspaceCollabContext, + WorkspaceDirectoryItem, +} from '@open-design/contracts'; +import type { ResourceHubPrincipal } from '../src/collab/resource-principal.js'; + +const unreachableRun = async (): Promise => { + throw new Error('Vela should not run when the permission gate stops sharing'); +}; +const principal: ResourceHubPrincipal = { + memberId: 'wm-1', + teamId: 't-1', + role: 'member', + lifecycleState: 'active', +}; +const scope: TeamResourceRequestScope = { principal, canShare: true }; +const readOnlyScope: TeamResourceRequestScope = { principal, canShare: false }; + +describe('team resource share permission gate', () => { + it('does not manufacture a Team resource scope for an authoritative Personal workspace', () => { + const personalContext = { + workspaceId: 'personal-1', + workspaceType: 'personal', + workspaceMemberId: 'wm-personal', + memberStatus: 'active', + permissions: { + canManageSharedResources: false, + canShareProjects: false, + }, + } as WorkspaceCollabContext; + + expect(teamResourceRequestScopeFromContext(personalContext)).toBeNull(); + }); + + it('resolves a background operation from the exact event Workspace membership', () => { + const directory: WorkspaceDirectoryItem[] = [ + { + workspaceId: 'team-a', + workspaceName: 'A', + workspaceType: 'team', + workspaceMemberId: 'wm-a', + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + }, + { + workspaceId: 'team-b', + workspaceName: 'B', + workspaceType: 'team', + workspaceMemberId: 'wm-b', + role: 'member', + memberStatus: 'removed', + lifecycleState: 'active', + }, + ]; + + expect(teamResourceRequestScopeForWorkspaceId(directory, 'team-a')).toMatchObject({ + principal: { teamId: 'team-a', memberId: 'wm-a' }, + }); + expect(teamResourceRequestScopeForWorkspaceId(directory, 'team-b')).toBeNull(); + }); + + it('uses the request-scoped principal even when the daemon ambient workspace has changed', async () => { + const requestPrincipal: ResourceHubPrincipal = { + memberId: 'wm-a', + teamId: 'team-a', + role: 'owner', + lifecycleState: 'active', + workspaceType: 'team', + }; + const calls: Array<{ args: string[]; workspaceId: string | undefined }> = []; + const service = createTeamResourceShareService({ + kind: 'skill', + idPrefix: 'skill', + resolveDir: () => '/tmp/skill', + run: async (args, workspaceId) => { + calls.push({ args, workspaceId }); + return JSON.stringify({ version: 1 }); + }, + env: { OD_WORKSPACE_CONTEXT_SOURCE: 'vela' }, + }); + + await service.share('skill-a', { principal: requestPrincipal, canShare: true }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.workspaceId).toBe('team-a'); + expect(calls[0]?.args[2]).toBe('skill-team-a-skill-a'); + }); + + it('refuses a team member who cannot manage shared resources (403 marker)', async () => { + const service = createTeamResourceShareService({ + kind: 'design_system', + idPrefix: 'ds', + resolveDir: () => '/tmp/ds', + run: unreachableRun, + env: { OD_WORKSPACE_CONTEXT_SOURCE: 'vela' }, + }); + await expect(service.share('ds-1', readOnlyScope)).rejects.toBeInstanceOf( + TeamResourceShareForbiddenError, + ); + expect(service.isShared('ds-1', readOnlyScope)).toBe(false); + }); + + it('keeps a non-Vela dev workspace on the unconfigured no-op path', async () => { + const service = createTeamResourceShareService({ + kind: 'design_system', + idPrefix: 'ds', + resolveDir: () => '/tmp/ds', + run: unreachableRun, + env: {}, + }); + + expect(service.configured).toBe(false); + expect(await service.share('ds-1', scope)).toBeNull(); + expect(await service.unshare('ds-1', scope)).toBe(false); + expect(await service.sharedIds(scope)).toEqual([]); + }); + + it('removes a team resource through the Vela CLI', async () => { + const calls: string[][] = []; + const run = async (args: string[]): Promise => { + calls.push(args); + if (args[0] === 'push') return JSON.stringify({ version: 1 }); + if (args[0] === 'remove') return JSON.stringify({ ok: true }); + throw new Error(`unexpected args: ${args.join(' ')}`); + }; + const service = createTeamResourceShareService({ + kind: 'skill', + idPrefix: 'skill', + resolveDir: () => '/tmp/skill', + run, + env: { OD_WORKSPACE_CONTEXT_SOURCE: 'vela' }, + }); + + expect(await service.share('mock-team-expert-kit', scope)).toEqual({ version: 1 }); + expect(service.isShared('mock-team-expert-kit', scope)).toBe(true); + await expect(service.unshare('mock-team-expert-kit', scope)).resolves.toBe(true); + expect(service.isShared('mock-team-expert-kit', scope)).toBe(false); + expect(calls.at(-1)).toEqual([ + 'remove', + 'skill-t-1-mock-team-expert-kit', + '--json', + ]); + }); + + it('keeps fallback shared state isolated when two Workspace requests interleave', async () => { + const workspaceA: TeamResourceRequestScope = { + principal: { ...principal, memberId: 'wm-a', teamId: 'team-a' }, + canShare: true, + }; + const workspaceB: TeamResourceRequestScope = { + principal: { ...principal, memberId: 'wm-b', teamId: 'team-b' }, + canShare: true, + }; + const service = createTeamResourceShareService({ + kind: 'skill', + idPrefix: 'skill', + resolveDir: () => '/tmp/skill', + run: async (args) => { + if (args[0] === 'push') return JSON.stringify({ version: 1 }); + throw new Error('hub listing temporarily unavailable'); + }, + env: { OD_WORKSPACE_CONTEXT_SOURCE: 'vela' }, + }); + + await service.share('skill-a', workspaceA); + await service.share('skill-b', workspaceB); + + expect(service.isShared('skill-a', workspaceA)).toBe(true); + expect(service.isShared('skill-b', workspaceB)).toBe(true); + await expect(service.sharedIds(workspaceA)).resolves.toEqual(['skill-a']); + await expect(service.sharedIds(workspaceB)).resolves.toEqual(['skill-b']); + }); + + it('lists resources already shared through another daemon via Vela CLI', async () => { + const run = async (args: string[]): Promise => { + expect(args).toEqual(['shared', '--json']); + return JSON.stringify({ + resources: [ + { + id: 'skill-mock-team-expert-kit', + kind: 'skill', + deletedAt: null, + ownerMemberId: 'wm-1', + metadata: { title: 'Mock kit', description: 'Shared kit' }, + }, + { id: 'skill-deleted-kit', kind: 'skill', deletedAt: '2026-07-13T00:00:00Z' }, + { id: 'project-p1', kind: 'project', deletedAt: null }, + ], + }); + }; + const service = createTeamResourceShareService({ + kind: 'skill', + idPrefix: 'skill', + resolveDir: () => '/tmp/skill', + run, + env: { OD_WORKSPACE_CONTEXT_SOURCE: 'vela' }, + }); + + expect(await service.sharedIds(readOnlyScope)).toEqual(['mock-team-expert-kit']); + await expect(service.sharedResources(readOnlyScope)).resolves.toEqual([ + { + id: 'mock-team-expert-kit', + title: 'Mock kit', + description: 'Shared kit', + ownerMemberId: 'wm-1', + canUnshare: true, + }, + ]); + expect(service.isShared('mock-team-expert-kit', readOnlyScope)).toBe(true); + }); + + it('preserves the workspace-scoped hub id for teammate materialization', async () => { + const service = createTeamResourceShareService({ + kind: 'skill', + idPrefix: 'skill', + resolveDir: () => '/tmp/skill', + run: async () => JSON.stringify({ + resources: [ + { + id: 'skill-t-1-shared-kit', + kind: 'skill', + deletedAt: null, + ownerMemberId: 'wm-owner', + metadata: { localId: 'shared-kit' }, + }, + ], + }), + env: { OD_WORKSPACE_CONTEXT_SOURCE: 'vela' }, + }); + + const [resource] = await service.sharedResources(readOnlyScope); + expect(resource).toEqual({ + id: 'shared-kit', + ownerMemberId: 'wm-owner', + canUnshare: false, + }); + expect(resource?.hubResourceId).toBe('skill-t-1-shared-kit'); + expect(Object.keys(resource ?? {})).not.toContain('hubResourceId'); + }); + + it('reconciles stale local shared ids when Vela reports the resource removed', async () => { + let remoteHasSkill = true; + const run = async (args: string[]): Promise => { + if (args[0] === 'push') return JSON.stringify({ version: 1 }); + expect(args).toEqual(['shared', '--json']); + return JSON.stringify({ + resources: remoteHasSkill + ? [ + { + id: 'skill-mock-team-expert-kit', + kind: 'skill', + deletedAt: null, + ownerMemberId: 'wm-1', + }, + ] + : [], + }); + }; + const service = createTeamResourceShareService({ + kind: 'skill', + idPrefix: 'skill', + resolveDir: () => '/tmp/skill', + run, + env: { OD_WORKSPACE_CONTEXT_SOURCE: 'vela' }, + }); + + expect(await service.share('mock-team-expert-kit', scope)).toEqual({ version: 1 }); + expect(await service.sharedIds(scope)).toEqual(['mock-team-expert-kit']); + expect(service.isShared('mock-team-expert-kit', scope)).toBe(true); + + remoteHasSkill = false; + + expect(await service.sharedIds(scope)).toEqual([]); + expect(service.isShared('mock-team-expert-kit', scope)).toBe(false); + }); + + it('marks resources unshareable for non-owner non-uploader members', async () => { + const run = async (): Promise => JSON.stringify({ + resources: [ + { + id: 'plugin-shared-kit', + kind: 'plugin', + deletedAt: null, + ownerMemberId: 'wm-owner', + }, + ], + }); + const service = createTeamResourceShareService({ + kind: 'plugin', + idPrefix: 'plugin', + resolveDir: () => '/tmp/plugin', + run, + env: { OD_WORKSPACE_CONTEXT_SOURCE: 'vela' }, + }); + + await expect(service.sharedResources(scope)).resolves.toEqual([ + { id: 'shared-kit', ownerMemberId: 'wm-owner', canUnshare: false }, + ]); + await expect(service.unshare('shared-kit', scope)).rejects.toBeInstanceOf( + TeamResourceShareForbiddenError, + ); + }); + + it('parses shared resource ids by kind and prefix', () => { + expect( + parseSharedResourceIds( + JSON.stringify({ + resources: [ + { id: 'plugin-alpha', kind: 'plugin' }, + { id: 'skill-alpha', kind: 'skill' }, + { id: 'skill-beta', kind: 'skill', deletedAt: null }, + { id: 'skill-gamma', kind: 'skill', deletedAt: '2026-07-13T00:00:00Z' }, + ], + }), + 'skill', + 'skill', + ), + ).toEqual(['alpha', 'beta']); + }); + + it('parses shared resource metadata for team cards', () => { + expect( + parseSharedResourceRecords( + JSON.stringify({ + resources: [ + { + id: 'skill-alpha', + kind: 'skill', + ownerMemberId: 'wm-1', + metadata: { title: 'Alpha skill', description: 'Useful in teams' }, + }, + ], + }), + 'skill', + 'skill', + ), + ).toEqual([{ + id: 'alpha', + title: 'Alpha skill', + description: 'Useful in teams', + ownerMemberId: 'wm-1', + }]); + }); + + it('decodes legacy design-system resource ids back to user ids', () => { + expect( + parseSharedResourceRecords( + JSON.stringify({ + resources: [ + { + id: 'ds-user-design-system-inspired-by-agentic', + kind: 'design_system', + ownerMemberId: 'wm-1', + metadata: { title: 'Agentic' }, + }, + ], + }), + 'design_system', + 'ds', + ), + ).toEqual([{ + id: 'user:design-system-inspired-by-agentic', + title: 'Agentic', + ownerMemberId: 'wm-1', + }]); + }); +}); diff --git a/apps/daemon/tests/team-resource-version-store.test.ts b/apps/daemon/tests/team-resource-version-store.test.ts new file mode 100644 index 00000000000..2883b15908e --- /dev/null +++ b/apps/daemon/tests/team-resource-version-store.test.ts @@ -0,0 +1,180 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createTeamResourceVersionStore } from '../src/collab/team-resource-version-store.js'; + +const roots: string[] = []; + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all( + roots.splice(0).map((root) => + fs.promises.rm(root, { recursive: true, force: true }), + ), + ); +}); + +describe('team resource version store', () => { + it('persists independent workspace and resource cursors', async () => { + const root = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'od-team-resource-versions-'), + ); + roots.push(root); + const store = createTeamResourceVersionStore(root); + + await store.set('team-a', 'skill', 'review-kit', 'version-1'); + await store.set('team-b', 'skill', 'review-kit', 'version-2'); + + const reloaded = createTeamResourceVersionStore(root); + expect(reloaded.get('team-a', 'skill', 'review-kit')).toBe('version-1'); + expect(reloaded.get('team-b', 'skill', 'review-kit')).toBe('version-2'); + expect(reloaded.get('team-a', 'plugin', 'review-kit')).toBeNull(); + }); + + it('publishes a cursor in memory only after the atomic rename commits', async () => { + const root = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'od-team-resource-versions-'), + ); + roots.push(root); + const store = createTeamResourceVersionStore(root); + vi.spyOn(fs.promises, 'rename').mockRejectedValueOnce( + new Error('disk unavailable'), + ); + + await expect( + store.set('team-a', 'project-content', 'project-a', '7'), + ).rejects.toThrow('disk unavailable'); + expect(store.get('team-a', 'project-content', 'project-a')).toBeNull(); + + // The rejected write must not poison the queue or leak into later writes. + // Concurrent keys share the next single-writer batch, which builds from + // the last successfully committed in-memory state. + await Promise.all([ + store.set('team-a', 'project-content', 'project-b', '8'), + store.set('team-a', 'project-content', 'project-c', '9'), + ]); + + expect(store.get('team-a', 'project-content', 'project-a')).toBeNull(); + expect(store.get('team-a', 'project-content', 'project-b')).toBe('8'); + expect(store.get('team-a', 'project-content', 'project-c')).toBe('9'); + const reloaded = createTeamResourceVersionStore(root); + expect(reloaded.get('team-a', 'project-content', 'project-a')).toBeNull(); + expect(reloaded.get('team-a', 'project-content', 'project-b')).toBe('8'); + expect(reloaded.get('team-a', 'project-content', 'project-c')).toBe('9'); + }); + + it('commits a burst of independent pull cursors in one durable snapshot', async () => { + const root = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'od-team-resource-versions-'), + ); + roots.push(root); + const store = createTeamResourceVersionStore(root); + const writeFile = vi.spyOn(fs.promises, 'writeFile'); + + await Promise.all( + Array.from({ length: 20 }, (_, index) => + store.set( + 'team-a', + 'project-content', + `project-${index}`, + String(index + 1), + ), + ), + ); + + expect(writeFile).toHaveBeenCalledTimes(1); + const reloaded = createTeamResourceVersionStore(root); + for (let index = 0; index < 20; index += 1) { + expect( + reloaded.get('team-a', 'project-content', `project-${index}`), + ).toBe(String(index + 1)); + } + }); + + it('preserves cursors that arrive while an earlier batch is committing', async () => { + const root = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'od-team-resource-versions-'), + ); + roots.push(root); + const store = createTeamResourceVersionStore(root); + const realRename = fs.promises.rename.bind(fs.promises); + let releaseFirstRename!: () => void; + let firstRenameStarted!: () => void; + const firstRenameGate = new Promise((resolve) => { + releaseFirstRename = resolve; + }); + const firstRenameStart = new Promise((resolve) => { + firstRenameStarted = resolve; + }); + const rename = vi.spyOn(fs.promises, 'rename').mockImplementationOnce( + async (oldPath, newPath) => { + firstRenameStarted(); + await firstRenameGate; + await realRename(oldPath, newPath); + }, + ); + + const first = store.set( + 'team-a', + 'project-content', + 'project-a', + '1', + ); + await firstRenameStart; + const later = Promise.all([ + store.set('team-a', 'project-content', 'project-b', '2'), + store.set('team-a', 'project-content', 'project-c', '3'), + ]); + releaseFirstRename(); + await Promise.all([first, later]); + + expect(rename).toHaveBeenCalledTimes(2); + const reloaded = createTeamResourceVersionStore(root); + expect(reloaded.get('team-a', 'project-content', 'project-a')).toBe('1'); + expect(reloaded.get('team-a', 'project-content', 'project-b')).toBe('2'); + expect(reloaded.get('team-a', 'project-content', 'project-c')).toBe('3'); + }); + + it('commits a later batch after the in-flight batch fails', async () => { + const root = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'od-team-resource-versions-'), + ); + roots.push(root); + const store = createTeamResourceVersionStore(root); + let releaseFirstRename!: () => void; + let firstRenameStarted!: () => void; + const firstRenameGate = new Promise((resolve) => { + releaseFirstRename = resolve; + }); + const firstRenameStart = new Promise((resolve) => { + firstRenameStarted = resolve; + }); + vi.spyOn(fs.promises, 'rename').mockImplementationOnce(async () => { + firstRenameStarted(); + await firstRenameGate; + throw new Error('first batch unavailable'); + }); + + const failed = store.set( + 'team-a', + 'project-content', + 'project-a', + '1', + ); + await firstRenameStart; + const later = store.set( + 'team-a', + 'project-content', + 'project-b', + '2', + ); + releaseFirstRename(); + + await expect(failed).rejects.toThrow('first batch unavailable'); + await expect(later).resolves.toBeUndefined(); + const reloaded = createTeamResourceVersionStore(root); + expect(reloaded.get('team-a', 'project-content', 'project-a')).toBeNull(); + expect(reloaded.get('team-a', 'project-content', 'project-b')).toBe('2'); + }); +}); diff --git a/apps/daemon/tests/terminal-workspace-authority.test.ts b/apps/daemon/tests/terminal-workspace-authority.test.ts new file mode 100644 index 00000000000..d143d67b329 --- /dev/null +++ b/apps/daemon/tests/terminal-workspace-authority.test.ts @@ -0,0 +1,102 @@ +import express from 'express'; +import type http from 'node:http'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { registerTerminalRoutes } from '../src/routes/terminal.js'; + +describe('terminal project authority', () => { + let server: http.Server; + let baseUrl = ''; + const session = { id: 'terminal-a', projectId: 'project-a' }; + const terminals = { + list: vi.fn(() => [session]), + statusBody: vi.fn((value) => value), + create: vi.fn(async () => session), + get: vi.fn(() => session), + stream: vi.fn(), + write: vi.fn(() => true), + resize: vi.fn(() => true), + kill: vi.fn(), + }; + const resolveProjectDir = vi.fn(() => '/tmp/project-a'); + const authorizeProjectRequest = vi.fn(async (_req, res) => { + res.status(503).json({ + error: { + code: 'WORKSPACE_AUTHORITY_UNAVAILABLE', + message: 'unavailable', + retryable: true, + }, + }); + return false; + }); + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + registerTerminalRoutes(app, { + db: {}, + http: { + sendApiError: (res: any, status: number, code: string, message: string) => + res.status(status).json({ error: { code, message } }), + createSseResponse: vi.fn(), + }, + paths: { PROJECTS_DIR: '/tmp/projects' }, + projectStore: { + getProject: () => ({ id: 'project-a', metadata: null }), + }, + projectFiles: { resolveProjectDir }, + terminals, + authorizeProjectRequest, + } as any); + await new Promise((resolve) => { + server = app.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('missing address'); + baseUrl = `http://127.0.0.1:${address.port}`; + resolve(); + }); + }); + }); + + afterAll(() => new Promise((resolve) => server.close(() => resolve()))); + + it('denies list/create/stream/input/resize/kill before any terminal side effect', async () => { + const requests = [ + fetch(`${baseUrl}/api/projects/project-a/terminals`), + fetch(`${baseUrl}/api/projects/project-a/terminals`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ cols: 80, rows: 24 }), + }), + fetch(`${baseUrl}/api/projects/project-a/terminals/terminal-a/stream`), + fetch(`${baseUrl}/api/projects/project-a/terminals/terminal-a/stdin`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ data: 'whoami\n' }), + }), + fetch(`${baseUrl}/api/projects/project-a/terminals/terminal-a/resize`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ cols: 100, rows: 40 }), + }), + fetch(`${baseUrl}/api/projects/project-a/terminals/terminal-a/kill`, { + method: 'POST', + }), + fetch(`${baseUrl}/api/projects/project-a/terminals/terminal-a`, { + method: 'DELETE', + }), + ]; + const responses = await Promise.all(requests); + + expect(responses.map((response) => response.status)).toEqual( + Array.from({ length: requests.length }, () => 503), + ); + expect(terminals.list).not.toHaveBeenCalled(); + expect(terminals.create).not.toHaveBeenCalled(); + expect(terminals.get).not.toHaveBeenCalled(); + expect(terminals.stream).not.toHaveBeenCalled(); + expect(terminals.write).not.toHaveBeenCalled(); + expect(terminals.resize).not.toHaveBeenCalled(); + expect(terminals.kill).not.toHaveBeenCalled(); + expect(resolveProjectDir).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/daemon/tests/tool-tokens.test.ts b/apps/daemon/tests/tool-tokens.test.ts index 5ddf0f8add2..cf2ccc0b80d 100644 --- a/apps/daemon/tests/tool-tokens.test.ts +++ b/apps/daemon/tests/tool-tokens.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { CHAT_TOOL_ENDPOINTS, CHAT_TOOL_OPERATIONS, ToolTokenRegistry } from '../src/tool-tokens.js'; +import { + CHAT_TOOL_ENDPOINTS, + CHAT_TOOL_OPERATIONS, + MEDIA_TASK_WAIT_TOOL_ENDPOINT, + ToolTokenRegistry, +} from '../src/tool-tokens.js'; afterEach(() => { vi.useRealTimers(); @@ -83,6 +88,8 @@ describe('run-scoped tool tokens', () => { const grant = registry.mint({ runId: 'run-defaults', projectId: 'project-a', nowMs: 1_000 }); expect(grant.allowedEndpoints).toEqual([...CHAT_TOOL_ENDPOINTS]); + expect(MEDIA_TASK_WAIT_TOOL_ENDPOINT).toBe('/api/media/tasks/:id/wait'); + expect(grant.allowedEndpoints).toContain(MEDIA_TASK_WAIT_TOOL_ENDPOINT); expect(grant.allowedOperations).toEqual([...CHAT_TOOL_OPERATIONS]); registry.clear(); }); diff --git a/apps/daemon/tests/vela-billing.test.ts b/apps/daemon/tests/vela-billing.test.ts new file mode 100644 index 00000000000..cdd958f0869 --- /dev/null +++ b/apps/daemon/tests/vela-billing.test.ts @@ -0,0 +1,340 @@ +import { describe, expect, it } from 'vitest'; +import { + VelaWorkspaceBillingSnapshotUnsupportedError, + fetchBillingCheckoutUrl, + fetchVelaBillingCatalog, + fetchVelaBillingSummary, + fetchVelaWorkspaceBillingProjection, + fetchVelaWorkspaceBalance, + parseBillingCatalog, + parseBillingSummary, + parseWorkspaceBillingSnapshot, + parseWorkspaceWalletBalance, +} from '../src/integrations/vela-billing.js'; + +// A representative `vela billing summary --format json` payload. +const SAMPLE = JSON.stringify({ + balanceUsd: '1.2500', + creditsPerUsd: 10000, + balances: { subscriptionCredits: '5000', rechargeCredits: '7500', totalAvailableCredits: '12500' }, + membershipTier: 'team', + billingInterval: 'monthly', + subscriptionStatus: 'active', + availableActions: ['subscription_checkout', 'billing_portal'], +}); + +const CATALOG_SAMPLE = JSON.stringify({ + workspaceId: 'ws_team', + billingInterval: 'monthly', + plans: [ + { + planId: 'team_plus', + seatUnitAmountCents: 3900, + currency: 'usd', + minSeats: 1, + status: 'active', + }, + ], +}); + +const WORKSPACE_BALANCE_SAMPLE = JSON.stringify({ + balanceUsd: '7.8900', + expiresAt: null, + updatedAt: '2026-07-26T12:00:00Z', + billingScopeVersion: 2, + workspaceId: 'ws_team', + workspaceMemberId: 'member_team', +}); + +const WORKSPACE_SNAPSHOT_SAMPLE = JSON.stringify({ + schemaVersion: 1, + workspaceId: 'ws_team', + workspaceMemberId: 'member_team', + billingScopeVersion: 2, + billing: { + billingState: 'active', + planId: 'team_plus', + }, + wallet: { + balanceUsd: '7.8900', + expiresAt: null, + updatedAt: '2026-07-26T12:00:00Z', + }, + revisions: { + billing: 'billing-rev-1', + wallet: 'wallet-rev-1', + }, + revisionClocks: { + billing: { epoch: 'billing-epoch-a', counter: '12' }, + wallet: { epoch: 'wallet-epoch-a', counter: '34' }, + }, +}); + +describe('vela billing 收口', () => { + // Acceptance #112: B splits the wallet into a subscription grant bucket and + // a top-up bucket (`balances.subscriptionCredits` / `balances.rechargeCredits`, + // summing to `totalAvailableCredits`). The mapper kept only the total, so the + // menu's 附加积分 row had no field to read and could only ever print 0. + it('maps the vela billing summary JSON, including BOTH credit buckets', () => { + expect(parseBillingSummary(SAMPLE)).toEqual({ + workspaceId: null, + membershipTier: 'team', + totalAvailableCredits: 12500, + subscriptionCredits: 5000, + rechargeCredits: 7500, + balanceUsd: '1.2500', + subscriptionStatus: 'active', + availableActions: ['subscription_checkout', 'billing_portal'], + workspaceBalance: null, + }); + }); + + it('returns null on empty or malformed output (clean "no summary")', () => { + expect(parseBillingSummary('')).toBeNull(); + expect(parseBillingSummary('not json')).toBeNull(); + }); + + it('degrades to null when the CLI throws — no billing session', async () => { + const out = await fetchVelaBillingSummary({ + run: async () => { + throw new Error('no vela session'); + }, + }); + expect(out).toBeNull(); + }); + + it('drives the injected runner and maps its output', async () => { + const seen: string[][] = []; + const out = await fetchVelaBillingSummary({ + run: async (args) => { + seen.push(args); + return SAMPLE; + }, + }); + expect(out?.membershipTier).toBe('team'); + expect(out?.totalAvailableCredits).toBe(12500); + expect(out?.rechargeCredits).toBe(7500); + expect(out?.availableActions).toContain('billing_portal'); + expect(out?.workspaceId).toBeNull(); + expect(seen).toEqual([['summary', '--format', 'json']]); + }); + + it('fetches one explicit workspace balance and preserves backend scope identity', async () => { + const seen: string[][] = []; + const out = await fetchVelaWorkspaceBalance('ws_team', { + run: async (args) => { + seen.push(args); + return WORKSPACE_BALANCE_SAMPLE; + }, + }); + expect(out).toEqual({ + balanceUsd: '7.8900', + expiresAt: null, + updatedAt: '2026-07-26T12:00:00Z', + billingScopeVersion: 2, + workspaceId: 'ws_team', + workspaceMemberId: 'member_team', + }); + expect(seen).toEqual([ + ['workspace-balance', '--workspace-id', 'ws_team', '--format', 'json'], + ]); + }); + + it('rejects an unscoped or foreign workspace balance instead of self-stamping it', () => { + expect(parseWorkspaceWalletBalance(JSON.stringify({ balanceUsd: '7.89' }), 'ws_team')).toBeNull(); + expect( + parseWorkspaceWalletBalance( + JSON.stringify({ + balanceUsd: '7.89', + billingScopeVersion: 2, + workspaceId: 'ws_other', + workspaceMemberId: 'member_other', + }), + 'ws_team', + ), + ).toBeNull(); + }); + + it('maps one authoritative workspace billing snapshot without changing its scope', () => { + expect(parseWorkspaceBillingSnapshot(WORKSPACE_SNAPSHOT_SAMPLE, 'ws_team')).toEqual({ + schemaVersion: 1, + workspaceId: 'ws_team', + workspaceMemberId: 'member_team', + billingScopeVersion: 2, + billing: { + billingState: 'active', + planId: 'team_plus', + }, + wallet: { + balanceUsd: '7.8900', + expiresAt: null, + updatedAt: '2026-07-26T12:00:00Z', + }, + revisions: { + billing: 'billing-rev-1', + wallet: 'wallet-rev-1', + }, + revisionClocks: { + billing: { epoch: 'billing-epoch-a', counter: '12' }, + wallet: { epoch: 'wallet-epoch-a', counter: '34' }, + }, + }); + expect(parseWorkspaceBillingSnapshot(WORKSPACE_SNAPSHOT_SAMPLE, 'ws_other')).toBeNull(); + expect( + parseWorkspaceBillingSnapshot( + JSON.stringify({ + ...JSON.parse(WORKSPACE_SNAPSHOT_SAMPLE), + billing: { billingState: 'mystery', planId: 'team_plus' }, + }), + 'ws_team', + ), + ).toBeNull(); + }); + + it('drops malformed additive revision clocks without rejecting the legacy snapshot', () => { + const raw = JSON.parse(WORKSPACE_SNAPSHOT_SAMPLE); + raw.revisionClocks = { + billing: { epoch: 'billing-epoch-a', counter: '-1' }, + wallet: { epoch: '', counter: '34' }, + }; + + const parsed = parseWorkspaceBillingSnapshot(JSON.stringify(raw), 'ws_team'); + expect(parsed).not.toBeNull(); + expect(parsed).not.toHaveProperty('revisionClocks'); + expect(parsed?.revisions).toEqual({ + billing: 'billing-rev-1', + wallet: 'wallet-rev-1', + }); + }); + + it('uses the additive workspace snapshot command when the CLI supports it', async () => { + const seen: string[][] = []; + const out = await fetchVelaWorkspaceBillingProjection('ws_team', { + run: async (args) => { + seen.push(args); + return WORKSPACE_SNAPSHOT_SAMPLE; + }, + }); + + expect(seen).toEqual([ + ['workspace-snapshot', '--workspace-id', 'ws_team', '--format', 'json'], + ]); + expect(out.snapshot?.billing.planId).toBe('team_plus'); + expect(out.workspaceBalance).toMatchObject({ + workspaceId: 'ws_team', + workspaceMemberId: 'member_team', + balanceUsd: '7.8900', + }); + }); + + it('falls back to the legacy balance command only for typed unsupported CLIs', async () => { + const seen: string[][] = []; + const out = await fetchVelaWorkspaceBillingProjection('ws_team', { + run: async (args) => { + seen.push(args); + if (args[0] === 'workspace-snapshot') { + throw new VelaWorkspaceBillingSnapshotUnsupportedError(); + } + return WORKSPACE_BALANCE_SAMPLE; + }, + }); + + expect(seen).toEqual([ + ['workspace-snapshot', '--workspace-id', 'ws_team', '--format', 'json'], + ['workspace-balance', '--workspace-id', 'ws_team', '--format', 'json'], + ]); + expect(out.snapshot).toBeNull(); + expect(out.workspaceBalance?.balanceUsd).toBe('7.8900'); + }); + + it('falls back when the installed old CLI rejects the new snapshot flags', async () => { + const seen: string[][] = []; + const out = await fetchVelaWorkspaceBillingProjection('ws_team', { + run: async (args) => { + seen.push(args); + if (args[0] === 'workspace-snapshot') { + throw new Error('unknown flag: --workspace-id'); + } + return WORKSPACE_BALANCE_SAMPLE; + }, + }); + + expect(seen).toEqual([ + ['workspace-snapshot', '--workspace-id', 'ws_team', '--format', 'json'], + ['workspace-balance', '--workspace-id', 'ws_team', '--format', 'json'], + ]); + expect(out.snapshot).toBeNull(); + expect(out.workspaceBalance?.balanceUsd).toBe('7.8900'); + }); + + it('does not turn an auth/network snapshot failure into a successful empty balance', async () => { + await expect( + fetchVelaWorkspaceBillingProjection('ws_team', { + run: async () => { + throw new Error('control key expired'); + }, + }), + ).rejects.toThrow('control key expired'); + }); + + it('maps the vela team billing catalog JSON into client catalog data', () => { + expect(parseBillingCatalog(CATALOG_SAMPLE)).toEqual({ + workspaceId: 'ws_team', + billingInterval: 'monthly', + plans: [ + { + planId: 'team_plus', + seatUnitAmountCents: 3900, + currency: 'usd', + minSeats: 1, + status: 'active', + }, + ], + }); + expect(parseBillingCatalog('not json')).toBeNull(); + }); + + it('fetches team billing catalog through the vela CLI workspace route', async () => { + const seen: string[][] = []; + const out = await fetchVelaBillingCatalog('ws_team', { + run: async (args) => { + seen.push(args); + return CATALOG_SAMPLE; + }, + }); + expect(out?.plans[0]?.planId).toBe('team_plus'); + expect(seen).toEqual([ + ['team-catalog', '--workspace-id', 'ws_team', '--format', 'json'], + ]); + }); + + it('starts checkout with workspace and selected team plan through the vela CLI', async () => { + const seen: string[][] = []; + const url = await fetchBillingCheckoutUrl({ + workspaceId: 'ws_team', + planId: 'team_pro', + seats: 4, + run: async (args) => { + seen.push(args); + return JSON.stringify({ + checkoutSessionId: 'cs_team', + checkoutUrl: 'https://checkout.stripe.test/cs_team', + }); + }, + }); + expect(url).toBe('https://checkout.stripe.test/cs_team'); + expect(seen).toEqual([ + [ + 'checkout', + '--workspace-id', + 'ws_team', + '--plan-id', + 'team_pro', + '--seats', + '4', + '--format', + 'json', + ], + ]); + }); +}); diff --git a/apps/daemon/tests/vela-cli-resource-adapter.test.ts b/apps/daemon/tests/vela-cli-resource-adapter.test.ts new file mode 100644 index 00000000000..5fc3f5e4130 --- /dev/null +++ b/apps/daemon/tests/vela-cli-resource-adapter.test.ts @@ -0,0 +1,498 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + contextHasTeamIdentity, + createVelaCliResourceAdapter, + shouldUseVelaCliResourceTransport, +} from '../src/collab/vela-cli-resource-adapter.js'; +import { createCollabRuntime } from '../src/collab/runtime.js'; +import type { ResourceHubPrincipal } from '../src/collab/resource-principal.js'; + +function recordingRun(outputs: Record) { + const calls: string[][] = []; + const workspaces: Array = []; + const run = async (args: string[], workspaceId?: string): Promise => { + calls.push(args); + workspaces.push(workspaceId); + return outputs[args[0] ?? ''] ?? ''; + }; + return { run, calls, workspaces }; +} + +function scriptedRun(steps: Array<{ match: string[]; output?: string; error?: Error }>) { + const calls: string[][] = []; + const run = async (args: string[]): Promise => { + calls.push(args); + const step = steps.shift(); + if (!step) throw new Error(`unexpected call: ${args.join(' ')}`); + expect(args).toEqual(step.match); + if (step.error) throw step.error; + return step.output ?? ''; + }; + return { run, calls }; +} + +const OPTS = { + resolveProjectDir: (id: string) => `/projects/${id}`, + resolvePullDir: (id: string) => `/copies/${id}`, + resourceIdFor: (id: string) => `project-${id}`, + kind: 'design_system', + hasTeamIdentity: () => true, +}; + +describe('createVelaCliResourceAdapter', () => { + it('publishes by spawning `push … --ref published --json` and parses the version', async () => { + const { run, calls } = recordingRun({ push: JSON.stringify({ version: 7, id: 'v7' }) }); + const adapter = createVelaCliResourceAdapter({ ...OPTS, run }); + const result = await adapter.publish({ projectId: 'p1', reason: 'edit' }); + expect(result).toEqual({ version: 7, versionId: 'v7' }); + expect(calls[0]).toEqual([ + 'push', + 'design_system', + 'project-p1', + '/projects/p1', + '--ref', + 'published', + '--json', + '--exclude', + '.file-versions', + '--exclude', + '.live-artifacts', + '--exclude', + '.od-skills', + '--exclude', + '.git', + '--exclude', + 'node_modules', + '--exclude', + '.npmrc', + '--exclude', + '.yarnrc', + '--exclude', + '.yarnrc.yml', + '--exclude', + '.aws', + '--exclude', + '.ssh', + '--exclude', + '.azure', + '--exclude', + '.docker', + '--exclude', + '.gnupg', + '--exclude', + '.kube', + '--exclude', + '.pulumi', + '--exclude', + '.terraform', + '--exclude', + '.git-credentials', + '--exclude', + '.netrc', + '--exclude', + '.pypirc', + '--exclude', + 'terraform.tfstate', + '--exclude', + 'terraform.tfstate.backup', + '--exclude-prefix', + '.env', + ]); + }); + + it('passes project metadata to the resource index when available', async () => { + const { run, calls } = recordingRun({ push: JSON.stringify({ version: 7, id: 'v7' }) }); + const adapter = createVelaCliResourceAdapter({ + ...OPTS, + describeProject: () => ({ name: 'Launch Deck', metadata: { kind: 'deck' } }), + run, + }); + await adapter.publish({ projectId: 'p1', reason: 'edit' }); + expect(calls[0]).toEqual([ + 'push', + 'design_system', + 'project-p1', + '/projects/p1', + '--ref', + 'published', + '--json', + '--exclude', + '.file-versions', + '--exclude', + '.live-artifacts', + '--exclude', + '.od-skills', + '--exclude', + '.git', + '--exclude', + 'node_modules', + '--exclude', + '.npmrc', + '--exclude', + '.yarnrc', + '--exclude', + '.yarnrc.yml', + '--exclude', + '.aws', + '--exclude', + '.ssh', + '--exclude', + '.azure', + '--exclude', + '.docker', + '--exclude', + '.gnupg', + '--exclude', + '.kube', + '--exclude', + '.pulumi', + '--exclude', + '.terraform', + '--exclude', + '.git-credentials', + '--exclude', + '.netrc', + '--exclude', + '.pypirc', + '--exclude', + 'terraform.tfstate', + '--exclude', + 'terraform.tfstate.backup', + '--exclude-prefix', + '.env', + '--metadata-json', + JSON.stringify({ name: 'Launch Deck', metadata: { kind: 'deck' } }), + ]); + }); + + it('stores the project id in project resource metadata for legacy catalog fallback', async () => { + const { run, calls } = recordingRun({ push: JSON.stringify({ version: 7 }) }); + const adapter = createVelaCliResourceAdapter({ + ...OPTS, + kind: 'project', + describeProject: () => ({ name: 'Launch Deck' }), + run, + }); + + await adapter.publish({ projectId: 'p1', reason: 'share' }); + + expect(calls[0]?.slice(-2)).toEqual([ + '--metadata-json', + JSON.stringify({ projectId: 'p1', name: 'Launch Deck' }), + ]); + }); + + it('reports the head version via `head` without pulling', async () => { + const { run, calls } = recordingRun({ head: JSON.stringify({ version: 3 }) }); + const adapter = createVelaCliResourceAdapter({ ...OPTS, run }); + expect(await adapter.syncLatest!({ projectId: 'p1' })).toEqual({ version: 3 }); + expect(calls[0]).toEqual(['head', 'project-p1', '--ref', 'published', '--json']); + }); + + it('passes the selected team workspace to every scoped Vela invocation', async () => { + const principal = { + teamId: 'team-selected', + memberId: 'member-1', + role: 'member', + lifecycleState: 'active', + workspaceType: 'team', + } as const; + const { run, workspaces } = recordingRun({ + push: JSON.stringify({ version: 7 }), + head: JSON.stringify({ version: 7 }), + pull: JSON.stringify({ version: 7, versionId: 'v7' }), + remove: '{}', + }); + const adapter = createVelaCliResourceAdapter({ ...OPTS, run }); + + await adapter.publish({ projectId: 'p1', principal, reason: 'edit' }); + await adapter.syncLatest!({ projectId: 'p1', principal }); + await adapter.pull!({ projectId: 'p1', principal }); + await adapter.unpublish!({ projectId: 'p1', principal }); + + expect(workspaces).toEqual([ + 'team-selected', + 'team-selected', + 'team-selected', + 'team-selected', + ]); + }); + + it('treats a null head version (nothing published) as no result', async () => { + const { run } = recordingRun({ head: JSON.stringify({ resourceId: 'project-p1', ref: 'published', version: null }) }); + const adapter = createVelaCliResourceAdapter({ ...OPTS, run }); + expect(await adapter.syncLatest!({ projectId: 'p1' })).toBeNull(); + }); + + it('falls back to the legacy unscoped resource id when a scoped head is empty', async () => { + const principal = { teamId: 't1', memberId: 'm1', role: 'member', lifecycleState: 'active' } as const; + const { run, calls } = scriptedRun([ + { + match: ['head', 'project-t1-m1-p1', '--ref', 'published', '--json'], + output: JSON.stringify({ resourceId: 'project-t1-m1-p1', ref: 'published', version: null }), + }, + { + match: ['head', 'project-p1', '--ref', 'published', '--json'], + output: JSON.stringify({ version: 9 }), + }, + ]); + const adapter = createVelaCliResourceAdapter({ + ...OPTS, + resourceIdFor: (id, inputPrincipal) => + inputPrincipal ? `project-${inputPrincipal.teamId}-${inputPrincipal.memberId}-${id}` : `project-${id}`, + run, + }); + expect(await adapter.syncLatest!({ projectId: 'p1', principal })).toEqual({ version: 9 }); + expect(calls).toHaveLength(2); + }); + + it('returns the exact version materialized by `pull --json`', async () => { + const { run, calls } = recordingRun({ + pull: JSON.stringify({ + version: 1, + versionId: 'v1', + manifestDigest: 'd1', + }), + }); + const adapter = createVelaCliResourceAdapter({ ...OPTS, run }); + const result = await adapter.pull!({ projectId: 'p1' }); + expect(result).toEqual({ version: 1, versionId: 'v1' }); + expect(calls[0]).toEqual(['pull', 'design_system', 'project-p1', '/copies/p1', '--ref', 'published', '--json']); + }); + + it('falls back to the legacy unscoped resource id when a scoped pull is missing', async () => { + const principal = { teamId: 't1', memberId: 'm1', role: 'member', lifecycleState: 'active' } as const; + const { run, calls } = scriptedRun([ + { + match: ['pull', 'design_system', 'project-t1-m1-p1', '/copies/p1', '--ref', 'published', '--json'], + error: new Error('resource_not_found'), + }, + { + match: ['pull', 'design_system', 'project-p1', '/copies/p1', '--ref', 'published', '--json'], + output: JSON.stringify({ version: 9, versionId: 'v9' }), + }, + ]); + const adapter = createVelaCliResourceAdapter({ + ...OPTS, + resourceIdFor: (id, inputPrincipal) => + inputPrincipal ? `project-${inputPrincipal.teamId}-${inputPrincipal.memberId}-${id}` : `project-${id}`, + run, + }); + await adapter.pull!({ projectId: 'p1', principal }); + expect(calls).toHaveLength(2); + }); + + it('fails closed when a successful pull response omits the materialized version', async () => { + const { run } = recordingRun({ + pull: JSON.stringify({ + resourceId: 'project-p1', + ref: 'published', + dir: '/copies/p1', + }), + }); + const adapter = createVelaCliResourceAdapter({ ...OPTS, run }); + + await expect(adapter.pull!({ projectId: 'p1' })).rejects.toThrow( + 'missing the materialized version', + ); + }); + + it('does not hide authentication failures behind a legacy pull fallback', async () => { + const principal = { teamId: 't1', memberId: 'm1', role: 'member', lifecycleState: 'active' } as const; + const { run, calls } = scriptedRun([ + { + match: ['pull', 'design_system', 'project-t1-m1-p1', '/copies/p1', '--ref', 'published', '--json'], + error: new Error('API request failed with status 403: missing_principal'), + }, + ]); + const adapter = createVelaCliResourceAdapter({ + ...OPTS, + resourceIdFor: (id, inputPrincipal) => + inputPrincipal ? `project-${inputPrincipal.teamId}-${inputPrincipal.memberId}-${id}` : `project-${id}`, + run, + }); + + await expect(adapter.pull!({ projectId: 'p1', principal })).rejects.toThrow( + 'missing_principal', + ); + expect(calls).toHaveLength(1); + }); + + it('removes a project from the team resource index', async () => { + const { run, calls } = recordingRun({ remove: JSON.stringify({ ok: true }) }); + const adapter = createVelaCliResourceAdapter({ ...OPTS, run }); + await adapter.unpublish!({ projectId: 'p1' }); + expect(calls[0]).toEqual(['remove', 'project-p1', '--json']); + }); + + it('no-ops (never spawns) when there is no team identity', async () => { + const { run, calls } = recordingRun({ push: JSON.stringify({ version: 1 }) }); + const adapter = createVelaCliResourceAdapter({ ...OPTS, hasTeamIdentity: () => false, run }); + expect(await adapter.publish({ projectId: 'p1', reason: 'edit' })).toBeNull(); + expect(await adapter.syncLatest!({ projectId: 'p1' })).toBeNull(); + await adapter.pull!({ projectId: 'p1' }); + await adapter.unpublish!({ projectId: 'p1' }); + expect(calls.length).toBe(0); + }); + + it('stops spawning `vela resource push` on the very next attempt once the live context reports the member removed', async () => { + // Reproduces the collab-publish-watcher gap: an already-attached file + // watcher never re-checks `shouldPublish`, so the ONLY thing standing + // between a removed owner's local edits and `vela resource push` is this + // adapter re-deriving `hasTeamIdentity` fresh on every publish attempt. + const { run, calls } = recordingRun({ push: JSON.stringify({ version: 1 }) }); + let memberStatus: 'active' | 'removed' = 'active'; + const adapter = createVelaCliResourceAdapter({ + ...OPTS, + hasTeamIdentity: () => + contextHasTeamIdentity({ + workspaceType: 'team', + workspaceId: 't1', + workspaceMemberId: 'm1', + memberStatus, + } as never), + run, + }); + + // While still an active member, an edit publishes normally. + expect(await adapter.publish({ projectId: 'p1', reason: 'edit' })).toEqual({ version: 1 }); + expect(calls).toHaveLength(1); + + // The team removes this member out-of-band (B-side); the daemon keeps + // running with the file watcher still attached. + memberStatus = 'removed'; + + // The next debounced publish for the SAME already-watched project must not + // reach the vela CLI at all. + expect(await adapter.publish({ projectId: 'p1', reason: 'edit' })).toBeNull(); + expect(calls).toHaveLength(1); + + // Read/unpublish operations on the same live session are refused too — + // a removed member's daemon should not keep talking to the team hub at + // all through this session. + expect(await adapter.syncLatest!({ projectId: 'p1' })).toBeNull(); + await adapter.pull!({ projectId: 'p1' }); + await adapter.unpublish!({ projectId: 'p1' }); + expect(calls).toHaveLength(1); + }); +}); + +describe('transport selection', () => { + it('opts into the CLI transport for explicit or Vela-backed team modes', () => { + expect(shouldUseVelaCliResourceTransport({ OD_RESOURCE_TRANSPORT: 'vela-cli' })).toBe(true); + expect(shouldUseVelaCliResourceTransport({ OD_RESOURCE_TRANSPORT: 'sdk' })).toBe(false); + expect(shouldUseVelaCliResourceTransport({ OD_WORKSPACE_CONTEXT_SOURCE: 'vela' })).toBe(true); + expect(shouldUseVelaCliResourceTransport({ + OD_WORKSPACE_CONTEXT_SOURCE: 'vela', + OD_RESOURCE_TRANSPORT: 'sdk', + })).toBe(true); + expect(shouldUseVelaCliResourceTransport({ OD_TEAM_PROJECTS_TRANSPORT: 'vela-cli' })).toBe(true); + expect(shouldUseVelaCliResourceTransport({ OD_COLLAB_TRANSPORT: 'vela-cli' })).toBe(true); + expect(shouldUseVelaCliResourceTransport({})).toBe(false); + }); + + it('gates team identity on a live team workspace context', () => { + expect( + contextHasTeamIdentity({ + workspaceType: 'team', + workspaceId: 't1', + workspaceMemberId: 'm1', + memberStatus: 'active', + } as never), + ).toBe(true); + expect(contextHasTeamIdentity({ + workspaceType: 'personal', + workspaceId: 'personal-1', + workspaceMemberId: 'm1', + memberStatus: 'active', + } as never)).toBe(false); + expect(contextHasTeamIdentity(null)).toBe(false); + }); + + it('refuses a member the team has removed, even though their identity fields still resolve', () => { + // A removed member's workspaceType/workspaceId/workspaceMemberId keep + // resolving — only memberStatus flips — so the identity fields alone are + // not enough to prove this session may still address the resource hub. + expect( + contextHasTeamIdentity({ + workspaceType: 'team', + workspaceId: 't1', + workspaceMemberId: 'm1', + memberStatus: 'removed', + } as never), + ).toBe(false); + }); +}); + +// Red spec for the "unshare retry-trap / fresh-install ghost" family: an +// unshare is two hub writes (resource remove → team-projects catalog remove). +// When the first landed but the second did not (crash or network between +// them), the hub is left dangling: the team_project_catalog row still lists +// the project while its backing resource row is tombstoned. Reproduced live +// on the feature-test hub (2026-07-27, workspace res-wipe-0727): every +// subsequent unshare attempt died re-removing the already-tombstoned resource +// (`vela resource remove` → 404 `resource_not_found`, surfaced as HTTP 400 +// and a local-row rollback), so the catalog row could NEVER be removed — and +// after a reinstall (fresh data root, local `cloudTombstonedAt` gone) the +// retracted project came back as a normal-looking team card for everyone. +// +// The invariant under test: `unpublish` is a retraction toward one end state +// — "the hub no longer serves this resource". A hub answer that the resource +// is already absent IS that end state, so retraction must treat it as +// success and let the caller finish the rest of the unshare (the catalog +// removal), instead of failing the whole operation forever. +describe('unpublish retraction idempotency (dangling team-catalog heal)', () => { + const retractedError = () => + new Error( + 'Command failed: vela resource remove project-p1 --json\n' + + 'Error: remove resource: API request failed with status 404: resource_not_found\n', + ); + + it('treats an already-retracted hub resource as unpublish success, not failure', async () => { + const { run, calls } = scriptedRun([ + { match: ['remove', 'project-p1', '--json'], error: retractedError() }, + ]); + const adapter = createVelaCliResourceAdapter({ ...OPTS, run }); + await expect(adapter.unpublish!({ projectId: 'p1' })).resolves.toBeUndefined(); + expect(calls).toHaveLength(1); + }); + + it('still surfaces unpublish failures that do not prove the resource is gone', async () => { + const { run } = scriptedRun([ + { + match: ['remove', 'project-p1', '--json'], + error: new Error('Command failed: vela resource remove project-p1 --json\nError: network unreachable\n'), + }, + ]); + const adapter = createVelaCliResourceAdapter({ ...OPTS, run }); + await expect(adapter.unpublish!({ projectId: 'p1' })).rejects.toThrow('network unreachable'); + }); + + it('an unshare retry against an already-retracted resource completes the catalog removal', async () => { + // The exact retry a stuck sharer fires from the UI: the resource row is + // already tombstoned (first attempt half-landed), the catalog row is the + // one thing left to remove. Before the fix the retry rejected at the + // resource step and never reached the catalog. + const principal: ResourceHubPrincipal = { + teamId: 't1', + memberId: 'owner-1', + role: 'owner', + lifecycleState: 'active', + }; + const { run } = scriptedRun([ + { match: ['remove', 'project-p1', '--json'], error: retractedError() }, + ]); + const adapter = createVelaCliResourceAdapter({ ...OPTS, run }); + const catalogRemove = vi.fn(async () => ({})); + const runtime = createCollabRuntime({ + adapter, + teamProjectCatalog: { upsert: async () => ({}), remove: catalogRemove }, + }); + try { + await expect(runtime.requestTeamUnshare('p1', principal)).resolves.toBeUndefined(); + expect(catalogRemove).toHaveBeenCalledWith('p1', principal); + } finally { + runtime.dispose(); + } + }); +}); diff --git a/apps/daemon/tests/vela-cli-team-projects.test.ts b/apps/daemon/tests/vela-cli-team-projects.test.ts new file mode 100644 index 00000000000..5ece9d82382 --- /dev/null +++ b/apps/daemon/tests/vela-cli-team-projects.test.ts @@ -0,0 +1,527 @@ +import { describe, expect, it } from 'vitest'; +import { + createScopedVelaTeamProjectCatalogClientCache, + createVelaCliTeamProjectCatalog, + createVelaCliTeamProjectCatalogClient, + shouldUseVelaCliTeamProjectCatalog, +} from '../src/collab/vela-cli-team-projects.js'; + +describe('Vela CLI team-project catalog adapter', () => { + it('gets one project through the exact workspace-scoped command', async () => { + const calls: Array<{ + args: string[]; + workspaceId: string | undefined; + }> = []; + const catalog = createVelaCliTeamProjectCatalog({ + run: async (args, workspaceId) => { + calls.push({ args, workspaceId }); + return JSON.stringify({ + projectId: 'p1', + ownerMemberId: 'wm-owner', + displayName: 'Electric Studio 2', + syncState: 'synced', + createdAt: '2026-07-01T00:00:00.000Z', + updatedAt: '2026-07-02T00:00:00.000Z', + }); + }, + }); + + await expect(catalog.get('p1', 'team-captured')).resolves.toMatchObject({ + projectId: 'p1', + ownerMemberId: 'wm-owner', + name: 'Electric Studio 2', + }); + expect(calls).toEqual([ + { + args: ['get', 'p1', '--json'], + workspaceId: 'team-captured', + }, + ]); + }); + + it('returns null for an authoritative not-found without listing', async () => { + const calls: string[][] = []; + const catalog = createVelaCliTeamProjectCatalog({ + run: async (args) => { + calls.push(args); + throw new Error( + 'get team project: API request failed with status 404: team_project_not_found', + ); + }, + }); + + await expect(catalog.get('missing', 'team-1')).resolves.toBeNull(); + expect(calls).toEqual([['get', 'missing', '--json']]); + }); + + it('caches exact-command capability fallback but not authorization failures', async () => { + const capabilityCalls: string[][] = []; + const capabilityCatalog = createVelaCliTeamProjectCatalog({ + supportsTeamProjects: () => true, + run: async (args) => { + capabilityCalls.push(args); + if (args[0] === 'get') { + throw new Error('unknown command "get" for "team-projects"'); + } + return JSON.stringify({ + projects: [ + { + projectId: 'p1', + ownerMemberId: 'wm-owner', + syncState: 'synced', + createdAt: '2026-07-01T00:00:00.000Z', + updatedAt: '2026-07-01T00:00:00.000Z', + }, + ], + }); + }, + }); + + await expect(capabilityCatalog.get('p1', 'team-1')).resolves.toMatchObject({ + projectId: 'p1', + }); + await expect(capabilityCatalog.get('p1', 'team-1')).resolves.toMatchObject({ + projectId: 'p1', + }); + expect(capabilityCalls).toEqual([ + ['get', 'p1', '--json'], + ['list'], + ['list'], + ]); + + for (const message of [ + 'get team project: API request failed with status 401: unauthenticated', + 'get team project: API request failed with status 403: workspace_forbidden', + 'get team project: API request failed with status 500', + 'connect ECONNRESET', + ]) { + let calls = 0; + const catalog = createVelaCliTeamProjectCatalog({ + run: async () => { + calls += 1; + throw new Error(message); + }, + }); + await expect(catalog.get('p1', 'team-1')).rejects.toThrow(message); + await expect(catalog.get('p1', 'team-1')).rejects.toThrow(message); + expect(calls).toBe(2); + } + }); + + it('treats only a code-less API 404 as an old endpoint capability miss', async () => { + const calls: string[][] = []; + const catalog = createVelaCliTeamProjectCatalog({ + supportsTeamProjects: () => true, + run: async (args) => { + calls.push(args); + if (args[0] === 'get') { + throw new Error('get team project: API request failed with status 404'); + } + return JSON.stringify({ + projects: [ + { + projectId: 'p1', + ownerMemberId: 'wm-owner', + syncState: 'synced', + createdAt: '2026-07-01T00:00:00.000Z', + updatedAt: '2026-07-01T00:00:00.000Z', + }, + ], + }); + }, + }); + + await expect(catalog.get('p1', 'team-1')).resolves.toMatchObject({ + projectId: 'p1', + }); + expect(calls).toEqual([['get', 'p1', '--json'], ['list']]); + }); + + it('uses an explicitly captured workspace for an authoritative list', async () => { + const catalog = createVelaCliTeamProjectCatalog({ + supportsTeamProjects: () => true, + run: async (args, workspaceId) => { + expect(args).toEqual(['list']); + expect(workspaceId).toBe('team-captured'); + return JSON.stringify({ projects: [] }); + }, + }); + + await expect(catalog.list('team-captured')).resolves.toEqual([]); + }); + + it('keeps the rich membership list on its explicit principal when active workspace switches during capability detection', async () => { + let activeWorkspaceId = 'team-a'; + const calls: Array<{ args: string[]; workspaceId: string | undefined }> = []; + const client = createVelaCliTeamProjectCatalogClient({ + supportsTeamProjects: async () => { + activeWorkspaceId = 'team-b'; + return true; + }, + run: async (args, workspaceId) => { + calls.push({ args, workspaceId }); + return JSON.stringify({ projects: [] }); + }, + }); + + await expect(client.list({ + memberId: 'member-a', + teamId: 'team-a', + role: 'member', + lifecycleState: 'active', + })).resolves.toEqual([]); + + expect(activeWorkspaceId).toBe('team-b'); + expect(calls).toEqual([{ args: ['list'], workspaceId: 'team-a' }]); + }); + + it('partitions cached client reads by the complete captured principal', async () => { + const calls: string[] = []; + const cached = createScopedVelaTeamProjectCatalogClientCache({ + list: async (principal) => { + calls.push(principal.teamId); + return []; + }, + upsert: async () => null, + }); + const principalA = { + memberId: 'member-a', + teamId: 'team-a', + role: 'member' as const, + lifecycleState: 'active' as const, + }; + const principalB = { + memberId: 'member-b', + teamId: 'team-b', + role: 'member' as const, + lifecycleState: 'active' as const, + }; + + await cached.list(principalA); + await cached.list(principalB); + await cached.list(principalA); + + expect(calls).toEqual(['team-a', 'team-b']); + }); + + it('rejects a rich catalog with rows outside the explicit workspace as incomplete', async () => { + const client = createVelaCliTeamProjectCatalogClient({ + supportsTeamProjects: () => true, + run: async () => JSON.stringify({ + projects: [ + { + id: 'row-b', + workspaceId: 'team-b', + projectId: 'project-b', + resourceId: 'resource-b', + ownerMemberId: 'member-b', + syncState: 'synced', + createdAt: '2026-07-01T00:00:00.000Z', + updatedAt: '2026-07-01T00:00:00.000Z', + }, + ], + }), + }); + + await expect(client.list({ + memberId: 'member-a', + teamId: 'team-a', + role: 'member', + lifecycleState: 'active', + })).rejects.toThrow(/incomplete team project catalog/); + }); + + it('rejects a partially parseable rich catalog instead of treating dropped rows as confirmed absence', async () => { + const client = createVelaCliTeamProjectCatalogClient({ + supportsTeamProjects: () => true, + run: async () => JSON.stringify({ + projects: [ + { + id: 'row-valid', + workspaceId: 'team-a', + projectId: 'project-valid', + resourceId: 'resource-valid', + ownerMemberId: 'member-a', + syncState: 'synced', + createdAt: '2026-07-01T00:00:00.000Z', + updatedAt: '2026-07-01T00:00:00.000Z', + }, + { + id: 'row-malformed', + workspaceId: 'team-a', + projectId: 'project-malformed', + }, + ], + }), + }); + + await expect(client.list({ + memberId: 'member-a', + teamId: 'team-a', + role: 'member', + lifecycleState: 'active', + })).rejects.toThrow(/incomplete team project catalog/); + }); + + it('keeps catalog upsert and remove on their explicit principal across capability awaits', async () => { + let activeWorkspaceId = 'team-a'; + const calls: Array<{ args: string[]; workspaceId: string | undefined }> = []; + const catalog = createVelaCliTeamProjectCatalog({ + supportsTeamProjects: async () => { + activeWorkspaceId = 'team-b'; + return true; + }, + run: async (args, workspaceId) => { + calls.push({ args, workspaceId }); + return ''; + }, + }); + const principal = { + memberId: 'member-a', + teamId: 'team-a', + role: 'member' as const, + lifecycleState: 'active' as const, + }; + + await catalog.upsert({ projectId: 'project-a' }, principal); + activeWorkspaceId = 'team-a'; + await catalog.remove('project-a', principal); + + expect(calls).toEqual([ + { + args: ['upsert', 'project-a', '--resource-id', 'project-project-a'], + workspaceId: 'team-a', + }, + { + args: ['remove', 'project-a'], + workspaceId: 'team-a', + }, + ]); + }); + + it('maps list output into team-project DTOs', async () => { + const catalog = createVelaCliTeamProjectCatalog({ + supportsTeamProjects: () => true, + run: async (args, workspaceId) => { + expect(args).toEqual(['list']); + expect(workspaceId).toBe('team-selected'); + return JSON.stringify({ + projects: [ + { + projectId: 'p1', + ownerMemberId: 'wm-owner', + displayName: 'Electric Studio 2', + syncState: 'synced', + metadata: { + skillId: 'deck-builder', + designSystemId: 'ds-emerald', + createdAt: 1719820800000, + updatedAt: 1719907200000, + metadata: { kind: 'deck', entryFile: 'index.html' }, + }, + createdAt: '2026-07-01T00:00:00.000Z', + updatedAt: '2026-07-02T00:00:00.000Z', + }, + ], + }); + }, + }); + + await expect(catalog.list('team-selected')).resolves.toEqual([ + { + projectId: 'p1', + ownerMemberId: 'wm-owner', + sharedAt: '2026-07-01T00:00:00.000Z', + name: 'Electric Studio 2', + skillId: 'deck-builder', + designSystemId: 'ds-emerald', + createdAt: 1719820800000, + updatedAt: 1719907200000, + metadata: { kind: 'deck', entryFile: 'index.html' }, + }, + ]); + }); + + it('hides catalog rows whose project bytes are not synced yet', async () => { + const catalog = createVelaCliTeamProjectCatalog({ + supportsTeamProjects: () => true, + run: async () => JSON.stringify({ + projects: [ + { + projectId: 'pending', + ownerMemberId: 'wm-owner', + displayName: 'Pending Upload', + syncState: 'pending_upload', + createdAt: '2026-07-01T00:00:00.000Z', + }, + { + projectId: 'failed', + ownerMemberId: 'wm-owner', + displayName: 'Failed Upload', + syncState: 'failed', + createdAt: '2026-07-01T00:00:00.000Z', + }, + { + projectId: 'synced', + ownerMemberId: 'wm-owner', + displayName: 'Ready Project', + syncState: 'synced', + createdAt: '2026-07-01T00:00:00.000Z', + }, + ], + }), + }); + + await expect(catalog.list('team-selected')).resolves.toEqual([ + { + projectId: 'synced', + ownerMemberId: 'wm-owner', + sharedAt: '2026-07-01T00:00:00.000Z', + name: 'Ready Project', + createdAt: Date.parse('2026-07-01T00:00:00.000Z'), + }, + ]); + }); + + it('uses Vela team-project commands for upsert and remove', async () => { + const calls: string[][] = []; + const catalog = createVelaCliTeamProjectCatalog({ + supportsTeamProjects: () => true, + run: async (args) => { + calls.push(args); + return '{}'; + }, + }); + const principal = { + memberId: 'member-owner', + teamId: 'team-1', + role: 'owner' as const, + lifecycleState: 'active' as const, + }; + + await catalog.upsert({ + projectId: 'p1', + displayName: 'Electric Studio 2', + syncState: 'pending_upload', + lastSyncedVersionId: 'v2', + metadata: { + skillId: 'deck-builder', + designSystemId: 'ds-emerald', + metadata: { kind: 'deck' }, + }, + }, principal); + await catalog.remove('p1', principal); + + expect(calls).toEqual([ + [ + 'upsert', + 'p1', + '--resource-id', + 'project-p1', + '--display-name', + 'Electric Studio 2', + '--sync-state', + 'pending_upload', + '--last-synced-version-id', + 'v2', + '--metadata-json', + JSON.stringify({ + skillId: 'deck-builder', + designSystemId: 'ds-emerald', + metadata: { kind: 'deck' }, + }), + ], + ['remove', 'p1'], + ]); + }); + + it('falls back to vela resource shared when the CLI lacks team-projects', async () => { + const scopedId = `project-${Buffer.from( + JSON.stringify(['team-1', 'member-owner', 'p-fallback']), + 'utf8', + ).toString('base64url')}`; + const teamCalls: string[][] = []; + const resourceCalls: string[][] = []; + const sharedOutput = JSON.stringify({ + resources: [ + { + id: scopedId, + teamId: 'team-1', + kind: 'project', + ownerMemberId: 'member-owner', + metadata: { + name: 'Fallback Project', + skillId: 'deck-builder', + createdAt: 1719820800000, + updatedAt: 1719907200000, + metadata: { kind: 'deck' }, + }, + createdAt: '2026-07-01T00:00:00.000Z', + deletedAt: null, + }, + ], + }); + const options = { + run: async (args: string[]) => { + teamCalls.push(args); + throw new Error('unknown command "team-projects" for "vela"'); + }, + runResource: async (args: string[]) => { + resourceCalls.push(args); + return sharedOutput; + }, + }; + + const catalog = createVelaCliTeamProjectCatalog(options); + await expect(catalog.list('team-1')).resolves.toEqual([ + { + projectId: 'p-fallback', + ownerMemberId: 'member-owner', + sharedAt: '2026-07-01T00:00:00.000Z', + name: 'Fallback Project', + skillId: 'deck-builder', + createdAt: 1719820800000, + updatedAt: 1719907200000, + metadata: { kind: 'deck' }, + }, + ]); + const principal = { + memberId: 'member-owner', + teamId: 'team-1', + role: 'member' as const, + lifecycleState: 'active' as const, + }; + await catalog.upsert({ projectId: 'p-fallback' }, principal); + await catalog.remove('p-fallback', principal); + expect(teamCalls).toEqual([['--help']]); + expect(resourceCalls).toEqual([['shared', '--json']]); + + const client = createVelaCliTeamProjectCatalogClient(options); + await expect(client.list(principal)).resolves.toEqual([ + expect.objectContaining({ + workspaceId: 'team-1', + projectId: 'p-fallback', + resourceId: scopedId, + ownerMemberId: 'member-owner', + displayName: 'Fallback Project', + syncState: 'synced', + }), + ]); + await expect(client.upsert({ + projectId: 'p-fallback', + resourceId: scopedId, + }, principal)).resolves.toBeNull(); + expect(teamCalls).toEqual([['--help'], ['--help']]); + expect(resourceCalls).toEqual([ + ['shared', '--json'], + ['shared', '--json'], + ]); + }); + + it('keeps Vela workspace context authoritative over legacy transport flags', () => { + expect(shouldUseVelaCliTeamProjectCatalog({ + OD_WORKSPACE_CONTEXT_SOURCE: 'vela', + OD_TEAM_PROJECTS_TRANSPORT: 'resource-hub', + })).toBe(true); + }); +}); diff --git a/apps/daemon/tests/vela-console-origin.test.ts b/apps/daemon/tests/vela-console-origin.test.ts new file mode 100644 index 00000000000..ad6d4944430 --- /dev/null +++ b/apps/daemon/tests/vela-console-origin.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { resolveVelaConsoleOrigin } from '../src/integrations/vela.js'; + +// The vela web console origin is the one piece of an internal AMR deployment +// the web runtime needs and cannot infer: wallet / plans / upgrade links point +// at it. Internal environments are not public, so the origin is injected into +// packaged builds at build time (OD_VELA_WEB_URL) and reported to the client +// through GET /api/integrations/vela/status instead of living in web source. +describe('resolveVelaConsoleOrigin', () => { + it('reports the configured origin with any trailing slash removed', () => { + expect( + resolveVelaConsoleOrigin({ OD_VELA_WEB_URL: 'https://vela.example.invalid' }), + ).toBe('https://vela.example.invalid'); + expect( + resolveVelaConsoleOrigin({ OD_VELA_WEB_URL: ' https://vela.example.invalid/ ' }), + ).toBe('https://vela.example.invalid'); + }); + + it('reports nothing when the runtime was never given an origin', () => { + expect(resolveVelaConsoleOrigin({})).toBeUndefined(); + expect(resolveVelaConsoleOrigin({ OD_VELA_WEB_URL: ' ' })).toBeUndefined(); + }); +}); diff --git a/apps/daemon/tests/vela-team-projects.test.ts b/apps/daemon/tests/vela-team-projects.test.ts new file mode 100644 index 00000000000..567d91c5434 --- /dev/null +++ b/apps/daemon/tests/vela-team-projects.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import type { ResourceHubPrincipal } from '../src/collab/resource-principal.js'; +import { + projectResourceIdFor, + projectSyncStateToVela, + velaProjectSyncStateToProject, +} from '../src/integrations/vela-team-projects.js'; + +const principalA: ResourceHubPrincipal = { + memberId: 'member-a', + teamId: 'team-1', + role: 'admin', + lifecycleState: 'active', +}; + +const principalB: ResourceHubPrincipal = { + memberId: 'member-b', + teamId: 'team-1', + role: 'admin', + lifecycleState: 'active', +}; + +describe('team project catalog identity', () => { + it('derives collision-safe project resource ids from the workspace principal', () => { + expect(projectResourceIdFor('landing', principalA)).not.toBe( + projectResourceIdFor('landing', principalB), + ); + expect(projectResourceIdFor('landing', principalA)).toMatch( + /^project-[A-Za-z0-9_-]+$/, + ); + }); + + it('maps local and Vela sync states without an HTTP client', () => { + expect(projectSyncStateToVela('sync_failed')).toBe('failed'); + expect(velaProjectSyncStateToProject('synced')).toBe('synced'); + expect(velaProjectSyncStateToProject('syncing')).toBe('pending_upload'); + }); +}); diff --git a/apps/daemon/tests/vela-workspace-context.test.ts b/apps/daemon/tests/vela-workspace-context.test.ts new file mode 100644 index 00000000000..ebe0d68cb3e --- /dev/null +++ b/apps/daemon/tests/vela-workspace-context.test.ts @@ -0,0 +1,804 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createCachedWorkspaceDirectoryFetcher, + createFreshWorkspaceDirectoryFetcher, + createWorkspaceDirectoryAuthorityBroker, + createVelaWorkspaceContextProvider, + fetchVelaWorkspaceDirectory, + mapVelaWorkspaceContext, + workspaceContextFromDirectoryItem, +} from '../src/collab/vela-workspace-context.js'; + +// A well-formed body as B's GET /api/v1/workspaces/current returns it — a team +// member on a BYOK provider (workspace features stay on regardless of provider). +const B_TEAM_CONTEXT = { + userId: 'auth-user-1', + appUserId: 'app-user-1', + workspaceId: 'ws-team-1', + workspaceType: 'team', + workspaceMemberId: 'wm-1', + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + billingState: 'active', + planId: 'team-pro', + providerMode: 'personal_byok', + seatSummary: { seatLimit: 5, usedSeats: 2, availableSeats: 3, isSeatFull: false }, + permissions: { + canManageMembers: false, + canManageBilling: false, + canInviteMembers: false, + canManageAutoRecharge: false, + canShareProjects: true, + canWriteSyncedFiles: true, + canViewWorkspaceSettings: true, + canManageSharedResources: false, + }, + lastActiveWorkspaceId: 'ws-team-1', +}; + +const SESSION = { profile: 'prod', apiUrl: 'https://vela.example', controlKey: 'ck-1', user: null, configMtimeMs: null }; + +function jsonResponse(status: number, body: unknown): Response { + return { ok: status >= 200 && status < 300, status, json: async () => body } as unknown as Response; +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('mapVelaWorkspaceContext', () => { + it('maps a team context, deriving teamId from workspaceId and preserving BYOK', () => { + const mapped = mapVelaWorkspaceContext(B_TEAM_CONTEXT); + expect(mapped).not.toBeNull(); + // The team workspace IS the team scope → teamId mirrors workspaceId. + expect(mapped?.teamId).toBe('ws-team-1'); + // BYOK provider must not disable team features — provider is carried verbatim. + expect(mapped?.providerMode).toBe('personal_byok'); + // B's permissions are trusted (passed through), not re-derived. + expect(mapped?.permissions.canWriteSyncedFiles).toBe(true); + expect(mapped?.seatSummary).toEqual({ seatLimit: 5, usedSeats: 2, availableSeats: 3, isSeatFull: false }); + // B-only identity fields are dropped from the collab context. + expect(mapped).not.toHaveProperty('userId'); + expect(mapped).not.toHaveProperty('appUserId'); + }); + + it('does not attach teamId for a personal workspace', () => { + const mapped = mapVelaWorkspaceContext({ ...B_TEAM_CONTEXT, workspaceType: 'personal' }); + expect(mapped?.workspaceType).toBe('personal'); + expect(mapped?.teamId).toBeUndefined(); + }); + + // recvpkuLOujgAm follow-up: B names EVERY workspace, personal included + // (vela #964 derives "'s workspace" for an unnamed personal one, and + // an owner may rename it outright). Dropping that name for the personal case + // left the switcher's collapsed label with nothing but a hardcoded English + // fallback until the user opened the dropdown and the directory read landed. + it('carries the workspace name for a personal workspace', () => { + const mapped = mapVelaWorkspaceContext({ + ...B_TEAM_CONTEXT, + workspaceType: 'personal', + workspaceName: "Ada's workspace", + }); + expect(mapped?.workspaceName).toBe("Ada's workspace"); + // `teamName` stays team-only — it is the team switcher's field. + expect(mapped?.teamName).toBeUndefined(); + }); + + it('carries the workspace name for a team workspace alongside teamName', () => { + const mapped = mapVelaWorkspaceContext({ ...B_TEAM_CONTEXT, workspaceName: '1321' }); + expect(mapped?.workspaceName).toBe('1321'); + expect(mapped?.teamName).toBe('1321'); + }); + + it('carries a personal workspace name synthesized from a directory item', () => { + const context = workspaceContextFromDirectoryItem({ + workspaceId: 'ws-personal-1', + workspaceName: "Ada's workspace", + workspaceType: 'personal', + workspaceMemberId: 'wm-9', + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + }); + expect(context.workspaceName).toBe("Ada's workspace"); + expect(context.teamName).toBeUndefined(); + }); + + it('re-derives an inconsistent seat summary from the authoritative counts', () => { + const mapped = mapVelaWorkspaceContext({ + ...B_TEAM_CONTEXT, + seatSummary: { seatLimit: 5, usedSeats: 5, availableSeats: 99, isSeatFull: false }, + }); + expect(mapped?.seatSummary).toEqual({ seatLimit: 5, usedSeats: 5, availableSeats: 0, isSeatFull: true }); + }); + + it('accepts member contexts that hide billing-only fields', () => { + const mapped = mapVelaWorkspaceContext({ + ...B_TEAM_CONTEXT, + billingState: undefined, + planId: undefined, + seatSummary: undefined, + }); + expect(mapped).not.toBeNull(); + expect(mapped?.billingState).toBe('active'); + expect(mapped?.planId).toBeNull(); + expect(mapped?.seatSummary).toEqual({ seatLimit: 0, usedSeats: 0, availableSeats: 0, isSeatFull: true }); + expect(mapped?.permissions.canShareProjects).toBe(true); + }); + + it('returns null on a bad enum or a missing id', () => { + expect(mapVelaWorkspaceContext({ ...B_TEAM_CONTEXT, role: 'viewer' })).toBeNull(); + expect(mapVelaWorkspaceContext({ ...B_TEAM_CONTEXT, lifecycleState: 'frozen' })).toBeNull(); + expect(mapVelaWorkspaceContext({ ...B_TEAM_CONTEXT, workspaceMemberId: '' })).toBeNull(); + expect(mapVelaWorkspaceContext(null)).toBeNull(); + }); +}); + +describe('createCachedWorkspaceDirectoryFetcher', () => { + it('treats a missing local session as authoritative signed-out, not an outage', async () => { + await expect( + fetchVelaWorkspaceDirectory({ readSession: () => null }), + ).resolves.toEqual({ ok: true, items: [] }); + }); + + it('coalesces concurrent readers and briefly reuses one authoritative success', async () => { + let now = 1_000; + let resolveRead: + | ((result: { ok: true; items: [] }) => void) + | undefined; + const fetchDirectory = vi.fn( + () => + new Promise<{ ok: true; items: [] }>((resolve) => { + resolveRead = resolve; + }), + ); + const read = createCachedWorkspaceDirectoryFetcher({ + fetchDirectory, + ttlMs: 5_000, + now: () => now, + }); + + const first = read(); + const concurrent = read(); + expect(fetchDirectory).toHaveBeenCalledTimes(1); + resolveRead?.({ ok: true, items: [] }); + await expect(first).resolves.toEqual({ ok: true, items: [] }); + await expect(concurrent).resolves.toEqual({ ok: true, items: [] }); + + await expect(read()).resolves.toEqual({ ok: true, items: [] }); + expect(fetchDirectory).toHaveBeenCalledTimes(1); + + now += 5_000; + const refreshed = read(); + expect(fetchDirectory).toHaveBeenCalledTimes(2); + resolveRead?.({ ok: true, items: [] }); + await expect(refreshed).resolves.toEqual({ ok: true, items: [] }); + }); + + it('does not cache a failed directory read', async () => { + const fetchDirectory = vi + .fn() + .mockResolvedValueOnce({ ok: false, items: [] }) + .mockResolvedValueOnce({ ok: true, items: [] }); + const read = createCachedWorkspaceDirectoryFetcher({ fetchDirectory }); + + await expect(read()).resolves.toEqual({ ok: false, items: [] }); + await expect(read()).resolves.toEqual({ ok: true, items: [] }); + expect(fetchDirectory).toHaveBeenCalledTimes(2); + }); + + it('never serves account A cache or in-flight data after identity changes to B', async () => { + let identity = 'account-a'; + const reads: Array<{ + identity: string; + resolve: (result: { ok: true; items: [] }) => void; + }> = []; + const fetchDirectory = vi.fn( + () => + new Promise<{ ok: true; items: [] }>((resolve) => { + reads.push({ identity, resolve }); + }), + ); + const read = createCachedWorkspaceDirectoryFetcher({ + fetchDirectory, + identityKey: () => identity, + }); + + const accountA = read(); + identity = 'account-b'; + const accountB = read(); + expect(fetchDirectory).toHaveBeenCalledTimes(2); + expect(reads.map((entry) => entry.identity)).toEqual(['account-a', 'account-b']); + + reads[0]!.resolve({ ok: true, items: [] }); + await expect(accountA).resolves.toEqual({ ok: true, items: [] }); + let accountBResolved = false; + void accountB.then(() => { + accountBResolved = true; + }); + await Promise.resolve(); + expect(accountBResolved).toBe(false); + + reads[1]!.resolve({ ok: true, items: [] }); + await expect(accountB).resolves.toEqual({ ok: true, items: [] }); + }); +}); + +describe('createFreshWorkspaceDirectoryFetcher', () => { + it('isolates in-flight mutation authority reads by session and never caches a settled result', async () => { + let identity = 'account-a'; + const reads: Array<{ + identity: string; + resolve: (result: { ok: true; items: [] }) => void; + }> = []; + const fetchDirectory = vi.fn( + () => + new Promise<{ ok: true; items: [] }>((resolve) => { + reads.push({ identity, resolve }); + }), + ); + const read = createFreshWorkspaceDirectoryFetcher({ + fetchDirectory, + identityKey: () => identity, + }); + + const accountA = read(); + const concurrentAccountA = read(); + expect(concurrentAccountA).toBe(accountA); + expect(fetchDirectory).toHaveBeenCalledTimes(1); + + identity = 'account-b'; + const accountB = read(); + expect(fetchDirectory).toHaveBeenCalledTimes(2); + expect(reads.map((entry) => entry.identity)).toEqual(['account-a', 'account-b']); + + reads[0]!.resolve({ ok: true, items: [] }); + await expect(accountA).resolves.toEqual({ ok: true, items: [] }); + await expect(concurrentAccountA).resolves.toEqual({ ok: true, items: [] }); + let accountBResolved = false; + void accountB.then(() => { + accountBResolved = true; + }); + await Promise.resolve(); + expect(accountBResolved).toBe(false); + + reads[1]!.resolve({ ok: true, items: [] }); + await expect(accountB).resolves.toEqual({ ok: true, items: [] }); + + const freshAccountB = read(); + expect(fetchDirectory).toHaveBeenCalledTimes(3); + expect(reads[2]!.identity).toBe('account-b'); + reads[2]!.resolve({ ok: true, items: [] }); + await expect(freshAccountB).resolves.toEqual({ ok: true, items: [] }); + }); +}); + +describe('createWorkspaceDirectoryAuthorityBroker', () => { + it('single-flights shell and project bootstrap reads per account generation without caching failures', async () => { + let identity = 'account-a:config-a'; + const fetchDirectory = vi.fn(async () => ({ + ok: true as const, + items: [], + })); + const authority = createWorkspaceDirectoryAuthorityBroker({ + fetchDirectory, + identityKey: () => identity, + }); + + const [shellDirectory, projectBootstrap] = await Promise.all([ + authority.read(), + authority.read(), + ]); + expect(shellDirectory).toEqual(projectBootstrap); + expect(fetchDirectory).toHaveBeenCalledTimes(1); + await authority.read(); + expect(fetchDirectory).toHaveBeenCalledTimes(1); + + identity = 'account-b:config-b'; + await authority.read(); + expect(fetchDirectory).toHaveBeenCalledTimes(2); + + const failedFetch = vi.fn(async () => ({ ok: false as const, items: [] })); + const failedAuthority = createWorkspaceDirectoryAuthorityBroker({ + fetchDirectory: failedFetch, + identityKey: () => 'account-failing', + }); + await failedAuthority.read(); + await failedAuthority.read(); + expect(failedFetch).toHaveBeenCalledTimes(2); + }); + + it('bounds 30s of status polls while every heartbeat mutation stays fresh', async () => { + let now = 0; + let activeReads = 0; + let maxActiveReads = 0; + const fetchDirectory = vi.fn(async () => { + activeReads += 1; + maxActiveReads = Math.max(maxActiveReads, activeReads); + await Promise.resolve(); + activeReads -= 1; + return { ok: true as const, items: [] }; + }); + const authority = createWorkspaceDirectoryAuthorityBroker({ + fetchDirectory, + identityKey: () => 'account-a:config-a', + now: () => now, + }); + + // Model the production order pessimistically: status first every 5s, then + // heartbeat at each 10s boundary. A fresh heartbeat seeds the next read + // lease, but never consumes a settled lease itself. + for (now = 0; now <= 30_000; now += 5_000) { + await authority.read(); + if (now % 10_000 === 0) await authority.fresh(); + } + + expect(fetchDirectory).toHaveBeenCalledTimes(5); + expect(maxActiveReads).toBe(1); + }); + + it('coalesces unsettled read and mutation checks without reusing settled authority', async () => { + let resolveRead: + | ((result: { ok: true; items: [] }) => void) + | undefined; + const fetchDirectory = vi.fn( + () => + new Promise<{ ok: true; items: [] }>((resolve) => { + resolveRead = resolve; + }), + ); + const authority = createWorkspaceDirectoryAuthorityBroker({ + fetchDirectory, + identityKey: () => 'account-a:config-a', + }); + + const read = authority.read(); + const concurrentMutation = authority.fresh(); + expect(fetchDirectory).toHaveBeenCalledTimes(1); + resolveRead?.({ ok: true, items: [] }); + await Promise.all([read, concurrentMutation]); + + const nextMutation = authority.fresh(); + expect(fetchDirectory).toHaveBeenCalledTimes(2); + resolveRead?.({ ok: true, items: [] }); + await nextMutation; + }); + + it('starts a post-mutation fetch after draining an older in-flight directory read', async () => { + let resolvePreMutation: + | ((result: { ok: true; items: [] }) => void) + | undefined; + const accepted = { + ok: true as const, + items: [{ ...B_TEAM_CONTEXT }], + }; + const fetchDirectory = vi + .fn() + .mockImplementationOnce( + () => new Promise<{ ok: true; items: [] }>((resolve) => { + resolvePreMutation = resolve; + }), + ) + .mockResolvedValueOnce(accepted); + const authority = createWorkspaceDirectoryAuthorityBroker({ + fetchDirectory, + identityKey: () => 'account-a:config-a', + }); + + const preMutationRead = authority.read(); + const postMutationRefresh = authority.refreshAfterMutation(); + expect(fetchDirectory).toHaveBeenCalledTimes(1); + + resolvePreMutation?.({ ok: true, items: [] }); + await expect(preMutationRead).resolves.toEqual({ ok: true, items: [] }); + await expect(postMutationRefresh).resolves.toEqual(accepted); + expect(fetchDirectory).toHaveBeenCalledTimes(2); + await expect(authority.read()).resolves.toEqual(accepted); + }); + + it('publishes a fresh revocation result into the subsequent read lease', async () => { + const active = { + ok: true as const, + items: [{ ...B_TEAM_CONTEXT }], + }; + const revoked = { ok: true as const, items: [] }; + const fetchDirectory = vi + .fn() + .mockResolvedValueOnce(active) + .mockResolvedValueOnce(revoked); + const authority = createWorkspaceDirectoryAuthorityBroker({ + fetchDirectory, + identityKey: () => 'account-a:config-a', + }); + + await expect(authority.read()).resolves.toEqual(active); + await expect(authority.read()).resolves.toEqual(active); + await expect(authority.fresh()).resolves.toEqual(revoked); + await expect(authority.read()).resolves.toEqual(revoked); + expect(fetchDirectory).toHaveBeenCalledTimes(2); + }); +}); + +describe('createVelaWorkspaceContextProvider', () => { + it('fetches B with the vela session bearer token and maps the result', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(200, B_TEAM_CONTEXT)) as unknown as typeof fetch; + const provider = createVelaWorkspaceContextProvider({ + fetch: fetchImpl, + readSession: () => SESSION, + }); + const context = await provider.current({}); + expect(context?.workspaceMemberId).toBe('wm-1'); + expect(context?.teamId).toBe('ws-team-1'); + const [url, init] = (fetchImpl as unknown as ReturnType).mock.calls[0]!; + expect(String(url)).toBe('https://vela.example/api/v1/workspaces/current'); + expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer ck-1' }); + }); + + it('returns null without calling B when there is no vela session', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(200, B_TEAM_CONTEXT)) as unknown as typeof fetch; + const provider = createVelaWorkspaceContextProvider({ fetch: fetchImpl, readSession: () => null }); + expect(await provider.current({})).toBeNull(); + expect((fetchImpl as unknown as ReturnType).mock.calls.length).toBe(0); + }); + + it('degrades to null on a 401 (signed out) or a network error', async () => { + const unauthorized = createVelaWorkspaceContextProvider({ + fetch: (async () => jsonResponse(401, { error: 'unauthenticated' })) as unknown as typeof fetch, + readSession: () => SESSION, + }); + expect(await unauthorized.current({})).toBeNull(); + + const broken = createVelaWorkspaceContextProvider({ + fetch: (async () => { + throw new Error('network down'); + }) as unknown as typeof fetch, + readSession: () => SESSION, + }); + expect(await broken.current({})).toBeNull(); + }); +}); + +// B-line explicit-workspace handoff: the client must not perceive (or write) +// B's account-level Active Workspace. The provider serves the LOCALLY selected +// workspace — enriched from B when B agrees, synthesized from the workspace +// directory when it does not — and only falls back to B's current when the +// client has no selection at all. A fresh account (no current anywhere) picks +// a LOCAL default (personal first) without ever PUTting server state. +describe('createVelaWorkspaceContextProvider explicit local scope', () => { + const B_PERSONAL_CONTEXT = { + ...B_TEAM_CONTEXT, + workspaceId: 'ws-personal-1', + workspaceType: 'personal', + workspaceMemberId: 'wm-p1', + role: 'owner', + }; + const DIRECTORY = { + items: [ + { + workspaceId: 'ws-team-1', + workspaceName: 'Team', + workspaceType: 'team', + workspaceMemberId: 'wm-1', + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + }, + { + workspaceId: 'ws-personal-1', + workspaceName: 'Personal', + workspaceType: 'personal', + workspaceMemberId: 'wm-p1', + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + }, + ], + }; + + function scriptedFetch(handlers: { + current?: () => Response; + directory?: () => Response; + put?: () => Response; + }) { + const calls: Array<{ url: string; method: string }> = []; + const fetchImpl = vi.fn(async (url: URL | string, init?: RequestInit) => { + const method = init?.method ?? 'GET'; + const u = String(url); + calls.push({ url: u, method }); + if (u.includes('/workspaces/current') && method === 'GET' && handlers.current) return handlers.current(); + if (u.endsWith('/api/v1/workspaces') && method === 'GET' && handlers.directory) return handlers.directory(); + if (u.includes('/workspaces/current') && method === 'PUT' && handlers.put) return handlers.put(); + throw new Error(`unexpected fetch ${method} ${u}`); + }) as unknown as typeof fetch; + return { fetchImpl, calls }; + } + + it('picks a LOCAL default (personal first) with no server write when B has no current', async () => { + vi.stubEnv('OD_VELA_WEB_URL', 'https://web.example.com/console'); + const { fetchImpl, calls } = scriptedFetch({ + current: () => jsonResponse(403, { error: 'missing_principal' }), + directory: () => jsonResponse(200, DIRECTORY), + }); + const selected: string[] = []; + const provider = createVelaWorkspaceContextProvider({ + fetch: fetchImpl, + readSession: () => SESSION, + setLocalSelection: (id) => { selected.push(id); }, + }); + const context = await provider.current({}); + expect(selected).toEqual(['ws-personal-1']); + expect(context?.workspaceId).toBe('ws-personal-1'); + expect(context?.workspaceType).toBe('personal'); + expect(context?.workspaceMemberId).toBe('wm-p1'); + expect(context?.workspaceSettingsUrl).toBe( + 'https://web.example.com/console/settings?workspaceId=ws-personal-1', + ); + // Resource semantics from the handoff: a plain read NEVER writes the + // account-level Active Workspace. + expect(calls.some((c) => c.method === 'PUT')).toBe(false); + }); + + it('keeps Personal workspace actions stable from a synthesized context to a rich refresh', async () => { + vi.stubEnv('OD_VELA_WEB_URL', 'https://web.example.com/console'); + let currentRead = 0; + const { fetchImpl } = scriptedFetch({ + // First read disagrees with OD's Personal pin and forces directory + // synthesis. The next read models the rich response after switching away + // and back, when B's account-level current finally matches the local pin. + current: () => jsonResponse(200, currentRead++ === 0 ? B_TEAM_CONTEXT : B_PERSONAL_CONTEXT), + directory: () => jsonResponse(200, DIRECTORY), + }); + const provider = createVelaWorkspaceContextProvider({ + fetch: fetchImpl, + readSession: () => SESSION, + getActiveWorkspaceId: () => 'ws-personal-1', + }); + + const initial = await provider.current({}); + const refreshed = await provider.current({}); + + expect(initial?.workspaceType).toBe('personal'); + expect(initial?.workspaceSettingsUrl).toBe( + 'https://web.example.com/console/settings?workspaceId=ws-personal-1', + ); + expect(refreshed?.workspaceSettingsUrl).toBe(initial?.workspaceSettingsUrl); + }); + + it('serves the local selection and ignores a mismatched server current', async () => { + const { fetchImpl } = scriptedFetch({ + current: () => jsonResponse(200, B_PERSONAL_CONTEXT), + directory: () => jsonResponse(200, DIRECTORY), + }); + const provider = createVelaWorkspaceContextProvider({ + fetch: fetchImpl, + readSession: () => SESSION, + getActiveWorkspaceId: () => 'ws-team-1', + }); + const context = await provider.current({}); + // Another device switched B's Active Workspace to personal; this daemon's + // pinned scope must not follow it. + expect(context?.workspaceId).toBe('ws-team-1'); + expect(context?.workspaceType).toBe('team'); + expect(context?.teamId).toBe('ws-team-1'); + expect(context?.workspaceMemberId).toBe('wm-1'); + }); + + it('enriches from B when the server current matches the local selection', async () => { + const { fetchImpl } = scriptedFetch({ + current: () => jsonResponse(200, B_TEAM_CONTEXT), + }); + const provider = createVelaWorkspaceContextProvider({ + fetch: fetchImpl, + readSession: () => SESSION, + getActiveWorkspaceId: () => 'ws-team-1', + }); + const context = await provider.current({}); + expect(context?.workspaceId).toBe('ws-team-1'); + // Rich billing data only B carries — proof the mapped body was used. + expect(context?.planId).toBe('team-pro'); + }); + + it('does not bootstrap on 401 (signed out is not a missing principal)', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(401, { error: 'unauthenticated' })) as unknown as typeof fetch; + const provider = createVelaWorkspaceContextProvider({ fetch: fetchImpl, readSession: () => SESSION }); + expect(await provider.current({})).toBeNull(); + expect((fetchImpl as unknown as ReturnType).mock.calls.length).toBe(1); + }); + + it('cools down after a failed default pick instead of hammering the directory', async () => { + const { fetchImpl, calls } = scriptedFetch({ + current: () => jsonResponse(403, { error: 'missing_principal' }), + directory: () => jsonResponse(200, { items: [] }), + }); + const provider = createVelaWorkspaceContextProvider({ + fetch: fetchImpl, + readSession: () => SESSION, + }); + expect(await provider.current({})).toBeNull(); + expect(await provider.current({})).toBeNull(); + const directoryCalls = calls.filter((c) => c.url.endsWith('/api/v1/workspaces')); + expect(directoryCalls.length).toBe(1); + }); +}); + +// recvqbbQ4yljNC: a member removed from a team workspace stayed pinned to it +// forever. `current()` must tell apart "the directory CONFIRMS the pin is +// gone" (safe to clear + fall back) from "B could not be asked right now" +// (must NOT touch the pin — a network blip must never evict an online user). +describe('createVelaWorkspaceContextProvider — stale pin recovery', () => { + const DIRECTORY_WITHOUT_TEAM = { + items: [ + { + workspaceId: 'ws-personal-1', + workspaceName: 'Personal', + workspaceType: 'personal', + workspaceMemberId: 'wm-p1', + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + }, + ], + }; + const DIRECTORY_TEAM_MEMBER_REMOVED = { + items: [ + { + workspaceId: 'ws-team-1', + workspaceName: 'Team', + workspaceType: 'team', + workspaceMemberId: 'wm-1', + role: 'member', + memberStatus: 'removed', + lifecycleState: 'active', + }, + { + workspaceId: 'ws-personal-1', + workspaceName: 'Personal', + workspaceType: 'personal', + workspaceMemberId: 'wm-p1', + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + }, + ], + }; + + /** A stateful local-pin double: `clearLocalSelection`/`setLocalSelection` + * mutate the SAME backing value `getActiveWorkspaceId` reads, exactly like + * the real `ActiveWorkspaceSelectionStore` — so a clear takes effect for + * the very same `current()` call's fallback pick, not just the next one. */ + function statefulPin(initial: string | undefined) { + let value = initial; + const setCalls: string[] = []; + const clearCalls: number[] = []; + return { + getActiveWorkspaceId: () => value, + setLocalSelection: (id: string) => { + value = id; + setCalls.push(id); + }, + clearLocalSelection: () => { + value = undefined; + clearCalls.push(1); + }, + setCalls, + clearCalls, + }; + } + + function scriptedFetch(handlers: { current?: () => Response; directory?: () => Response }) { + const fetchImpl = vi.fn(async (url: URL | string, init?: RequestInit) => { + const method = init?.method ?? 'GET'; + const u = String(url); + if (u.includes('/workspaces/current') && method === 'GET' && handlers.current) return handlers.current(); + if (u.endsWith('/api/v1/workspaces') && method === 'GET' && handlers.directory) return handlers.directory(); + throw new Error(`unexpected fetch ${method} ${u}`); + }) as unknown as typeof fetch; + return fetchImpl; + } + + it('RED→GREEN: clears the pin and falls back to personal when the workspace vanished from the directory', async () => { + const pin = statefulPin('ws-team-1'); + const fetchImpl = scriptedFetch({ + // B also refuses this pinned scope now (not a 401 — the vela session + // itself is still fine, just this workspace is no longer usable). + current: () => jsonResponse(403, { error: 'membership_not_found' }), + directory: () => jsonResponse(200, DIRECTORY_WITHOUT_TEAM), + }); + const provider = createVelaWorkspaceContextProvider({ + fetch: fetchImpl, + readSession: () => SESSION, + getActiveWorkspaceId: pin.getActiveWorkspaceId, + setLocalSelection: pin.setLocalSelection, + clearLocalSelection: pin.clearLocalSelection, + }); + + const context = await provider.current({}); + + // Before the fix this returned null forever (the front end reads a null + // context as "signed out"), even though the user still has a usable + // personal workspace. + expect(context).not.toBeNull(); + expect(context?.workspaceId).toBe('ws-personal-1'); + expect(context?.workspaceType).toBe('personal'); + expect(pin.clearCalls.length).toBe(1); + expect(pin.setCalls).toEqual(['ws-personal-1']); + expect(pin.getActiveWorkspaceId()).toBe('ws-personal-1'); + }); + + it('RED→GREEN: clears the pin when the membership is listed but no longer active', async () => { + const pin = statefulPin('ws-team-1'); + const fetchImpl = scriptedFetch({ + current: () => jsonResponse(403, { error: 'membership_not_found' }), + directory: () => jsonResponse(200, DIRECTORY_TEAM_MEMBER_REMOVED), + }); + const provider = createVelaWorkspaceContextProvider({ + fetch: fetchImpl, + readSession: () => SESSION, + getActiveWorkspaceId: pin.getActiveWorkspaceId, + setLocalSelection: pin.setLocalSelection, + clearLocalSelection: pin.clearLocalSelection, + }); + + const context = await provider.current({}); + + expect(context?.workspaceId).toBe('ws-personal-1'); + expect(pin.clearCalls.length).toBe(1); + expect(pin.setCalls).toEqual(['ws-personal-1']); + }); + + it('does NOT clear the pin when the directory request fails (network error) — preserve on B outage', async () => { + const pin = statefulPin('ws-team-1'); + const fetchImpl = vi.fn(async (url: URL | string, init?: RequestInit) => { + const method = init?.method ?? 'GET'; + const u = String(url); + if (u.includes('/workspaces/current') && method === 'GET') { + return jsonResponse(403, { error: 'membership_not_found' }); + } + if (u.endsWith('/api/v1/workspaces') && method === 'GET') { + throw new Error('network down'); + } + throw new Error(`unexpected fetch ${method} ${u}`); + }) as unknown as typeof fetch; + const provider = createVelaWorkspaceContextProvider({ + fetch: fetchImpl, + readSession: () => SESSION, + getActiveWorkspaceId: pin.getActiveWorkspaceId, + setLocalSelection: pin.setLocalSelection, + clearLocalSelection: pin.clearLocalSelection, + }); + + const context = await provider.current({}); + + // A transient B outage degrades to null for this one read, exactly like + // the existing network-error contract — but the pin itself must survive + // untouched so the NEXT successful poll can still recover the real + // workspace instead of having already been evicted to a fallback. + expect(context).toBeNull(); + expect(pin.clearCalls.length).toBe(0); + expect(pin.setCalls.length).toBe(0); + expect(pin.getActiveWorkspaceId()).toBe('ws-team-1'); + }); + + it('does NOT clear the pin when the directory request itself returns a non-2xx', async () => { + const pin = statefulPin('ws-team-1'); + const fetchImpl = scriptedFetch({ + current: () => jsonResponse(403, { error: 'membership_not_found' }), + directory: () => jsonResponse(500, { error: 'internal' }), + }); + const provider = createVelaWorkspaceContextProvider({ + fetch: fetchImpl, + readSession: () => SESSION, + getActiveWorkspaceId: pin.getActiveWorkspaceId, + setLocalSelection: pin.setLocalSelection, + clearLocalSelection: pin.clearLocalSelection, + }); + + const context = await provider.current({}); + + expect(context).toBeNull(); + expect(pin.clearCalls.length).toBe(0); + expect(pin.setCalls.length).toBe(0); + expect(pin.getActiveWorkspaceId()).toBe('ws-team-1'); + }); +}); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f4d1e64770e..3c35ec5cbe5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@open-design/desktop", - "version": "0.16.1", + "version": "0.16.2", "private": true, "type": "module", "main": "./dist/main/index.js", diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 3549dee5dd9..552123acc62 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -39,6 +39,7 @@ import { import { readProcessStamp } from "@open-design/platform"; import { createDesktopRuntime, type DesktopRuntime } from "./runtime.js"; +import { registerInviteDeeplink, focusPrimaryWindow } from "./invite-deeplink.js"; import { setUpDesktopCrashReporter, writeDesktopGpuInfo } from "./crash-diagnostics.js"; import { beginDesktopSession, clearReportedCrash, endDesktopSessionCleanly, markDesktopSessionRunning } from "./session-lifecycle.js"; import { @@ -104,7 +105,7 @@ export { const TOOLS_DEV_PARENT_PID_ENV = SIDECAR_ENV.TOOLS_DEV_PARENT_PID; const AMR_PROFILE_ENV_KEY = "OPEN_DESIGN_AMR_PROFILE"; const AMR_PROFILE_AGENT_ID = "amr"; -const AMR_ENVIRONMENT_PROFILES = ["prod", "test", "local"] as const; +const AMR_ENVIRONMENT_PROFILES = ["prod", "test", "feature-test", "local"] as const; const APP_CONFIG_CHANGED_IPC_CHANNEL = "od:app-config-changed"; type AmrEnvironmentProfile = (typeof AMR_ENVIRONMENT_PROFILES)[number]; type DesktopAppConfigPrefs = { @@ -140,6 +141,27 @@ export function applyOsLocaleSwitch(electronApp: Electron.App): string { return osLocale; } +/** + * Lift Chromium's hardcoded 6-connections-per-origin socket cap for the + * loopback hosts every Open Design renderer talks to (directly in dev, + * through the od:// proxy's main-process net.fetch when packaged). + * + * Long-lived SSE streams pin pool slots, and once the pool saturates, + * queued requests cannot even be aborted before a Response exists + * (electron/electron#47097), which deadlocked the packaged app until + * restart. `ignore-connections-limit` is Electron's own escape hatch: + * matching hosts get LOAD_IGNORE_LIMITS. Loopback-only, so the extra + * parallelism has no upstream cost. + * + * Must run before `app.whenReady()`; Chromium consumes the switch at + * network-service startup. + */ +export function applyLoopbackConnectionLimitSwitch(electronApp: Electron.App): void { + if (!electronApp.isReady()) { + electronApp.commandLine.appendSwitch("ignore-connections-limit", "127.0.0.1,localhost"); + } +} + export type DesktopMainOptions = { beforeShutdown?: () => Promise; onExternalShow?: () => void | Promise; @@ -256,7 +278,9 @@ export function mergeAmrEnvironmentProfileConfig( profile: AmrEnvironmentProfile, ): DesktopAppConfigPrefs { if (!AMR_ENVIRONMENT_PROFILES.includes(profile)) { - throw new Error(`Unsupported AMR Environment Profile: ${String(profile)}`); + throw new Error( + `AMR Environment Profile must be prod, test, feature-test, or local: ${String(profile)}`, + ); } const currentProfile = normalizeAmrEnvironmentProfile( config.agentCliEnv?.[AMR_PROFILE_AGENT_ID]?.[AMR_PROFILE_ENV_KEY], @@ -680,6 +704,9 @@ export async function runDesktopMain( // its own `whenReady`; this call is then a no-op for the switch and // only recovers the locale string for the BrowserWindow below. const osLocale = applyOsLocaleSwitch(app); + // Same dev-vs-packaged split as the locale switch above: dev lands the + // switch here, packaged has already applied it pre-whenReady. + applyLoopbackConnectionLimitSwitch(app); await app.whenReady(); configureAboutPanel(options); @@ -975,6 +1002,11 @@ export async function runDesktopMain( removeDiagnosticsIpc = registerDesktopDiagnosticsIpc({ discoverDaemonBaseUrl: resolveDaemonBaseUrl(runtime, options), }); + // Route opendesign:// team-invite deeplinks to the daemon (desktop wake-up). + registerInviteDeeplink({ + resolveDaemonBaseUrl: resolveDaemonBaseUrl(runtime, options), + focus: focusPrimaryWindow, + }); const discoverUpdaterAppConfigBaseUrl = resolveDaemonBaseUrl(runtime, options); updateScheduler = createDesktopUpdaterScheduler(updater, { backoffInitialMs: updater.config.checkBackoffInitialMs, diff --git a/apps/desktop/src/main/invite-deeplink-core.ts b/apps/desktop/src/main/invite-deeplink-core.ts new file mode 100644 index 00000000000..7a67b74d0a9 --- /dev/null +++ b/apps/desktop/src/main/invite-deeplink-core.ts @@ -0,0 +1,130 @@ +// Pure core of the desktop invite hand-off — no electron import, so it is unit +// testable. The electron scheme registration lives in `invite-deeplink.ts`. + +export const INVITE_DEEPLINK_SCHEME = "opendesign"; +const INVITE_DEEPLINK_HOST = "workspace"; +const INVITE_DEEPLINK_PATH = "/invite/continue"; + +interface ParsedInviteDeeplink { + workspaceId: string; + memberId: string; + inviteId: string; + nonce: string; +} + +/** + * Parse `opendesign://workspace/invite/continue?workspace_id=&member_id=&invite_id= + * &nonce=` into its four required fields, or null if the scheme/host/path is wrong + * or any field is missing. The desktop only forwards the nonce to the daemon, but + * all four are validated so a malformed deeplink is rejected rather than + * half-handled. The payload shape is fixed by the B-C invite contract; the daemon + * and web share the same fields. + */ +function parseInviteDeeplink(url: string): ParsedInviteDeeplink | null { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + if (parsed.protocol !== `${INVITE_DEEPLINK_SCHEME}:`) return null; + if (parsed.host !== INVITE_DEEPLINK_HOST) return null; + if (parsed.pathname.replace(/\/+$/, "") !== INVITE_DEEPLINK_PATH) return null; + const q = parsed.searchParams; + const workspaceId = q.get("workspace_id")?.trim() ?? ""; + const memberId = q.get("member_id")?.trim() ?? ""; + const inviteId = q.get("invite_id")?.trim() ?? ""; + const nonce = q.get("nonce")?.trim() ?? ""; + if (!workspaceId || !memberId || !inviteId || !nonce) return null; + return { workspaceId, memberId, inviteId, nonce }; +} + +export interface InviteDeeplinkDeps { + /** Resolve the running daemon's base URL; rejects when it is not up yet. */ + resolveDaemonBaseUrl: () => Promise; + /** Injectable for tests. */ + fetch?: typeof fetch; + /** Bring the app to the foreground after a successful hand-off. */ + focus?: () => void; + /** Fired with the resolved workspace context on success (e.g. to nudge the web). */ + onActivated?: (context: unknown) => void; +} + +type ContinueInvite = ( + url: string, + deps: InviteDeeplinkDeps, +) => Promise<{ ok: boolean; reason?: string; status?: number }>; + +/** + * Queue OS deeplinks that arrive before the desktop runtime can resolve the + * daemon URL. macOS can deliver `open-url` during cold start, before the app has + * finished constructing the daemon/web bridge; dropping that URL would strand + * the accepted invite on the cloud success page. + */ +export function createInviteDeeplinkDispatcher( + continueInvite: ContinueInvite = continueInviteFromUrl, +) { + let deps: InviteDeeplinkDeps | null = null; + const pending: string[] = []; + + const dispatch = (url: string | null) => { + if (!url) return; + if (!deps) { + pending.push(url); + return; + } + void continueInvite(url, deps); + }; + + return { + dispatch, + setDeps(nextDeps: InviteDeeplinkDeps) { + deps = nextDeps; + const queued = pending.splice(0); + for (const url of queued) dispatch(url); + }, + pendingCount() { + return pending.length; + }, + }; +} + +/** Extract an `opendesign://` url from a process argv list, if present. */ +export function findDeeplinkArg(argv: readonly string[]): string | null { + return argv.find((arg) => arg.startsWith(`${INVITE_DEEPLINK_SCHEME}://`)) ?? null; +} + +/** + * Parse an invite deeplink and consume it via the daemon. Returns the outcome (or + * a reason it did nothing) and never throws, so the app's url handlers stay safe. + */ +export async function continueInviteFromUrl( + url: string, + deps: InviteDeeplinkDeps, +): Promise<{ ok: boolean; reason?: string; status?: number }> { + const parsed = parseInviteDeeplink(url); + if (!parsed) return { ok: false, reason: "not_an_invite_deeplink" }; + let baseUrl: string; + try { + baseUrl = await deps.resolveDaemonBaseUrl(); + } catch { + return { ok: false, reason: "daemon_unavailable" }; + } + const fetchImpl = deps.fetch ?? fetch; + try { + const response = await fetchImpl(new URL("/api/workspace/invite/continue", baseUrl), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ nonce: parsed.nonce }), + }); + if (!response.ok) return { ok: false, reason: "consume_failed", status: response.status }; + const body = (await response.json()) as { context?: unknown }; + deps.onActivated?.(body.context ?? null); + deps.focus?.(); + return { ok: true }; + } catch { + // The web success page keeps a retry-open affordance, so a transient failure + // here is recoverable — never throw into the app's url handlers. + return { ok: false, reason: "unreachable" }; + } +} diff --git a/apps/desktop/src/main/invite-deeplink.ts b/apps/desktop/src/main/invite-deeplink.ts new file mode 100644 index 00000000000..30b346b62b7 --- /dev/null +++ b/apps/desktop/src/main/invite-deeplink.ts @@ -0,0 +1,78 @@ +import { app, BrowserWindow } from "electron"; +import { + INVITE_DEEPLINK_SCHEME, + createInviteDeeplinkDispatcher, + continueInviteFromUrl, + findDeeplinkArg, + type InviteDeeplinkDeps, +} from "./invite-deeplink-core.js"; + +// Desktop side of the invite hand-off ("桌面唤起", C's lane in the B-C invite +// contract). The cloud web app accepts the invite, then opens +// `opendesign://workspace/invite/continue?...&nonce=...` to wake this client. We +// register the scheme and route the deeplink to the daemon, which consumes the +// one-time continuation on B with the signed-in vela session; the client then +// focuses and the web re-reads the context to switch into the team workspace. + +export { + continueInviteFromUrl, + createInviteDeeplinkDispatcher, + findDeeplinkArg, + INVITE_DEEPLINK_SCHEME, + type InviteDeeplinkDeps, +} from "./invite-deeplink-core.js"; + +const deeplinkDispatcher = createInviteDeeplinkDispatcher(); +let secondInstanceHandlerRegistered = false; + +/** + * Attach the macOS `open-url` handler at import time rather than inside + * {@link registerInviteDeeplink}: registration only happens late in bootstrap + * (after `app.whenReady()`), while a cold start through the deeplink delivers + * `open-url` well before that. The dispatcher queues until deps arrive, so + * listening this early is what makes the cold-start hand-off work at all. + * + * Importing this module must nevertheless stay side-effect-safe outside + * Electron: `apps/desktop/src/main/index.ts` re-exports pure helpers that unit + * tests import directly, and in a plain Node process the `electron` entry + * resolves to the binary-path shim that has no `app`. Attaching unconditionally + * would turn every such import into a TypeError at module load. + */ +function attachOpenUrlListenerWhenHosted(): void { + if (typeof app?.on !== "function") return; + app.on("open-url", (event, url) => { + event.preventDefault(); + deeplinkDispatcher.dispatch(url); + }); +} + +attachOpenUrlListenerWhenHosted(); + +/** + * Register the `opendesign://` scheme and wire the OS deeplink events to + * {@link continueInviteFromUrl}. macOS delivers via `open-url`; Windows/Linux via + * a second-instance argv (requires the single-instance lock the app already + * holds). A cold start through the deeplink carries it in the initial argv. + */ +export function registerInviteDeeplink(deps: InviteDeeplinkDeps): void { + app.setAsDefaultProtocolClient(INVITE_DEEPLINK_SCHEME); + deeplinkDispatcher.setDeps(deps); + + if (!secondInstanceHandlerRegistered) { + secondInstanceHandlerRegistered = true; + app.on("second-instance", (_event, argv) => { + deeplinkDispatcher.dispatch(findDeeplinkArg(argv)); + }); + } + + const initial = findDeeplinkArg(process.argv); + if (initial) void app.whenReady().then(() => deeplinkDispatcher.dispatch(initial)); +} + +/** Best-effort bring-to-front for the deeplink hand-off. */ +export function focusPrimaryWindow(): void { + const win = BrowserWindow.getAllWindows()[0]; + if (!win) return; + if (win.isMinimized()) win.restore(); + win.focus(); +} diff --git a/apps/desktop/src/main/mailto-open.ts b/apps/desktop/src/main/mailto-open.ts new file mode 100644 index 00000000000..1db343ed837 --- /dev/null +++ b/apps/desktop/src/main/mailto-open.ts @@ -0,0 +1,220 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import { shell } from "electron"; + +const execFileAsync = promisify(execFile); + +// How long we let `defaults read` run before giving up and falling back to +// `shell.openExternal`. The read is a tiny local prefs lookup; anything slower +// means something is wrong and the click must not feel dead while we wait. +const HANDLER_LOOKUP_TIMEOUT_MS = 1_500; + +// Bundle-id shapes that identify a WEB BROWSER registered as the macOS +// "default email reader". Explicit prefixes for the browsers we know, plus +// conservative substrings for renamed/forked builds (e.g. `com.google.Chrome.beta`, +// `com.duckduckgo.macos.browser`). A mail client must never match: the whole +// point of the check is to tell "mailto goes to a real mail app" apart from +// "mailto goes to a browser that will swallow it". +const BROWSER_BUNDLE_ID_PREFIXES = [ + "com.apple.safari", + "com.google.chrome", + "org.chromium.chromium", + "com.microsoft.edgemac", + "org.mozilla.firefox", + "org.mozilla.nightly", + "com.brave.browser", + "com.operasoftware.", + "com.vivaldi.vivaldi", + "company.thebrowser.", + "ru.yandex.desktop.yandex-browser", +]; + +const BROWSER_BUNDLE_ID_SUBSTRINGS = ["chrome", "chromium", "firefox", "edgemac", "browser"]; + +export function isBrowserBundleId(bundleId: string): boolean { + const normalized = bundleId.trim().toLowerCase(); + if (!normalized) return false; + if (BROWSER_BUNDLE_ID_PREFIXES.some((prefix) => normalized.startsWith(prefix))) return true; + return BROWSER_BUNDLE_ID_SUBSTRINGS.some((needle) => normalized.includes(needle)); +} + +// Parse the old-style plist text printed by: +// `defaults read com.apple.LaunchServices/com.apple.launchservices.secure LSHandlers` +// and return the bundle id registered for the `mailto` scheme, lowercased, or +// null when no override exists (macOS then routes mailto to Apple Mail, the +// built-in default). Entries look like: +// +// { +// LSHandlerPreferredVersions = { +// LSHandlerRoleAll = "-"; +// }; +// LSHandlerRoleAll = "com.google.chrome"; +// LSHandlerURLScheme = mailto; +// }, +// +// The nested `LSHandlerPreferredVersions` dict also carries an +// `LSHandlerRoleAll` key, so the scan must only read keys at the entry's own +// depth — a flat regex over the whole entry would happily return `-`. +export function extractDefaultMailtoHandlerBundleId(lsHandlersText: string): string | null { + for (const entry of topLevelDictEntries(lsHandlersText)) { + const flattened = blankNestedDicts(entry); + if (!/LSHandlerURLScheme\s*=\s*"?mailto"?\s*;/i.test(flattened)) continue; + const role = /LSHandlerRoleAll\s*=\s*"?([^";\n]+)"?\s*;/.exec(flattened); + if (!role) return null; + const bundleId = role[1].trim().toLowerCase(); + return bundleId && bundleId !== "-" ? bundleId : null; + } + return null; +} + +// Split the printed LSHandlers array into its top-level `{ ... }` entries, +// keeping nested braces balanced inside each entry. +function topLevelDictEntries(text: string): string[] { + const entries: string[] = []; + let depth = 0; + let start = -1; + for (let i = 0; i < text.length; i += 1) { + const ch = text[i]; + if (ch === "{") { + if (depth === 0) start = i; + depth += 1; + } else if (ch === "}") { + depth -= 1; + if (depth === 0 && start >= 0) { + entries.push(text.slice(start, i + 1)); + start = -1; + } + if (depth < 0) depth = 0; + } + } + return entries; +} + +// Replace the CONTENT of any nested `{ ... }` inside a single entry with +// blanks so key lookups only see the entry's own depth. +function blankNestedDicts(entry: string): string { + const inner = entry.slice(1, -1); + let depth = 0; + let out = ""; + for (const ch of inner) { + if (ch === "{") { + depth += 1; + out += " "; + } else if (ch === "}") { + depth -= 1; + if (depth < 0) depth = 0; + out += " "; + } else { + out += depth === 0 ? ch : " "; + } + } + return out; +} + +// Where a validated first-party mailto should be launched. +// +// - `system-default`: hand the URL to the OS (`shell.openExternal`). Used when +// no mailto override exists (Apple Mail is the built-in default) or when the +// override is a genuine mail client the user chose. +// - `apple-mail`: the user's OS-level "default email reader" is a web browser. +// `shell.openExternal(mailto:)` then just focuses that browser, which drops +// the mailto unless a webmail handler happens to be configured — the exact +// dead-end of recvpZzUroEPUT ("click the mail button, the browser comes to +// the front on whatever page it was on, no compose window ever appears"). +// Launch Apple Mail with the mailto explicitly instead: the product intent +// of the button is "open the local mail client". +export type MailtoLaunch = "system-default" | "apple-mail"; + +export function resolveMailtoLaunch(handlerBundleId: string | null): MailtoLaunch { + if (handlerBundleId && isBrowserBundleId(handlerBundleId)) return "apple-mail"; + return "system-default"; +} + +type RunCommand = ( + file: string, + args: string[], +) => Promise<{ stdout: string; stderr: string }>; + +const defaultRunCommand: RunCommand = async (file, args) => { + const { stdout, stderr } = await execFileAsync(file, args, { + timeout: HANDLER_LOOKUP_TIMEOUT_MS, + windowsHide: true, + }); + return { stdout: String(stdout), stderr: String(stderr) }; +}; + +export async function readDefaultMailtoHandlerBundleId( + runCommand: RunCommand = defaultRunCommand, +): Promise { + try { + const { stdout } = await runCommand("defaults", [ + "read", + "com.apple.LaunchServices/com.apple.launchservices.secure", + "LSHandlers", + ]); + return extractDefaultMailtoHandlerBundleId(stdout); + } catch { + // Missing key/domain (no overrides — Apple Mail is the default), or a + // slow/failed read: both mean "trust the system default". + return null; + } +} + +export interface OpenFirstPartyMailtoDeps { + platform: NodeJS.Platform; + readHandlerBundleId: () => Promise; + openWithAppleMail: (url: string) => Promise; + openExternal: (url: string) => Promise; +} + +const defaultDeps: OpenFirstPartyMailtoDeps = { + platform: process.platform, + readHandlerBundleId: () => readDefaultMailtoHandlerBundleId(), + openWithAppleMail: async (url) => { + await execFileAsync("open", ["-b", "com.apple.mail", url], { + timeout: HANDLER_LOOKUP_TIMEOUT_MS, + }); + }, + openExternal: (url) => shell.openExternal(url), +}; + +// Open a first-party mailto in the user's LOCAL mail client. Callers must have +// already validated the URL against the first-party allowlist +// (`isFirstPartyMailtoUrl` / `isSupportMailtoUrl` in runtime.ts); this function +// re-checks only the scheme so nothing but a mailto can ever reach the shell. +// +// On macOS, when the OS-level mailto handler is a web browser, the URL is +// handed to Apple Mail directly (see `resolveMailtoLaunch`); every failure path +// degrades to plain `shell.openExternal`, which is the pre-existing behavior. +export async function openFirstPartyMailto( + url: string, + deps: Partial = {}, +): Promise { + const { platform, readHandlerBundleId, openWithAppleMail, openExternal } = { + ...defaultDeps, + ...deps, + }; + try { + if (new URL(url).protocol !== "mailto:") return false; + } catch { + return false; + } + if (platform === "darwin") { + const launch = resolveMailtoLaunch(await readHandlerBundleId()); + if (launch === "apple-mail") { + try { + await openWithAppleMail(url); + return true; + } catch { + // Apple Mail missing or `open` failed — fall through to the OS default. + } + } + } + try { + await openExternal(url); + return true; + } catch { + return false; + } +} diff --git a/apps/desktop/src/main/preload.cts b/apps/desktop/src/main/preload.cts index 27836d956f0..a1ab27af277 100644 --- a/apps/desktop/src/main/preload.cts +++ b/apps/desktop/src/main/preload.cts @@ -8,6 +8,7 @@ import type { OpenDesignHostCaptureResult, OpenDesignHostFailure, OpenDesignHostProjectImportResult, + OpenDesignHostProjectImportInit, OpenDesignHostProjectReplaceWorkingDirResult, OpenDesignHostPickWorkingDirResult, OpenDesignHostUpdaterActionOptions, @@ -178,7 +179,7 @@ type DesktopDiagnosticsExportResult = const project = { pickAndImport: ( - init?: { name?: string; skillId?: string | null; designSystemId?: string | null }, + init?: OpenDesignHostProjectImportInit, ): Promise => ipcRenderer.invoke('dialog:pick-and-import', init ?? null) .then(normalizeProjectImportResult) @@ -309,6 +310,12 @@ const hostBridge = { platform: process.platform, ...(osLocale !== undefined ? { osLocale } : {}), }, + appearance: { + // Pin the native window appearance (macOS vibrancy glass material) to the + // app theme. Fire-and-forget: the main process validates the value. + setTheme: (theme: 'light' | 'dark' | 'system'): void => + ipcRenderer.send('od:appearance:set-theme', theme), + }, shell, browser, capture, diff --git a/apps/desktop/src/main/runtime.ts b/apps/desktop/src/main/runtime.ts index 93ce7aaee16..3033cca9f67 100644 --- a/apps/desktop/src/main/runtime.ts +++ b/apps/desktop/src/main/runtime.ts @@ -6,7 +6,7 @@ import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; -import { BrowserWindow, app, dialog, ipcMain, nativeImage, screen, session, shell } from "electron"; +import { BrowserWindow, app, dialog, ipcMain, nativeImage, nativeTheme, screen, session, shell } from "electron"; import { DESKTOP_UPDATE_CHANNELS, DESKTOP_UPDATE_MODES, @@ -22,12 +22,14 @@ import { import type { OpenDesignHostActionResult, OpenDesignHostCaptureResult, + OpenDesignHostProjectImportInit, OpenDesignHostUpdaterActionOptions, OpenDesignHostUpdaterMenuLabels, OpenDesignHostUpdaterOpenDialogRequest, } from "@open-design/host"; import { renderDeckSlides } from "./deck-capture.js"; +import { openFirstPartyMailto } from "./mailto-open.js"; import { openValidatedDirectory } from "./open-path.js"; import { exportArtifact as exportArtifactFromHtml } from "./artifact-export.js"; import { createElectronPdfTarget, exportPdfFromHtml, savePrintReadyDocumentAsPdf } from "./pdf-export.js"; @@ -443,7 +445,7 @@ export type DesktopRuntimeOptions = { onUpdateMenuLabels?: (labels: OpenDesignHostUpdaterMenuLabels) => void; }; -const DESKTOP_IMPORT_TOKEN_HEADER = "X-OD-Desktop-Import-Token"; +const DESKTOP_IMPORT_TOKEN_HEADER = "x-od-desktop-import-token"; const DESKTOP_IMPORT_TOKEN_TTL_MS = 60_000; export function mintImportToken(secret: Buffer, baseDir: string): string { @@ -485,7 +487,7 @@ export type PickAndImportFolderDeps = { baseDir: string; desktopAuthSecret: Buffer; fetchImpl?: typeof globalThis.fetch; - init?: { name?: string; skillId?: string | null; designSystemId?: string | null }; + init?: OpenDesignHostProjectImportInit; /** Round-5: lazy re-registration hook. Called once on 503. */ registerDesktopAuth?: () => Promise; /** Injected for tests; defaults to the production HMAC mint. */ @@ -517,6 +519,22 @@ export async function pickAndImportFolder( headers: { "Content-Type": "application/json", [DESKTOP_IMPORT_TOKEN_HEADER]: headerValue, + ...(deps.init?.workspaceContext + ? { + "x-od-workspace-id": deps.init.workspaceContext.workspaceId, + "x-od-workspace-type": deps.init.workspaceContext.workspaceType, + "x-od-workspace-member-id": deps.init.workspaceContext.workspaceMemberId, + "x-od-workspace-role": deps.init.workspaceContext.role, + "x-od-workspace-lifecycle-state": deps.init.workspaceContext.lifecycleState, + "x-od-workspace-member-status": deps.init.workspaceContext.memberStatus, + "x-od-workspace-can-share-projects": String( + deps.init.workspaceContext.permissions.canShareProjects, + ), + "x-od-workspace-can-write-synced-files": String( + deps.init.workspaceContext.permissions.canWriteSyncedFiles, + ), + } + : {}), }, method: "POST", }); @@ -728,19 +746,48 @@ const MAC_WINDOW_CHROME = process.platform === "darwin" ? ({ titleBarStyle: "hiddenInset" as const, - trafficLightPosition: { x: 12, y: 10 }, + // y centers the 12px traffic-light circles on the tab strip's midline. + // The base `.workspace-tabs-chrome.app-chrome-header` rule in apps/web + // shell.css says 44px, but every real window wraps the tab bar in + // `.workspace-shell` (see App.tsx), and `.workspace-shell + // .workspace-tabs-chrome.app-chrome-header` in viewer/routines.css + // overrides it to 52px (10px above the tab + 32px tab + 10px below) — + // confirmed via getBoundingClientRect() against a live desktop window, + // not by reading the CSS alone, since that 44px rule reads as "the" + // rule until you check what actually wins. Midline is 52 / 2 = 26, so + // the circles' top edge is 26 - 6 = 20. A prior pass "corrected" this + // to y: 16 off the un-overridden 44px rule, which is what actually + // reintroduced the misalignment — don't repeat that without first + // measuring the live header height. + trafficLightPosition: { x: 12, y: 20 }, + // Frosted-glass window: the desktop wallpaper blurs through the whole + // window (NSVisualEffectView). The web shell keeps html/body + // transparent in desktop mode (see apps/web app-wash.css) so the + // vibrancy is actually visible; 'active' keeps the blur when the + // window loses focus instead of flattening to gray. + vibrancy: "under-window" as const, + visualEffectState: "active" as const, + backgroundColor: "#00000000", }) : {}; const MAC_WINDOW_CHROME_CSS = ` .app-chrome-header { - --app-chrome-traffic-space: 96px !important; - --app-chrome-traffic-margin: 12px !important; + /* Windowed: the home pill sits 4px after the traffic lights (lights span + x:12 + 52px = 64px). Fullscreen (class synced from main below): the + lights are hidden, so the pill left-aligns with the nav-rail card's + 10px inset instead. */ + --app-chrome-traffic-space: 64px !important; + --app-chrome-traffic-margin: 4px !important; -webkit-app-region: drag; } + html.is-window-fullscreen .app-chrome-header { + --app-chrome-traffic-space: 10px !important; + --app-chrome-traffic-margin: 0px !important; + } .app-chrome-traffic-space { - flex: 0 0 96px !important; - width: 96px !important; + flex: 0 0 var(--app-chrome-traffic-space) !important; + width: var(--app-chrome-traffic-space) !important; } .app-chrome-header button, .app-chrome-header a, @@ -1015,6 +1062,11 @@ interface RendererCrashScreenContext { const CRASH_REPORT_ISSUES_URL = "https://github.com/nexu-io/open-design/issues/new"; const SUPPORT_EMAIL = "support@open-design.ai"; +// Every address the app is allowed to hand to the OS mail client. Keep this in +// sync with the renderer's own contact affordances (`CONTACT_EMAIL_URL` in +// `apps/web/src/components/EntryNavRail.tsx`); an address that is not listed +// here silently does nothing when clicked in the packaged shell. +const FIRST_PARTY_EMAILS = new Set([SUPPORT_EMAIL, "contact@open.design"]); // Narrow allowlist for the crash screen's "Email us" action: only a mailto // addressed to our own support address, carrying nothing but the crash-screen's @@ -1026,10 +1078,24 @@ const SUPPORT_EMAIL = "support@open-design.ai"; // renderer could otherwise launch the mail client with arbitrary recipients, so // reject any `to`/`cc`/`bcc`/unknown query key. export function isSupportMailtoUrl(url: string): boolean { + return isMailtoUrlAddressedTo(url, (address) => address === SUPPORT_EMAIL); +} + +// Same allowlist discipline as `isSupportMailtoUrl`, widened to every address +// this app owns. A `mailto:` the user clicks in the UI never reaches the OS on +// its own: Electron raises `will-navigate` for it, and a handler that only +// recognises http(s) leaves the navigation to be dropped, so the click reads as +// dead. Routing first-party mailtos through `shell.openExternal` is what +// actually opens the mail client. +export function isFirstPartyMailtoUrl(url: string): boolean { + return isMailtoUrlAddressedTo(url, (address) => FIRST_PARTY_EMAILS.has(address)); +} + +function isMailtoUrlAddressedTo(url: string, allow: (address: string) => boolean): boolean { try { const parsed = new URL(url); if (parsed.protocol !== "mailto:") return false; - if (parsed.pathname.toLowerCase() !== SUPPORT_EMAIL) return false; + if (!allow(parsed.pathname.toLowerCase())) return false; for (const [key, value] of parsed.searchParams) { if (key !== "subject" && key !== "body") return false; // Reject a decoded CR/LF in the value: `subject=ok%0D%0ABcc:attacker@…` @@ -1402,6 +1468,22 @@ export type SplashWindowHandle = { window: BrowserWindow; }; +/** + * Pin Electron's native appearance to light. + * + * The app has one theme now, so `themeSource` is not a preference to sync — it + * is a constant. Leaving it at Electron's `system` default lets a dark-mode OS + * colour everything the web layer does not own: the macOS vibrancy glass + * (`vibrancy: "under-window"`), native menus and dialogs, and the renderer's + * own `prefers-color-scheme` before `data-theme` is stamped. + * + * Idempotent, so both the splash path and the `od:appearance:set-theme` handler + * can call it. + */ +export function pinNativeAppearanceToLight(): void { + nativeTheme.themeSource = "light"; +} + /** * Create and immediately show the light brand-splash window. The packaged entry * calls this BEFORE awaiting the daemon/web sidecars so the animation masks the @@ -1411,6 +1493,12 @@ export type SplashWindowHandle = { * + matching size so the reveal swap reads as a single window, never a flash. */ export function createSplashWindow(): SplashWindowHandle { + // Open Design ships light-only (the theme setting was removed), so pin the + // native appearance before the first window exists. Electron defaults + // `themeSource` to `system`, which paints the macOS vibrancy glass and the + // native chrome dark on a dark-mode Mac — visible on the splash and again in + // the gap before the renderer's `od:appearance:set-theme` lands. + pinNativeAppearanceToLight(); // Stamp creation time at the instant the window appears (see SplashWindowHandle). const startedAt = Date.now(); const splash = new BrowserWindow({ @@ -1544,7 +1632,25 @@ function installWindowChromeCssHook(window: BrowserWindow): void { void applyWindowChromeCss(window).catch((error: unknown) => { console.error("desktop window chrome CSS injection failed", error); }); + void syncWindowFullscreenClass(window); }); + window.on("enter-full-screen", () => void syncWindowFullscreenClass(window)); + window.on("leave-full-screen", () => void syncWindowFullscreenClass(window)); +} + +/** Mirrors the macOS fullscreen state onto so the injected window + * chrome CSS can reposition the tab-strip home pill (the traffic lights + * disappear in fullscreen). */ +async function syncWindowFullscreenClass(window: BrowserWindow): Promise { + if (process.platform !== "darwin" || window.isDestroyed()) return; + const flag = window.isFullScreen(); + try { + await window.webContents.executeJavaScript( + `document.documentElement.classList.toggle('is-window-fullscreen', ${flag ? "true" : "false"});`, + ); + } catch (error: unknown) { + console.error("desktop fullscreen class sync failed", error); + } } function desktopPetUrl(baseUrl: string): string { @@ -1911,7 +2017,8 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom ipcMain.handle("shell:open-external", async (_event, url: string) => { // http(s) as before, plus a mailto strictly to our support address (the // crash screen's "Email us"); no other scheme opens. - if (!isHttpUrl(url) && !isSupportMailtoUrl(url)) return false; + if (isSupportMailtoUrl(url)) return openFirstPartyMailto(url); + if (!isHttpUrl(url)) return false; try { await shell.openExternal(url); return true; @@ -1935,7 +2042,7 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom // import boundary while leaving web-only deployments untouched. ipcMain.handle( "dialog:pick-and-import", - async (event, init?: { name?: string; skillId?: string | null; designSystemId?: string | null }) => { + async (event, init?: OpenDesignHostProjectImportInit) => { // Defensive failsafe for non-production runtimes (test harnesses // that construct createDesktopRuntime without a secret). Round-5 // production wiring in runDesktopMain ALWAYS passes the per-process @@ -2429,6 +2536,20 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom else petWindow.hide(); }); + ipcMain.removeAllListeners("od:appearance:set-theme"); + ipcMain.on("od:appearance:set-theme", (event, theme: unknown) => { + if (window.isDestroyed() || event.sender !== window.webContents) return; + if (theme !== "light" && theme !== "dark" && theme !== "system") return; + // Pin the native appearance to the app theme. The macOS frosted window + // (vibrancy: under-window) draws its glass in the SYSTEM appearance by + // default, so a light app over a dark OS sat on dark glass and read as a + // muddy gray (#94); forcing the native theme keeps the glass material in + // step with the app's tokens. The host protocol still carries all three + // values as generic infrastructure, but the app ships light-only, so this + // is the same value `pinNativeAppearanceToLight` already set at startup. + nativeTheme.themeSource = theme; + }); + ipcMain.removeHandler('od:print-pdf'); ipcMain.handle('od:print-pdf', async (_event, html: unknown, nonce: unknown, options: unknown): Promise => { if (typeof html !== 'string') { @@ -2477,10 +2598,22 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom window.webContents.setWindowOpenHandler(({ url }) => { if (isAllowedChildWindowUrl(url)) return { action: "allow" }; if (isHttpUrl(url)) void shell.openExternal(url); + else if (isFirstPartyMailtoUrl(url)) void openFirstPartyMailto(url); return { action: "deny" }; }); window.webContents.on("will-navigate", (event, url) => { + // A `mailto:` never belongs in this window. Hand it to the local mail + // client and cancel the navigation, otherwise Electron drops it and the + // user sees the page sit there unchanged. `openFirstPartyMailto` also + // covers the machine whose OS-level mailto handler is a web browser — + // recvpZzUroEPUT: `shell.openExternal(mailto:)` there just focuses the + // browser on its current page and no compose window ever opens. + if (isFirstPartyMailtoUrl(url)) { + event.preventDefault(); + void openFirstPartyMailto(url); + return; + } if (!isHttpUrl(url) || url === currentUrl) return; const currentOrigin = currentUrl ? new URL(currentUrl).origin : null; const nextOrigin = new URL(url).origin; @@ -2803,6 +2936,7 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom } unsubscribeUpdater(); ipcMain.removeAllListeners("desktop-pet:set-visible"); + ipcMain.removeAllListeners("od:appearance:set-theme"); for (const channel of UPDATER_IPC_CHANNELS) { ipcMain.removeHandler(channel); } diff --git a/apps/desktop/tests/main/amr-environment-profile-menu.test.ts b/apps/desktop/tests/main/amr-environment-profile-menu.test.ts index ac10e0b766a..aee969170c2 100644 --- a/apps/desktop/tests/main/amr-environment-profile-menu.test.ts +++ b/apps/desktop/tests/main/amr-environment-profile-menu.test.ts @@ -13,6 +13,7 @@ describe("AMR Environment Profile desktop menu helpers", () => { expect(normalizeAmrEnvironmentProfile("")).toBe("prod"); expect(normalizeAmrEnvironmentProfile("staging")).toBe("prod"); expect(normalizeAmrEnvironmentProfile("local")).toBe("local"); + expect(normalizeAmrEnvironmentProfile("feature-test")).toBe("feature-test"); expect(normalizeAmrEnvironmentProfile("test")).toBe("test"); expect(normalizeAmrEnvironmentProfile("prod")).toBe("prod"); }); @@ -67,10 +68,10 @@ describe("AMR Environment Profile desktop menu helpers", () => { }); it("creates the AMR env section when the existing config has no agentCliEnv", () => { - expect(mergeAmrEnvironmentProfileConfig({}, "test")).toEqual({ + expect(mergeAmrEnvironmentProfileConfig({}, "feature-test")).toEqual({ agentCliEnv: { amr: { - OPEN_DESIGN_AMR_PROFILE: "test", + OPEN_DESIGN_AMR_PROFILE: "feature-test", }, }, }); @@ -144,7 +145,7 @@ describe("AMR Environment Profile desktop menu helpers", () => { }); }); - it("builds radio menu items for prod, test, and local", () => { + it("builds radio menu items for prod, test, feature-test, and local", () => { const selected: string[] = []; const [profileMenu] = createAmrEnvironmentProfileMenuItems("test", (profile) => { selected.push(profile); @@ -154,6 +155,7 @@ describe("AMR Environment Profile desktop menu helpers", () => { expect(profileMenu.submenu).toEqual([ expect.objectContaining({ label: "prod", type: "radio", checked: false }), expect.objectContaining({ label: "test", type: "radio", checked: true }), + expect.objectContaining({ label: "feature-test", type: "radio", checked: false }), expect.objectContaining({ label: "local", type: "radio", checked: false }), ]); diff --git a/apps/desktop/tests/main/invite-deeplink.test.ts b/apps/desktop/tests/main/invite-deeplink.test.ts new file mode 100644 index 00000000000..e8cc5a18781 --- /dev/null +++ b/apps/desktop/tests/main/invite-deeplink.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from "vitest"; +import { + continueInviteFromUrl, + createInviteDeeplinkDispatcher, + findDeeplinkArg, +} from "../../src/main/invite-deeplink-core.js"; + +const VALID = + "opendesign://workspace/invite/continue?workspace_id=ws-1&member_id=wm-1&invite_id=inv-1&nonce=n-1"; + +function jsonResponse(status: number, body: unknown): Response { + return { ok: status >= 200 && status < 300, status, json: async () => body } as unknown as Response; +} + +describe("findDeeplinkArg", () => { + it("finds the opendesign url in an argv list", () => { + expect(findDeeplinkArg(["/path/to/app", VALID])).toBe(VALID); + expect(findDeeplinkArg(["/path/to/app", "--some-flag"])).toBeNull(); + }); +}); + +describe("continueInviteFromUrl", () => { + it("POSTs the nonce to the daemon and focuses on success", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse(200, { context: { workspaceMemberId: "wm-1" } }), + ) as unknown as typeof fetch; + const focus = vi.fn(); + const onActivated = vi.fn(); + const out = await continueInviteFromUrl(VALID, { + resolveDaemonBaseUrl: async () => "http://127.0.0.1:17456", + fetch: fetchImpl, + focus, + onActivated, + }); + expect(out).toEqual({ ok: true }); + const [url, init] = (fetchImpl as unknown as ReturnType).mock.calls[0]!; + expect(String(url)).toBe("http://127.0.0.1:17456/api/workspace/invite/continue"); + expect((init as RequestInit).method).toBe("POST"); + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ nonce: "n-1" }); + expect(focus).toHaveBeenCalledTimes(1); + expect(onActivated).toHaveBeenCalledWith({ workspaceMemberId: "wm-1" }); + }); + + it("ignores a url that is not an invite deeplink (no daemon call)", async () => { + const fetchImpl = vi.fn() as unknown as typeof fetch; + const out = await continueInviteFromUrl("opendesign://something/else", { + resolveDaemonBaseUrl: async () => "http://x", + fetch: fetchImpl, + }); + expect(out).toEqual({ ok: false, reason: "not_an_invite_deeplink" }); + expect((fetchImpl as unknown as ReturnType).mock.calls.length).toBe(0); + }); + + it("reports daemon_unavailable when the base url rejects", async () => { + const out = await continueInviteFromUrl(VALID, { + resolveDaemonBaseUrl: async () => { + throw new Error("daemon URL is unavailable"); + }, + }); + expect(out).toEqual({ ok: false, reason: "daemon_unavailable" }); + }); + + it("reports consume_failed on a non-ok daemon response and unreachable on a throw", async () => { + const failed = await continueInviteFromUrl(VALID, { + resolveDaemonBaseUrl: async () => "http://x", + fetch: (async () => jsonResponse(409, { error: "continuation_409" })) as unknown as typeof fetch, + }); + expect(failed).toEqual({ ok: false, reason: "consume_failed", status: 409 }); + + const broken = await continueInviteFromUrl(VALID, { + resolveDaemonBaseUrl: async () => "http://x", + fetch: (async () => { + throw new Error("down"); + }) as unknown as typeof fetch, + }); + expect(broken).toEqual({ ok: false, reason: "unreachable" }); + }); +}); + +describe("createInviteDeeplinkDispatcher", () => { + it("queues cold-start deeplinks until daemon deps are registered", () => { + const continueInvite = vi.fn(async () => ({ ok: true })); + const dispatcher = createInviteDeeplinkDispatcher(continueInvite); + const deps = { + resolveDaemonBaseUrl: async () => "http://127.0.0.1:17456", + }; + + dispatcher.dispatch(VALID); + + expect(dispatcher.pendingCount()).toBe(1); + expect(continueInvite).not.toHaveBeenCalled(); + + dispatcher.setDeps(deps); + + expect(dispatcher.pendingCount()).toBe(0); + expect(continueInvite).toHaveBeenCalledWith(VALID, deps); + }); +}); diff --git a/apps/desktop/tests/main/mailto-open.test.ts b/apps/desktop/tests/main/mailto-open.test.ts new file mode 100644 index 00000000000..2d91ed63de9 --- /dev/null +++ b/apps/desktop/tests/main/mailto-open.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, test } from "vitest"; + +import { + extractDefaultMailtoHandlerBundleId, + isBrowserBundleId, + openFirstPartyMailto, + readDefaultMailtoHandlerBundleId, + resolveMailtoLaunch, +} from "../../src/main/mailto-open.js"; + +// Shape produced by: +// `defaults read com.apple.LaunchServices/com.apple.launchservices.secure LSHandlers` +// on a machine whose "default email reader" was switched to Chrome. The nested +// LSHandlerPreferredVersions dict carries its own LSHandlerRoleAll = "-" that +// a depth-blind scan would return instead of the real bundle id. +const LS_HANDLERS_CHROME_MAILTO = `( + { + LSHandlerContentType = "public.html"; + LSHandlerPreferredVersions = { + LSHandlerRoleAll = "-"; + }; + LSHandlerRoleAll = "com.apple.safari"; + }, + { + LSHandlerPreferredVersions = { + LSHandlerRoleAll = "-"; + }; + LSHandlerRoleAll = "com.google.chrome"; + LSHandlerURLScheme = mailto; + }, + { + LSHandlerPreferredVersions = { + LSHandlerRoleAll = "-"; + }; + LSHandlerRoleAll = "com.google.chrome"; + LSHandlerURLScheme = https; + } +)`; + +const LS_HANDLERS_MAIL_APP_MAILTO = `( + { + LSHandlerPreferredVersions = { + LSHandlerRoleAll = "-"; + }; + LSHandlerRoleAll = "com.readdle.smartemail-Mac"; + LSHandlerURLScheme = "mailto"; + } +)`; + +const LS_HANDLERS_EDGE_MAILTO = `( + { + LSHandlerContentType = "com.apple.default-app.mail-client"; + LSHandlerRoleAll = "com.microsoft.edgemac"; + }, + { + LSHandlerRoleAll = "com.microsoft.edgemac"; + LSHandlerURLScheme = mailto; + } +)`; + +describe("extractDefaultMailtoHandlerBundleId", () => { + test("returns the mailto entry's own bundle id, not the nested placeholder", () => { + expect(extractDefaultMailtoHandlerBundleId(LS_HANDLERS_CHROME_MAILTO)).toBe( + "com.google.chrome", + ); + }); + + test("handles quoted scheme values and mixed-case bundle ids", () => { + expect(extractDefaultMailtoHandlerBundleId(LS_HANDLERS_MAIL_APP_MAILTO)).toBe( + "com.readdle.smartemail-mac", + ); + }); + + test("returns null when no mailto entry exists", () => { + const text = `( + { + LSHandlerRoleAll = "com.google.chrome"; + LSHandlerURLScheme = https; + } +)`; + expect(extractDefaultMailtoHandlerBundleId(text)).toBeNull(); + }); + + test("returns null for empty or non-plist text", () => { + expect(extractDefaultMailtoHandlerBundleId("")).toBeNull(); + expect(extractDefaultMailtoHandlerBundleId("not a plist")).toBeNull(); + }); + + test("returns null when the mailto entry has a placeholder role", () => { + const text = `( + { + LSHandlerRoleAll = "-"; + LSHandlerURLScheme = mailto; + } +)`; + expect(extractDefaultMailtoHandlerBundleId(text)).toBeNull(); + }); +}); + +describe("isBrowserBundleId", () => { + test.each([ + "com.google.chrome", + "com.google.Chrome.beta", + "com.apple.Safari", + "com.microsoft.edgemac.Beta", + "org.mozilla.firefox", + "com.brave.Browser", + "company.thebrowser.Browser", + "com.duckduckgo.macos.browser", + ])("classifies %s as a browser", (id) => { + expect(isBrowserBundleId(id)).toBe(true); + }); + + test.each([ + "com.apple.mail", + "com.microsoft.Outlook", + "com.readdle.smartemail-Mac", + "org.airmailapp.airmail", + "it.bloop.airmail2", + "", + ])("does not classify %s as a browser", (id) => { + expect(isBrowserBundleId(id)).toBe(false); + }); +}); + +describe("resolveMailtoLaunch", () => { + test("no override means the system default (Apple Mail) is fine", () => { + expect(resolveMailtoLaunch(null)).toBe("system-default"); + }); + + test("a real mail client override is respected", () => { + expect(resolveMailtoLaunch("com.microsoft.outlook")).toBe("system-default"); + }); + + test("a browser override forces Apple Mail", () => { + expect(resolveMailtoLaunch("com.google.chrome")).toBe("apple-mail"); + }); +}); + +describe("readDefaultMailtoHandlerBundleId", () => { + test("reads the real macOS LaunchServices domain and detects an Edge mailto handler", async () => { + const calls: Array<{ file: string; args: string[] }> = []; + const result = await readDefaultMailtoHandlerBundleId(async (file, args) => { + calls.push({ file, args }); + return { + stdout: LS_HANDLERS_EDGE_MAILTO, + stderr: "", + }; + }); + + expect(calls).toEqual([{ + file: "defaults", + args: [ + "read", + "com.apple.LaunchServices/com.apple.launchservices.secure", + "LSHandlers", + ], + }]); + expect(result).toBe("com.microsoft.edgemac"); + expect(resolveMailtoLaunch(result)).toBe("apple-mail"); + }); + + test("returns null when the read fails (no overrides recorded)", async () => { + const result = await readDefaultMailtoHandlerBundleId(async () => { + throw new Error("The domain/default pair does not exist"); + }); + expect(result).toBeNull(); + }); +}); + +const expectedRealMailtoHandler = process.env.OD_EXPECT_REAL_MAILTO_HANDLER; +const realMacLaunchServicesTest = + process.platform === "darwin" && expectedRealMailtoHandler ? test : test.skip; + +realMacLaunchServicesTest( + "reads this machine's real mailto handler through the macOS LaunchServices domain", + async () => { + const handler = await readDefaultMailtoHandlerBundleId(); + expect(handler).toBe(expectedRealMailtoHandler); + expect(resolveMailtoLaunch(handler)).toBe("apple-mail"); + }, +); + +describe("openFirstPartyMailto", () => { + const MAILTO = "mailto:support@open-design.ai"; + + test("refuses anything that is not a mailto", async () => { + const calls: string[] = []; + const opened = await openFirstPartyMailto("https://open-design.ai", { + platform: "darwin", + readHandlerBundleId: async () => null, + openWithAppleMail: async (url) => void calls.push(`mail:${url}`), + openExternal: async (url) => void calls.push(`external:${url}`), + }); + expect(opened).toBe(false); + expect(calls).toEqual([]); + }); + + test("uses the OS default when no browser owns mailto", async () => { + const calls: string[] = []; + const opened = await openFirstPartyMailto(MAILTO, { + platform: "darwin", + readHandlerBundleId: async () => null, + openWithAppleMail: async (url) => void calls.push(`mail:${url}`), + openExternal: async (url) => void calls.push(`external:${url}`), + }); + expect(opened).toBe(true); + expect(calls).toEqual([`external:${MAILTO}`]); + }); + + test("routes to Apple Mail when a browser owns the mailto scheme", async () => { + const calls: string[] = []; + const opened = await openFirstPartyMailto(MAILTO, { + platform: "darwin", + readHandlerBundleId: async () => "com.google.chrome", + openWithAppleMail: async (url) => void calls.push(`mail:${url}`), + openExternal: async (url) => void calls.push(`external:${url}`), + }); + expect(opened).toBe(true); + expect(calls).toEqual([`mail:${MAILTO}`]); + }); + + test("falls back to the OS default when Apple Mail fails to launch", async () => { + const calls: string[] = []; + const opened = await openFirstPartyMailto(MAILTO, { + platform: "darwin", + readHandlerBundleId: async () => "com.google.chrome", + openWithAppleMail: async () => { + throw new Error("Unable to find application"); + }, + openExternal: async (url) => void calls.push(`external:${url}`), + }); + expect(opened).toBe(true); + expect(calls).toEqual([`external:${MAILTO}`]); + }); + + test("keeps plain openExternal on non-mac platforms", async () => { + const calls: string[] = []; + let lookedUp = false; + const opened = await openFirstPartyMailto(MAILTO, { + platform: "win32", + readHandlerBundleId: async () => { + lookedUp = true; + return "com.google.chrome"; + }, + openWithAppleMail: async (url) => void calls.push(`mail:${url}`), + openExternal: async (url) => void calls.push(`external:${url}`), + }); + expect(opened).toBe(true); + expect(lookedUp).toBe(false); + expect(calls).toEqual([`external:${MAILTO}`]); + }); + + test("reports failure when even openExternal throws", async () => { + const opened = await openFirstPartyMailto(MAILTO, { + platform: "linux", + readHandlerBundleId: async () => null, + openWithAppleMail: async () => {}, + openExternal: async () => { + throw new Error("no handler"); + }, + }); + expect(opened).toBe(false); + }); +}); diff --git a/apps/desktop/tests/main/pick-and-import-workspace-context.test.ts b/apps/desktop/tests/main/pick-and-import-workspace-context.test.ts new file mode 100644 index 00000000000..6042fce19b6 --- /dev/null +++ b/apps/desktop/tests/main/pick-and-import-workspace-context.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest'; +import { pickAndImportFolder } from '../../src/main/runtime.js'; + +describe('pickAndImportFolder workspace authority', () => { + it('forwards the exact renderer-selected workspace/member as daemon headers', async () => { + const fetchImpl = vi.fn(async () => new Response( + JSON.stringify({ + project: { id: 'project-imported' }, + conversationId: 'conversation-imported', + entryFile: 'index.html', + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + )); + + // A faithful renderer-side context, wider than the subset the host bridge + // models. Kept in a variable rather than inlined so it is checked for + // structural compatibility instead of exact-shape excess properties — + // carrying the extra fields is exactly what real callers do. + const workspaceContext = { + workspaceId: 'workspace-desktop', + workspaceType: 'team', + workspaceMemberId: 'member-desktop', + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + billingState: 'active', + planId: 'team', + providerMode: 'platform_credits', + seatSummary: { + seatLimit: 5, + usedSeats: 2, + availableSeats: 3, + isSeatFull: false, + }, + permissions: { + canManageMembers: false, + canManageBilling: false, + canInviteMembers: false, + canManageAutoRecharge: false, + canViewWorkspaceSettings: true, + canManageSharedResources: false, + canShareProjects: true, + canWriteSyncedFiles: true, + }, + }; + + const result = await pickAndImportFolder({ + apiBaseUrl: 'http://127.0.0.1:17591', + baseDir: '/tmp/workspace-folder', + desktopAuthSecret: Buffer.alloc(32, 1), + fetchImpl, + mintToken: () => 'desktop-import-token', + init: { + skillId: 'prototype-skill', + workspaceContext, + }, + }); + + expect(result.ok).toBe(true); + const [, init] = fetchImpl.mock.calls[0]!; + expect(init?.headers).toMatchObject({ + 'Content-Type': 'application/json', + 'x-od-desktop-import-token': 'desktop-import-token', + 'x-od-workspace-id': 'workspace-desktop', + 'x-od-workspace-member-id': 'member-desktop', + 'x-od-workspace-type': 'team', + }); + expect(JSON.parse(String(init?.body))).toEqual({ + baseDir: '/tmp/workspace-folder', + skillId: 'prototype-skill', + }); + }); +}); diff --git a/apps/desktop/tests/main/renderer-crash-loop.test.ts b/apps/desktop/tests/main/renderer-crash-loop.test.ts index e7dda5ac8ba..714c5dd668e 100644 --- a/apps/desktop/tests/main/renderer-crash-loop.test.ts +++ b/apps/desktop/tests/main/renderer-crash-loop.test.ts @@ -8,7 +8,7 @@ import { RENDERER_CRASH_LOOP_WINDOW_MS, RendererCrashLoopBreaker, } from "../../src/main/renderer-crash-loop.js"; -import { isSupportMailtoUrl } from "../../src/main/runtime.js"; +import { isFirstPartyMailtoUrl, isSupportMailtoUrl } from "../../src/main/runtime.js"; describe("RendererCrashLoopBreaker", () => { test("stays closed while crashes are below the limit inside the window", () => { @@ -180,3 +180,46 @@ describe("isSupportMailtoUrl", () => { expect(isSupportMailtoUrl("mailto:support@open-design.ai?body=line1%0Aline2")).toBe(false); }); }); + +describe("isFirstPartyMailtoUrl", () => { + test("covers every address the app itself offers to email", () => { + // The account menu's mail badge (`CONTACT_EMAIL_URL` in EntryNavRail.tsx). + // Before this predicate existed, `will-navigate` only recognised http(s), + // so clicking that badge in the packaged shell did nothing at all. + expect(isFirstPartyMailtoUrl("mailto:contact@open.design")).toBe(true); + expect(isFirstPartyMailtoUrl("mailto:contact@open.design?subject=Hi&body=there")).toBe(true); + expect(isFirstPartyMailtoUrl("mailto:Contact@Open.Design")).toBe(true); + // The crash screen's support address stays covered. + expect(isFirstPartyMailtoUrl("mailto:support@open-design.ai")).toBe(true); + }); + + test("keeps the support predicate narrow so the open-external bridge does not widen", () => { + // `shell:open-external` is renderer-reachable and still gates on the + // support address only; the wider allowlist is for navigation, not IPC. + expect(isSupportMailtoUrl("mailto:contact@open.design")).toBe(false); + }); + + test("applies the same recipient/header hardening as the support predicate", () => { + expect(isFirstPartyMailtoUrl("mailto:attacker@evil.com")).toBe(false); + expect(isFirstPartyMailtoUrl("mailto:contact@open.design?bcc=attacker@example.com")).toBe(false); + expect(isFirstPartyMailtoUrl("mailto:contact@open.design?to=attacker@example.com")).toBe(false); + expect(isFirstPartyMailtoUrl("mailto:contact@open.design?whatever=1")).toBe(false); + expect( + isFirstPartyMailtoUrl("mailto:contact@open.design?subject=ok%0D%0ABcc:attacker@example.com"), + ).toBe(false); + expect(isFirstPartyMailtoUrl("https://open-design.ai")).toBe(false); + expect(isFirstPartyMailtoUrl("javascript:alert(1)")).toBe(false); + expect(isFirstPartyMailtoUrl("not a url")).toBe(false); + }); + + test("the main window routes a mailto navigation to the OS instead of dropping it", () => { + // Guards the wiring, not just the predicate: `will-navigate` must cancel + // the navigation and hand the URL to `shell.openExternal`. + const runtimeSource = readFileSync(new URL("../../src/main/runtime.ts", import.meta.url), "utf8"); + const willNavigate = runtimeSource.slice(runtimeSource.indexOf('window.webContents.on("will-navigate"')); + expect(willNavigate).toContain("isFirstPartyMailtoUrl(url)"); + expect(willNavigate.indexOf("isFirstPartyMailtoUrl(url)")).toBeLessThan( + willNavigate.indexOf("if (!isHttpUrl(url)"), + ); + }); +}); diff --git a/apps/desktop/tests/main/window-chrome.test.ts b/apps/desktop/tests/main/window-chrome.test.ts index ffa21fb3fd2..d4417ea02c1 100644 --- a/apps/desktop/tests/main/window-chrome.test.ts +++ b/apps/desktop/tests/main/window-chrome.test.ts @@ -30,10 +30,20 @@ describe("desktop BrowserWindow chrome options", () => { }); test("keeps macOS traffic-light controls clear of the web tab strip", () => { - expect(runtimeSource).toContain("--app-chrome-traffic-space: 96px !important;"); - expect(runtimeSource).toContain("--app-chrome-traffic-margin: 12px !important;"); - expect(runtimeSource).toContain("flex: 0 0 96px !important;"); - expect(runtimeSource).toContain("width: 96px !important;"); + // Windowed: home pill 4px after the lights (12px inset + 52px span). + expect(runtimeSource).toContain("--app-chrome-traffic-space: 64px !important;"); + expect(runtimeSource).toContain("--app-chrome-traffic-margin: 4px !important;"); + // Fullscreen: lights hidden; the pill left-aligns with the nav-rail card. + expect(runtimeSource).toContain("html.is-window-fullscreen .app-chrome-header"); + expect(runtimeSource).toContain("--app-chrome-traffic-space: 10px !important;"); + expect(runtimeSource).toContain("flex: 0 0 var(--app-chrome-traffic-space) !important;"); + expect(runtimeSource).toContain("width: var(--app-chrome-traffic-space) !important;"); + }); + + test("mirrors macOS fullscreen state onto the renderer for chrome CSS", () => { + expect(runtimeSource).toContain('window.on("enter-full-screen", () => void syncWindowFullscreenClass(window));'); + expect(runtimeSource).toContain('window.on("leave-full-screen", () => void syncWindowFullscreenClass(window));'); + expect(runtimeSource).toContain("is-window-fullscreen"); }); test("keeps the visible renderer responsive when Chromium misclassifies visibility", () => { diff --git a/apps/landing-page/app/_lib/pricing-content.ts b/apps/landing-page/app/_lib/pricing-content.ts index 442cc953758..c601366189a 100644 --- a/apps/landing-page/app/_lib/pricing-content.ts +++ b/apps/landing-page/app/_lib/pricing-content.ts @@ -118,6 +118,7 @@ export const PREMIUM_MODELS: readonly PricingModel[] = [ { name: 'Claude-Fable-5' }, { name: 'Claude-Opus-4.8' }, { name: 'Claude-Opus-4.7' }, + { name: 'GPT-5.6 (Sol/Terra/Luna)' }, { name: 'GPT-5.5-Pro' }, { name: 'GPT-5.5' }, { name: 'Gemini-3.1-Pro' }, @@ -134,9 +135,10 @@ export const STANDARD_MODELS = [ ] as const; /** - * Limited-time credit bonus over the base grant, surfaced as a badge next to - * the credit amount to pull users up (Pro +20%, Max +50%). `null` = no bonus. - * The displayed credit is `grantUsd × (1 + pct/100)` — e.g. Pro $100 → $120. + * Limited-time credit bonus represented by the current grant itself and + * surfaced as a badge next to the amount (Pro $120 / +20%, Max $300 / +50%). + * `grantUsd` is already the final advertised grant, so consumers must not + * apply this percentage to it a second time. `null` = no bonus badge. */ export const CREDIT_BONUS_PCT: Record = { plus: null, diff --git a/apps/landing-page/app/_lib/pricing-team-content.ts b/apps/landing-page/app/_lib/pricing-team-content.ts new file mode 100644 index 00000000000..31fc4f514eb --- /dev/null +++ b/apps/landing-page/app/_lib/pricing-team-content.ts @@ -0,0 +1,557 @@ +import type { LandingLocaleCode } from '../i18n'; + +export const PRICING_LOCALES = [ + 'en', + 'zh', + 'ja', + 'ko', + 'de', + 'fr', + 'ru', + 'es', + 'pt-br', + 'it', + 'tr', +] as const; + +export type PricingLocale = (typeof PRICING_LOCALES)[number]; + +export interface TeamPricingContent { + metaTitle: string; + metaDescription: string; + breadcrumbLabel: string; + audienceTabsLabel: string; + creatorTab: string; + teamTab: string; + billingIntervalLabel: string; + teamTitle: string; + teamTagline: string; + recommended: string; + creditLabel: string; + seatOnly: string; + creditUnit: string; + seats: string; + decreaseSeats: string; + increaseSeats: string; + minSeatsNote: string; + perSeatMonth: string; + monthlyTotal: string; + yearlyTotal: string; + checkout: string; + teamFeatures: string[]; + enterpriseTitle: string; + enterpriseTagline: string; + enterpriseCta: string; + enterpriseFeatures: string[]; +} + +const EN: TeamPricingContent = { + metaTitle: 'Pricing — Open Design', + metaDescription: + 'Compare Open Design Creator and Team plans, including model credits, per-seat collaboration, annual savings, and Enterprise support.', + breadcrumbLabel: 'Pricing', + audienceTabsLabel: 'Plan audience', + creatorTab: 'Creator', + teamTab: 'Team', + billingIntervalLabel: 'Billing interval', + teamTitle: 'Team', + teamTagline: 'Built for design teams · Per-seat collaboration', + recommended: 'Recommended', + creditLabel: 'Monthly model credits per seat', + seatOnly: 'Seats only, no model credits', + creditUnit: 'model credits', + seats: 'Seats', + decreaseSeats: 'Decrease seats', + increaseSeats: 'Increase seats', + minSeatsNote: 'Team plans require at least {count} seats.', + perSeatMonth: '/ seat / month', + monthlyTotal: 'First month only {amount}', + yearlyTotal: 'First year only {amount}', + checkout: 'Upgrade team', + teamFeatures: [ + 'Share and manage projects, design systems, and plugins as a team', + 'Everyone can view and comment; only the project creator can edit', + 'Role-based access: Owner / Admin / Member', + ], + enterpriseTitle: 'Enterprise', + enterpriseTagline: 'Custom support and compliance for larger teams', + enterpriseCta: 'Contact us', + enterpriseFeatures: [ + 'Everything in Max', + 'Shared team design system and brand source of truth', + 'A design system that learns with your team', + 'Real-time collaboration', + 'Multi-user project and artifact editing', + 'Team project and artifact library', + 'Member and permission management', + 'Unified billing and usage dashboard', + 'SSO / SAML and priority support', + ], +}; + +const ZH: TeamPricingContent = { + metaTitle: '价格方案 — Open Design', + metaDescription: + '比较 Open Design 创作会员与团队版方案,了解模型额度、按席位协作、年付优惠和企业支持。', + breadcrumbLabel: '价格方案', + audienceTabsLabel: '方案类型', + creatorTab: '创作会员', + teamTab: '团队版会员', + billingIntervalLabel: '计费周期', + teamTitle: '团队版', + teamTagline: '为设计团队打造 · 按席位协作', + recommended: '推荐', + creditLabel: '每席每月模型额度', + seatOnly: '仅席位,不含模型额度', + creditUnit: '模型额度', + seats: '席位数', + decreaseSeats: '减少席位', + increaseSeats: '增加席位', + minSeatsNote: '团队版最少需要 {count} 个席位。', + perSeatMonth: '/ 席 / 月', + monthlyTotal: '首月仅需 {amount}', + yearlyTotal: '首年仅需 {amount}', + checkout: '升级团队版', + teamFeatures: [ + '项目、设计系统与插件,团队统一共享与管理', + '成员均可查看和评论项目,仅项目创建者可以编辑', + '按角色管理权限:Owner / Admin / Member', + ], + enterpriseTitle: '企业版', + enterpriseTagline: '为大团队与企业定制 · 专属支持与合规', + enterpriseCta: '联系我们', + enterpriseFeatures: [ + '包含 Max 全部功能', + '团队共享设计系统 · 统一品牌事实源', + '设计系统自进化 · 随团队产出持续学习', + '多人实时协同同一项目', + '项目与产物多人共同编辑', + '团队级项目与产物资产库', + '成员与权限管理', + '统一账单与用量仪表盘', + 'SSO / SAML 与优先支持', + ], +}; + +const JA: TeamPricingContent = { + metaTitle: '料金プラン — Open Design', + metaDescription: + 'Open Design のクリエイター向けプランと Team プランを比較。モデルクレジット、席単位の共同作業、年払い割引、Enterprise サポートを確認できます。', + breadcrumbLabel: '料金プラン', + audienceTabsLabel: 'プラン対象', + creatorTab: 'クリエイター', + teamTab: 'Team', + billingIntervalLabel: '請求サイクル', + teamTitle: 'Team', + teamTagline: 'デザインチーム向け · 席単位の共同作業', + recommended: 'おすすめ', + creditLabel: '1 席あたりの月間モデルクレジット', + seatOnly: '席のみ・モデルクレジットなし', + creditUnit: 'モデルクレジット', + seats: '席数', + decreaseSeats: '席数を減らす', + increaseSeats: '席数を増やす', + minSeatsNote: 'Team プランは最低 {count} 席から利用できます。', + perSeatMonth: '/ 席 / 月', + monthlyTotal: '初月は {amount} のみ', + yearlyTotal: '初年度は {amount} のみ', + checkout: 'Team にアップグレード', + teamFeatures: [ + 'プロジェクト、Design Systems、プラグインをチームで共有・管理', + '全員が閲覧とコメント可能。編集はプロジェクト作成者のみ', + 'Owner / Admin / Member のロールベース権限', + ], + enterpriseTitle: 'Enterprise', + enterpriseTagline: '大規模チーム向けの専用サポートとコンプライアンス', + enterpriseCta: 'お問い合わせ', + enterpriseFeatures: [ + 'Max の全機能', + 'チーム共有 Design System とブランドの信頼できる唯一の情報源', + 'チームとともに学習する Design System', + 'リアルタイム共同作業', + '複数ユーザーによるプロジェクトと成果物の編集', + 'チームのプロジェクト・成果物ライブラリ', + 'メンバーと権限の管理', + '統合された請求・使用量ダッシュボード', + 'SSO / SAML と優先サポート', + ], +}; + +const KO: TeamPricingContent = { + metaTitle: '요금제 — Open Design', + metaDescription: + 'Open Design 크리에이터 및 Team 요금제의 모델 크레딧, 좌석 기반 협업, 연간 할인과 Enterprise 지원을 비교하세요.', + breadcrumbLabel: '요금제', + audienceTabsLabel: '요금제 대상', + creatorTab: '크리에이터', + teamTab: 'Team', + billingIntervalLabel: '결제 주기', + teamTitle: 'Team', + teamTagline: '디자인 팀을 위한 좌석 기반 협업', + recommended: '추천', + creditLabel: '좌석당 월간 모델 크레딧', + seatOnly: '좌석만 제공, 모델 크레딧 없음', + creditUnit: '모델 크레딧', + seats: '좌석 수', + decreaseSeats: '좌석 줄이기', + increaseSeats: '좌석 늘리기', + minSeatsNote: 'Team 요금제는 최소 {count}개 좌석이 필요합니다.', + perSeatMonth: '/ 좌석 / 월', + monthlyTotal: '첫 달은 {amount}', + yearlyTotal: '첫해는 {amount}', + checkout: 'Team으로 업그레이드', + teamFeatures: [ + '프로젝트, Design Systems, 플러그인을 팀에서 공유하고 관리', + '모든 구성원이 보고 댓글을 달 수 있으며 편집은 프로젝트 생성자만 가능', + 'Owner / Admin / Member 역할 기반 권한', + ], + enterpriseTitle: 'Enterprise', + enterpriseTagline: '대규모 팀을 위한 맞춤 지원과 컴플라이언스', + enterpriseCta: '문의하기', + enterpriseFeatures: [ + 'Max의 모든 기능', + '팀 공유 Design System과 단일 브랜드 기준', + '팀과 함께 학습하는 Design System', + '실시간 협업', + '여러 사용자의 프로젝트 및 결과물 편집', + '팀 프로젝트 및 결과물 라이브러리', + '구성원과 권한 관리', + '통합 결제 및 사용량 대시보드', + 'SSO / SAML 및 우선 지원', + ], +}; + +const DE: TeamPricingContent = { + metaTitle: 'Preise — Open Design', + metaDescription: + 'Vergleiche Open Design Creator- und Team-Pläne mit Modellguthaben, Zusammenarbeit pro Sitz, Jahresrabatten und Enterprise-Support.', + breadcrumbLabel: 'Preise', + audienceTabsLabel: 'Planzielgruppe', + creatorTab: 'Creator', + teamTab: 'Team', + billingIntervalLabel: 'Abrechnungszeitraum', + teamTitle: 'Team', + teamTagline: 'Für Designteams · Zusammenarbeit pro Sitz', + recommended: 'Empfohlen', + creditLabel: 'Monatliches Modellguthaben pro Sitz', + seatOnly: 'Nur Sitze, kein Modellguthaben', + creditUnit: 'Modellguthaben', + seats: 'Sitze', + decreaseSeats: 'Sitz entfernen', + increaseSeats: 'Sitz hinzufügen', + minSeatsNote: 'Team-Pläne erfordern mindestens {count} Sitze.', + perSeatMonth: '/ Sitz / Monat', + monthlyTotal: 'Im ersten Monat nur {amount}', + yearlyTotal: 'Im ersten Jahr nur {amount}', + checkout: 'Auf Team upgraden', + teamFeatures: [ + 'Projekte, Design Systems und Plugins gemeinsam teilen und verwalten', + 'Alle können ansehen und kommentieren; nur Ersteller bearbeiten ihr Projekt', + 'Rollenbasierter Zugriff: Owner / Admin / Member', + ], + enterpriseTitle: 'Enterprise', + enterpriseTagline: 'Individueller Support und Compliance für größere Teams', + enterpriseCta: 'Kontakt aufnehmen', + enterpriseFeatures: [ + 'Alle Funktionen von Max', + 'Geteiltes Team-Design-System und zentrale Markenquelle', + 'Ein Design System, das mit dem Team lernt', + 'Zusammenarbeit in Echtzeit', + 'Gemeinsame Bearbeitung von Projekten und Ergebnissen', + 'Team-Bibliothek für Projekte und Ergebnisse', + 'Mitglieder- und Rechteverwaltung', + 'Zentrale Abrechnung und Nutzungsübersicht', + 'SSO / SAML und bevorzugter Support', + ], +}; + +const FR: TeamPricingContent = { + metaTitle: 'Tarifs — Open Design', + metaDescription: + 'Comparez les offres Creator et Team d’Open Design : crédits modèles, collaboration par siège, réductions annuelles et support Enterprise.', + breadcrumbLabel: 'Tarifs', + audienceTabsLabel: 'Public de l’offre', + creatorTab: 'Créateur', + teamTab: 'Équipe', + billingIntervalLabel: 'Période de facturation', + teamTitle: 'Équipe', + teamTagline: 'Conçu pour les équipes de design · Collaboration par siège', + recommended: 'Recommandé', + creditLabel: 'Crédits modèles mensuels par siège', + seatOnly: 'Sièges uniquement, sans crédits modèles', + creditUnit: 'crédits modèles', + seats: 'Sièges', + decreaseSeats: 'Retirer un siège', + increaseSeats: 'Ajouter un siège', + minSeatsNote: 'Les offres Team nécessitent au moins {count} sièges.', + perSeatMonth: '/ siège / mois', + monthlyTotal: 'Premier mois à seulement {amount}', + yearlyTotal: 'Première année à seulement {amount}', + checkout: 'Passer à Team', + teamFeatures: [ + 'Partager et gérer en équipe projets, Design Systems et plugins', + 'Tous peuvent consulter et commenter ; seul le créateur du projet le modifie', + 'Accès par rôle : Owner / Admin / Member', + ], + enterpriseTitle: 'Enterprise', + enterpriseTagline: 'Support sur mesure et conformité pour les grandes équipes', + enterpriseCta: 'Nous contacter', + enterpriseFeatures: [ + 'Toutes les fonctions de Max', + 'Design System d’équipe partagé et source de vérité de la marque', + 'Un Design System qui apprend avec votre équipe', + 'Collaboration en temps réel', + 'Édition multiutilisateur des projets et livrables', + 'Bibliothèque d’équipe de projets et livrables', + 'Gestion des membres et des autorisations', + 'Tableau de bord unifié de facturation et d’usage', + 'SSO / SAML et support prioritaire', + ], +}; + +const RU: TeamPricingContent = { + metaTitle: 'Тарифы — Open Design', + metaDescription: + 'Сравните тарифы Open Design для авторов и команд: кредиты моделей, совместная работа по местам, годовые скидки и поддержка Enterprise.', + breadcrumbLabel: 'Тарифы', + audienceTabsLabel: 'Тип тарифа', + creatorTab: 'Для авторов', + teamTab: 'Для команд', + billingIntervalLabel: 'Период оплаты', + teamTitle: 'Команда', + teamTagline: 'Для дизайн-команд · Совместная работа по местам', + recommended: 'Рекомендуем', + creditLabel: 'Ежемесячные кредиты моделей на место', + seatOnly: 'Только места, без кредитов моделей', + creditUnit: 'кредитов моделей', + seats: 'Места', + decreaseSeats: 'Уменьшить число мест', + increaseSeats: 'Увеличить число мест', + minSeatsNote: 'Для командного тарифа нужно минимум {count} места.', + perSeatMonth: '/ место / месяц', + monthlyTotal: 'Первый месяц — всего {amount}', + yearlyTotal: 'Первый год — всего {amount}', + checkout: 'Перейти на Team', + teamFeatures: [ + 'Общие проекты, Design Systems и плагины с управлением для команды', + 'Все могут смотреть и комментировать; редактирует только создатель проекта', + 'Ролевой доступ: Owner / Admin / Member', + ], + enterpriseTitle: 'Enterprise', + enterpriseTagline: 'Индивидуальная поддержка и соответствие требованиям', + enterpriseCta: 'Связаться с нами', + enterpriseFeatures: [ + 'Все возможности Max', + 'Общий Design System и единый источник данных бренда', + 'Design System, который учится вместе с командой', + 'Совместная работа в реальном времени', + 'Многопользовательское редактирование проектов и результатов', + 'Командная библиотека проектов и результатов', + 'Управление участниками и правами', + 'Единая панель оплаты и использования', + 'SSO / SAML и приоритетная поддержка', + ], +}; + +const ES: TeamPricingContent = { + metaTitle: 'Precios — Open Design', + metaDescription: + 'Compara los planes Creator y Team de Open Design: créditos de modelos, colaboración por asiento, ahorro anual y soporte Enterprise.', + breadcrumbLabel: 'Precios', + audienceTabsLabel: 'Público del plan', + creatorTab: 'Creadores', + teamTab: 'Equipos', + billingIntervalLabel: 'Periodo de facturación', + teamTitle: 'Equipo', + teamTagline: 'Para equipos de diseño · Colaboración por asiento', + recommended: 'Recomendado', + creditLabel: 'Créditos de modelos al mes por asiento', + seatOnly: 'Solo asientos, sin créditos de modelos', + creditUnit: 'créditos de modelos', + seats: 'Asientos', + decreaseSeats: 'Quitar un asiento', + increaseSeats: 'Añadir un asiento', + minSeatsNote: 'Los planes Team requieren al menos {count} asientos.', + perSeatMonth: '/ asiento / mes', + monthlyTotal: 'Primer mes por solo {amount}', + yearlyTotal: 'Primer año por solo {amount}', + checkout: 'Mejorar a Team', + teamFeatures: [ + 'Compartir y gestionar proyectos, Design Systems y plugins en equipo', + 'Todos pueden ver y comentar; solo el creador del proyecto puede editarlo', + 'Acceso por roles: Owner / Admin / Member', + ], + enterpriseTitle: 'Enterprise', + enterpriseTagline: 'Soporte personalizado y cumplimiento para equipos grandes', + enterpriseCta: 'Contactar', + enterpriseFeatures: [ + 'Todo lo incluido en Max', + 'Design System compartido y fuente única de verdad de marca', + 'Un Design System que aprende con tu equipo', + 'Colaboración en tiempo real', + 'Edición multiusuario de proyectos y entregables', + 'Biblioteca de proyectos y entregables del equipo', + 'Gestión de miembros y permisos', + 'Panel unificado de facturación y uso', + 'SSO / SAML y soporte prioritario', + ], +}; + +const PT_BR: TeamPricingContent = { + metaTitle: 'Preços — Open Design', + metaDescription: + 'Compare os planos Creator e Team do Open Design, com créditos de modelos, colaboração por assento, economia anual e suporte Enterprise.', + breadcrumbLabel: 'Preços', + audienceTabsLabel: 'Público do plano', + creatorTab: 'Criadores', + teamTab: 'Equipes', + billingIntervalLabel: 'Período de cobrança', + teamTitle: 'Equipe', + teamTagline: 'Feito para equipes de design · Colaboração por assento', + recommended: 'Recomendado', + creditLabel: 'Créditos de modelos mensais por assento', + seatOnly: 'Apenas assentos, sem créditos de modelos', + creditUnit: 'créditos de modelos', + seats: 'Assentos', + decreaseSeats: 'Diminuir assentos', + increaseSeats: 'Aumentar assentos', + minSeatsNote: 'Os planos Team exigem pelo menos {count} assentos.', + perSeatMonth: '/ assento / mês', + monthlyTotal: 'Primeiro mês por apenas {amount}', + yearlyTotal: 'Primeiro ano por apenas {amount}', + checkout: 'Fazer upgrade para Team', + teamFeatures: [ + 'Compartilhe e gerencie projetos, Design Systems e plugins em equipe', + 'Todos podem ver e comentar; apenas o criador do projeto pode editar', + 'Acesso por função: Owner / Admin / Member', + ], + enterpriseTitle: 'Enterprise', + enterpriseTagline: 'Suporte personalizado e conformidade para equipes maiores', + enterpriseCta: 'Fale conosco', + enterpriseFeatures: [ + 'Tudo do Max', + 'Design System compartilhado e fonte única da marca', + 'Um Design System que aprende com sua equipe', + 'Colaboração em tempo real', + 'Edição de projetos e entregáveis por vários usuários', + 'Biblioteca de projetos e entregáveis da equipe', + 'Gestão de membros e permissões', + 'Painel unificado de cobrança e uso', + 'SSO / SAML e suporte prioritário', + ], +}; + +const IT: TeamPricingContent = { + metaTitle: 'Prezzi — Open Design', + metaDescription: + 'Confronta i piani Creator e Team di Open Design: crediti modello, collaborazione per postazione, risparmio annuale e supporto Enterprise.', + breadcrumbLabel: 'Prezzi', + audienceTabsLabel: 'Destinatari del piano', + creatorTab: 'Creator', + teamTab: 'Team', + billingIntervalLabel: 'Periodo di fatturazione', + teamTitle: 'Team', + teamTagline: 'Per i team di design · Collaborazione per postazione', + recommended: 'Consigliato', + creditLabel: 'Crediti modello mensili per postazione', + seatOnly: 'Solo postazioni, senza crediti modello', + creditUnit: 'crediti modello', + seats: 'Postazioni', + decreaseSeats: 'Riduci le postazioni', + increaseSeats: 'Aumenta le postazioni', + minSeatsNote: 'I piani Team richiedono almeno {count} postazioni.', + perSeatMonth: '/ postazione / mese', + monthlyTotal: 'Primo mese a soli {amount}', + yearlyTotal: 'Primo anno a soli {amount}', + checkout: 'Passa a Team', + teamFeatures: [ + 'Condividi e gestisci progetti, Design Systems e plugin come team', + 'Tutti possono vedere e commentare; modifica solo chi crea il progetto', + 'Accesso basato sui ruoli: Owner / Admin / Member', + ], + enterpriseTitle: 'Enterprise', + enterpriseTagline: 'Supporto personalizzato e conformità per team più grandi', + enterpriseCta: 'Contattaci', + enterpriseFeatures: [ + 'Tutto ciò che include Max', + 'Design System condiviso e fonte unica della verità del brand', + 'Un Design System che impara con il team', + 'Collaborazione in tempo reale', + 'Modifica multiutente di progetti e risultati', + 'Libreria di progetti e risultati del team', + 'Gestione di membri e autorizzazioni', + 'Dashboard unificata di fatturazione e utilizzo', + 'SSO / SAML e supporto prioritario', + ], +}; + +const TR: TeamPricingContent = { + metaTitle: 'Fiyatlandırma — Open Design', + metaDescription: + 'Open Design Creator ve Team planlarını; model kredileri, koltuk başına iş birliği, yıllık tasarruf ve Enterprise desteğiyle karşılaştırın.', + breadcrumbLabel: 'Fiyatlandırma', + audienceTabsLabel: 'Plan hedefi', + creatorTab: 'İçerik üretici', + teamTab: 'Ekip', + billingIntervalLabel: 'Faturalandırma dönemi', + teamTitle: 'Ekip', + teamTagline: 'Tasarım ekipleri için · Koltuk başına iş birliği', + recommended: 'Önerilen', + creditLabel: 'Koltuk başına aylık model kredisi', + seatOnly: 'Yalnızca koltuk, model kredisi yok', + creditUnit: 'model kredisi', + seats: 'Koltuklar', + decreaseSeats: 'Koltuk sayısını azalt', + increaseSeats: 'Koltuk sayısını artır', + minSeatsNote: 'Team planları en az {count} koltuk gerektirir.', + perSeatMonth: '/ koltuk / ay', + monthlyTotal: 'İlk ay yalnızca {amount}', + yearlyTotal: 'İlk yıl yalnızca {amount}', + checkout: 'Team’e yükselt', + teamFeatures: [ + 'Projeleri, Design Systems öğelerini ve eklentileri ekipçe paylaşın ve yönetin', + 'Herkes görüntüleyip yorum yapabilir; yalnızca proje sahibi düzenleyebilir', + 'Role dayalı erişim: Owner / Admin / Member', + ], + enterpriseTitle: 'Enterprise', + enterpriseTagline: 'Büyük ekipler için özel destek ve uyumluluk', + enterpriseCta: 'Bize ulaşın', + enterpriseFeatures: [ + 'Max kapsamındaki her şey', + 'Paylaşılan ekip Design System’ı ve tek marka doğruluk kaynağı', + 'Ekibinizle birlikte öğrenen bir Design System', + 'Gerçek zamanlı iş birliği', + 'Çok kullanıcılı proje ve çıktı düzenleme', + 'Ekip proje ve çıktı kitaplığı', + 'Üye ve izin yönetimi', + 'Birleşik faturalandırma ve kullanım paneli', + 'SSO / SAML ve öncelikli destek', + ], +}; + +/** + * Pricing is a flagship page. This table is intentionally exported so tests + * can fail if any active landing locale silently falls back to English. + */ +export const TEAM_PRICING_CONTENT_BY_LOCALE: Record< + PricingLocale, + TeamPricingContent +> = { + en: EN, + zh: ZH, + ja: JA, + ko: KO, + de: DE, + fr: FR, + ru: RU, + es: ES, + 'pt-br': PT_BR, + it: IT, + tr: TR, +}; + +export function getTeamPricingContent( + locale: LandingLocaleCode, +): TeamPricingContent { + return TEAM_PRICING_CONTENT_BY_LOCALE[locale as PricingLocale] ?? EN; +} diff --git a/apps/landing-page/app/_lib/pricing.ts b/apps/landing-page/app/_lib/pricing.ts index aca7a3e4338..4b130af1521 100644 --- a/apps/landing-page/app/_lib/pricing.ts +++ b/apps/landing-page/app/_lib/pricing.ts @@ -11,6 +11,11 @@ */ export type PlanTier = 'plus' | 'pro' | 'max'; +export type TeamPlanTier = + | 'team_basic' + | 'team_plus' + | 'team_pro' + | 'team_max'; export type BillingInterval = 'monthly' | 'yearly'; export interface PlanMonthlyConfig { @@ -41,6 +46,29 @@ export interface PlanTierConfig { deployLimit: number; } +export interface TeamPlanIntervalConfig { + /** Recurring per-seat price for the complete billing period. */ + priceUsd: number; + /** Introductory per-seat price for the first billing period. */ + introPriceUsd: number; +} + +export interface TeamPlanYearlyConfig extends TeamPlanIntervalConfig { + /** Savings against twelve recurring monthly payments. */ + discountPct: number; +} + +export interface TeamPlanTierConfig { + tier: TeamPlanTier; + rank: number; + recommended: boolean; + minSeats: number; + /** Model-credit allowance granted to each seat every month. */ + monthlyCreditsPerSeatUsd: number; + monthly: TeamPlanIntervalConfig; + yearly: TeamPlanYearlyConfig; +} + export interface PricingContract { /** Contract version; bump in vela when the shape changes. */ version: number; @@ -48,42 +76,45 @@ export interface PricingContract { /** Per-deploy overage price once `deployLimit` is exceeded, USD. */ overageDeployPriceUsd: number; tiers: PlanTierConfig[]; + teamTiers: TeamPlanTierConfig[]; } -/** Production public host for the Open Design Cloud commerce app. */ -export const CLOUD_BASE_URL = 'https://open-design.ai/cloud'; +/** Production dashboard that owns the authenticated billing-plan dialog. */ +export const CLOUD_BASE_URL = 'https://vela.powerformer.net/dashboard'; /** Public pricing contract served by the landing page. */ export const PLANS_JSON_URL = '/pricing/plans.json'; /** - * Cloud billing console (the vela "wallet"). This is where subscriptions are - * managed and where a successful Stripe checkout returns. Use this for any - * "go to the console" link. + * Stable Vela contract for opening the billing plan chooser. `view=plans` and + * `checkout=auto` are wallet-era compatibility aliases and must not be emitted + * by the landing page. */ -export const CLOUD_CONSOLE_URL = `${CLOUD_BASE_URL}/wallet`; +export const CLOUD_CONSOLE_URL = `${CLOUD_BASE_URL}?billing=plan`; /** - * Deep link that starts subscription checkout for one tier inside the cloud - * console, then returns to the console on success. - * - * The console is auth-gated by the cloud app: an unauthenticated visitor is - * bounced to the login/registration page and returned to this exact URL after - * authenticating — so the same intent resumes with no extra step. The - * `checkout=auto` flag asks the console to open the Stripe checkout for - * `{plan, interval}` immediately instead of just showing the plans modal. + * Compatibility helper retained for existing call sites. Landing pricing is a + * static comparison surface, so every CTA opens the authoritative plan chooser + * rather than guessing a user's current workspace, plan, or permitted change. */ export function cloudSubscribeUrl( - tier: string, - interval: 'monthly' | 'yearly', + _tier: string, + _interval: 'monthly' | 'yearly', ): string { - const params = new URLSearchParams({ - view: 'plans', - plan: tier, - interval, - checkout: 'auto', - }); - return `${CLOUD_CONSOLE_URL}?${params.toString()}`; + return CLOUD_CONSOLE_URL; +} + +/** + * Preserve an explicitly supplied workspace scope without inventing one from + * local state. The browser enhancement calls this same URL contract when the + * public pricing page itself was opened with `?workspaceId=...`. + */ +export function scopedBillingPlanUrl(workspaceId?: string | null): string { + const normalized = workspaceId?.trim(); + if (!normalized) return CLOUD_CONSOLE_URL; + const url = new URL(CLOUD_CONSOLE_URL); + url.searchParams.set('workspaceId', normalized); + return url.toString(); } /** @@ -92,7 +123,7 @@ export function cloudSubscribeUrl( * published JSON. */ export const PRICING_SNAPSHOT: PricingContract = { - version: 1, + version: 2, currency: 'USD', overageDeployPriceUsd: 2, tiers: [ @@ -108,19 +139,57 @@ export const PRICING_SNAPSHOT: PricingContract = { tier: 'pro', rank: 2, recommended: true, - monthly: { priceUsd: 100, introPriceUsd: 70, grantUsd: 100 }, - yearly: { priceUsd: 720, discountPct: 40, grantUsd: 1200 }, + monthly: { priceUsd: 100, introPriceUsd: 70, grantUsd: 120 }, + yearly: { priceUsd: 720, discountPct: 40, grantUsd: 1440 }, deployLimit: 20, }, { tier: 'max', rank: 3, recommended: false, - monthly: { priceUsd: 200, introPriceUsd: 120, grantUsd: 200 }, - yearly: { priceUsd: 1176, discountPct: 51, grantUsd: 2400 }, + monthly: { priceUsd: 200, introPriceUsd: 120, grantUsd: 300 }, + yearly: { priceUsd: 1176, discountPct: 51, grantUsd: 3600 }, deployLimit: 50, }, ], + teamTiers: [ + { + tier: 'team_basic', + rank: 0, + recommended: false, + minSeats: 3, + monthlyCreditsPerSeatUsd: 0, + monthly: { priceUsd: 20, introPriceUsd: 16 }, + yearly: { priceUsd: 240, introPriceUsd: 168, discountPct: 30 }, + }, + { + tier: 'team_plus', + rank: 1, + recommended: false, + minSeats: 3, + monthlyCreditsPerSeatUsd: 20, + monthly: { priceUsd: 40, introPriceUsd: 32 }, + yearly: { priceUsd: 480, introPriceUsd: 336, discountPct: 30 }, + }, + { + tier: 'team_pro', + rank: 2, + recommended: true, + minSeats: 3, + monthlyCreditsPerSeatUsd: 100, + monthly: { priceUsd: 120, introPriceUsd: 84 }, + yearly: { priceUsd: 1440, introPriceUsd: 864, discountPct: 40 }, + }, + { + tier: 'team_max', + rank: 3, + recommended: false, + minSeats: 3, + monthlyCreditsPerSeatUsd: 200, + monthly: { priceUsd: 220, introPriceUsd: 132 }, + yearly: { priceUsd: 2640, introPriceUsd: 1296, discountPct: 51 }, + }, + ], }; /** Whole-dollar USD, no trailing cents (prices are whole-dollar by design). */ @@ -132,3 +201,12 @@ export function formatUsd(amount: number): string { export function yearlyMonthlyEquivalent(yearlyPriceUsd: number): number { return Math.round(yearlyPriceUsd / 12); } + +/** Introductory Team charge for the selected billing period and seat count. */ +export function teamIntroTotalUsd( + tier: TeamPlanTierConfig, + interval: BillingInterval, + seats: number, +): number { + return tier[interval].introPriceUsd * seats; +} diff --git a/apps/landing-page/app/pages/pricing/index.astro b/apps/landing-page/app/pages/pricing/index.astro index df869d21d77..5e10d4bfb4a 100644 --- a/apps/landing-page/app/pages/pricing/index.astro +++ b/apps/landing-page/app/pages/pricing/index.astro @@ -5,9 +5,9 @@ * Card LAYOUT + COPY mirror the vela subscription modal (`pricing-plans.tsx`). * The NUMBERS come from `PRICING_SNAPSHOT` at build time and are reconciled * from `PLANS_JSON_URL` by the inline script after validating the public JSON - * contract. The localized TEXT comes from app/_lib/pricing-content.ts. - * Numbers and text are kept separate on purpose: only numbers are part of the - * plans.json contract. + * contract. The localized TEXT comes from app/_lib/pricing-content.ts and + * app/_lib/pricing-team-content.ts. Numbers and text are kept separate on + * purpose: only numbers are part of the plans.json contract. */ import Layout from '../../_components/sub-page-layout.astro'; import { @@ -15,7 +15,9 @@ import { PLANS_JSON_URL, CLOUD_CONSOLE_URL, cloudSubscribeUrl, + teamIntroTotalUsd, type PlanTierConfig, + type TeamPlanTierConfig, } from '../../_lib/pricing'; import { getPricingContent, @@ -26,13 +28,13 @@ import { TRIAL_CREDIT_PROMO_ENABLED, type PlanTierId, } from '../../_lib/pricing-content'; +import { getTeamPricingContent } from '../../_lib/pricing-team-content'; import EnterpriseLeadForm from '../../_components/enterprise-lead-form.astro'; import { getStory, getFaqs, getFaqTitle, getEnterprise, - ENTERPRISE_EMAIL, } from '../../_lib/pricing-extras-content'; import { getCatalogCounts } from '../../_lib/catalog'; import { localeFromPath, localizedHref } from '../../i18n'; @@ -40,6 +42,7 @@ import { localeFromPath, localizedHref } from '../../i18n'; const locale = localeFromPath(Astro.url.pathname); const href = (path: string) => localizedHref(path, locale); const content = getPricingContent(locale); +const teamContent = getTeamPricingContent(locale); const L = content.labels; const catalogCounts = await getCatalogCounts(locale); const catalogLabelVars = { @@ -63,26 +66,62 @@ const enterprise = getEnterprise(locale); const LF = enterprise.leadForm; const SITE = Astro.site?.toString() ?? 'https://open-design.ai/'; -const title = 'Pricing — Open Design'; -const description = - 'Open Design subscription plans. Free, Plus, Pro, and Max tiers with monthly credits, one-click deployments, and auto top-up — one unified balance for every Open Design call.'; +const title = teamContent.metaTitle; +const description = teamContent.metaDescription; +const usd = (n: number) => `$${Math.round(n).toLocaleString('en-US')}`; const tiers: PlanTierConfig[] = PRICING_SNAPSHOT.tiers; +const teamTiers: TeamPlanTierConfig[] = PRICING_SNAPSHOT.teamTiers; +const initialTeamTier = teamTiers.find((tier) => tier.recommended) ?? teamTiers[0]!; +const initialTeamSeats = initialTeamTier.minSeats; + +function teamTierLabel(tier: TeamPlanTierConfig): string { + return tier.tier + .replace(/^team_/, 'Team ') + .replace(/\b\w/g, (character) => character.toUpperCase()); +} + +function teamCreditOption(tier: TeamPlanTierConfig): string { + const credit = tier.monthlyCreditsPerSeatUsd; + return `${teamTierLabel(tier).replace(/^Team /, '')}: ${ + credit === 0 ? teamContent.seatOnly : `${usd(credit)} ${teamContent.creditUnit}` + }`; +} + +function teamView(tier: TeamPlanTierConfig, interval: 'monthly' | 'yearly') { + const selected = tier[interval]; + const months = interval === 'yearly' ? 12 : 1; + const introSeatMonth = Math.round(selected.introPriceUsd / months); + const regularSeatMonth = + interval === 'yearly' + ? Math.round(tier.monthly.priceUsd) + : Math.round(selected.priceUsd); + const discountPct = Math.round( + (1 - introSeatMonth / regularSeatMonth) * 100, + ); + const intervalTotal = teamIntroTotalUsd(tier, interval, initialTeamSeats); + return { + amount: usd(introSeatMonth), + strike: usd(regularSeatMonth), + discountPct, + intervalTotal: usd(intervalTotal), + }; +} + +const initialTeamView = teamView(initialTeamTier, 'yearly'); // Free card model roster: trial-pool models lead the premium list, the rest // render greyed-out — mirrors the vela modal's Free column. const FREE_PREMIUM_MODELS = [...PREMIUM_MODELS].sort( (a, b) => Number(b.trial === true) - Number(a.trial === true), ); -const usd = (n: number) => `$${Math.round(n).toLocaleString('en-US')}`; const renderCatalogLabel = (label: string) => fillTemplate(label, catalogLabelVars); -// Displayed monthly credit = base grant × the limited-time bonus (Pro +20%, -// Max +50%). The base grant stays the plans.json contract number; the bonus is -// a promo surfaced as a badge next to the amount. +// Vela's grant already includes the advertised Pro/Max uplift. The percentage +// is a presentation badge only; applying it again would inflate $120/$300 to +// $144/$450. function effectiveCreditUsd(t: PlanTierConfig): number { - const bonusPct = CREDIT_BONUS_PCT[t.tier as PlanTierId]; - return bonusPct ? Math.round(t.monthly.grantUsd * (1 + bonusPct / 100)) : t.monthly.grantUsd; + return t.monthly.grantUsd; } // Build-time display values per tier (the inline script recomputes these from @@ -120,7 +159,7 @@ const jsonLd = [ '@type': 'BreadcrumbList', itemListElement: [ { '@type': 'ListItem', position: 1, name: 'Open Design', item: absUrl('/') }, - { '@type': 'ListItem', position: 2, name: 'Pricing', item: absUrl('/pricing/') }, + { '@type': 'ListItem', position: 2, name: teamContent.breadcrumbLabel, item: absUrl('/pricing/') }, ], }, { @@ -143,6 +182,32 @@ const jsonLd = [ priceCurrency: 'USD', url: CLOUD_CONSOLE_URL, })), + ...teamTiers.flatMap((t) => [ + { + '@type': 'Offer', + name: `Open Design ${teamTierLabel(t)} monthly`, + price: String(t.monthly.priceUsd), + priceCurrency: 'USD', + url: CLOUD_CONSOLE_URL, + eligibleQuantity: { + '@type': 'QuantitativeValue', + minValue: t.minSeats, + unitText: 'seat', + }, + }, + { + '@type': 'Offer', + name: `Open Design ${teamTierLabel(t)} yearly`, + price: String(t.yearly.priceUsd), + priceCurrency: 'USD', + url: CLOUD_CONSOLE_URL, + eligibleQuantity: { + '@type': 'QuantitativeValue', + minValue: t.minSeats, + unitText: 'seat', + }, + }, + ]), ], }, ]; @@ -152,13 +217,39 @@ const jsonLd = [ -
+

{L.heroTitle}

-
+
+ + +
+
@@ -169,7 +260,13 @@ const jsonLd = [
-
+
+
{/* Free card — content-only (not part of the paid pricing contract). The CTA points at the auth-gated cloud console: signed-in visitors land on the console, signed-out visitors get the login page and @@ -320,39 +417,138 @@ const jsonLd = [

-

+ -
-
-
-
+
@@ -409,8 +605,8 @@ const jsonLd = [
- {/* Team-plan lead-capture modal — opened by the "Request team access" CTA in - the team banner. Renders the SAME shared lead form as /enterprise + {/* Enterprise lead-capture modal — opened by the Enterprise card CTA. + Renders the SAME shared lead form as /enterprise (fields, required rules, options, validation, /contact-sales contract), so the two funnels can never drift; only `source` differs for lead attribution. */} @@ -426,7 +622,7 @@ const jsonLd = [ - '; - const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input); - if (url.includes('/api/projects/project-1/files') && init?.method === 'POST') { - return new Response(JSON.stringify({ file: htmlPreviewFile() }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - } - return new Response(source, { status: 200, headers: { 'Content-Type': 'text/html' } }); - }); + it('keeps a drag on an unselected element out of the open panel draft', async () => { + const source = '
Hero
'; + const fetchMock = vi.fn(async () => + new Response(source, { status: 200, headers: { 'Content-Type': 'text/html' } }), + ); vi.stubGlobal('fetch', fetchMock); render( @@ -893,105 +583,36 @@ describe('FileViewer manual edit regressions', () => { />, ); - clickManualTool('manual-edit-mode-toggle'); - await selectManualEditTarget({ - ...heroTarget(), - id: 'app-root', - label: 'App root', - text: 'App', - outerHtml: '
App
', - }); + await enterManualEditMode(); + await selectManualEditTarget(); + await findStyleInput(FONT_SIZE_ROW); - // Both the selection-frame action bar and the panel footer expose a - // delete control now; either one drives the same remove-element patch. - fireEvent.click(screen.getAllByLabelText('Delete element')[0]!); + // A drag commit for a different element must not dirty the panel that is + // showing `hero` — otherwise Save would write someone else's transform + // into this element's draft. + await dropManualEditDrag('side', 'translate(40px, 0px)'); - await waitFor(() => { - expect(screen.getByText('Cannot remove the last rendered element in the document.')).toBeTruthy(); - }); - expect((screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement).srcdoc).toContain('data-od-id="app-root"'); + expect(screen.queryByText('Reset')).toBeNull(); expect(fetchMock).not.toHaveBeenCalledWith( '/api/projects/project-1/files', expect.objectContaining({ method: 'POST' }), ); }); - // --------------------------------------------------------------------------- - // In-place content pipeline: content commits mutate the live iframe DOM via - // od-edit-apply-dom instead of swapping srcDoc — no white flash, no scroll - // reset. These tests play the bridge's role: capture the apply-dom post and - // ack it, then assert the canvas was NOT reloaded (srcdoc unchanged). - // --------------------------------------------------------------------------- - - type ApplyDomMessage = { - type: string; - id: string; - html: string; - op?: string; - fields?: Record; - version: number; - }; - - function lastApplyDomMessage(spy: { mock: { calls: unknown[][] } }): ApplyDomMessage | null { - for (let i = spy.mock.calls.length - 1; i >= 0; i--) { - const msg = spy.mock.calls[i]?.[0] as { type?: string } | undefined; - if (msg?.type === 'od-edit-apply-dom') return msg as ApplyDomMessage; - } - return null; - } - - async function ackApplyDom(frame: HTMLIFrameElement, spy: { mock: { calls: unknown[][] } }) { - const message = await waitFor(() => { - const found = lastApplyDomMessage(spy); - if (!found) throw new Error('no od-edit-apply-dom posted yet'); - return found; - }); - act(() => { - window.dispatchEvent(new MessageEvent('message', { - data: { type: 'od-edit-apply-dom-result', version: message.version, ok: true }, - source: frame.contentWindow, - })); - }); - return message; - } - - function manualEditWriteMock(initialSource: string) { - const savedBodies: Array<{ content: string; versionLabel?: string; versionSource?: string }> = []; + it('saves text typed in the inspector while an inline text session is active', async () => { + const source = '
Hero
'; + const savedBodies: string[] = []; const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input); - if (url.includes('/api/projects/project-1/upload') && init?.method === 'POST') { - return new Response(JSON.stringify({ files: [{ name: 'pasted-image.png', path: 'pasted-image.png' }] }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - } if (url.includes('/api/projects/project-1/files') && init?.method === 'POST') { - savedBodies.push(JSON.parse(String(init.body)) as (typeof savedBodies)[number]); + savedBodies.push(String(init.body)); return new Response(JSON.stringify({ file: htmlPreviewFile() }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } - if (url.includes('/api/projects/project-1/deployments')) { - return new Response(JSON.stringify({ deployments: [] }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - } - if (url.includes('/api/projects/project-1/raw/preview.html')) { - // The latest saved content is the persisted truth; applyManualEdit's - // freshness confirm must see its own writes or it clears history. - const latest = savedBodies[savedBodies.length - 1]?.content ?? initialSource; - return new Response(latest, { status: 200, headers: { 'Content-Type': 'text/html' } }); - } - return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + return new Response(source, { status: 200, headers: { 'Content-Type': 'text/html' } }); }); - return { fetchMock, savedBodies }; - } - - it('applies a text commit in place without reloading the srcDoc canvas', async () => { - const source = '
Hero
'; - const { fetchMock, savedBodies } = manualEditWriteMock(source); vi.stubGlobal('fetch', fetchMock); render( @@ -999,148 +620,51 @@ describe('FileViewer manual edit regressions', () => { liveHtml={source} />, ); - clickManualTool('manual-edit-mode-toggle'); - const frame = await previewFrame(); - const postSpy = vi.spyOn(frame.contentWindow!, 'postMessage'); - await selectManualEditTarget(); - const srcdocBefore = frame.srcdoc; + await enterManualEditMode(); + await selectManualEditTarget(); + const frame = await previewFrame(); act(() => { window.dispatchEvent(new MessageEvent('message', { - data: { type: 'od-edit-text-commit', id: 'hero', value: 'Updated hero' }, + data: { type: 'od-edit-text-session', id: 'hero', active: true }, source: frame.contentWindow, })); }); - const applied = await ackApplyDom(frame, postSpy); - expect(applied.op ?? 'replace').toBe('replace'); - expect(applied.id).toBe('hero'); - expect(applied.html).toContain('Updated hero'); - - await waitFor(() => expect(savedBodies).toHaveLength(1)); - expect(savedBodies[0]!.content).toContain('Updated hero'); - expect(savedBodies[0]!.versionSource).toBe('manual'); - // No srcDoc swap — the canvas kept its DOM (and therefore its scroll). - expect(frame.srcdoc).toBe(srcdocBefore); - }); - - it('deletes an element in place through the remove op', async () => { - const source = '
Hero
Footer
'; - const { fetchMock, savedBodies } = manualEditWriteMock(source); - vi.stubGlobal('fetch', fetchMock); - - render( - , - ); - clickManualTool('manual-edit-mode-toggle'); - const frame = await previewFrame(); - const postSpy = vi.spyOn(frame.contentWindow!, 'postMessage'); - await selectManualEditTarget(); - const srcdocBefore = frame.srcdoc; - - fireEvent.click(screen.getAllByLabelText('Delete element')[0]!); - - const applied = await ackApplyDom(frame, postSpy); - expect(applied.op).toBe('remove'); - expect(applied.id).toBe('hero'); - - await waitFor(() => expect(savedBodies).toHaveLength(1)); - expect(savedBodies[0]!.content).not.toContain('data-od-id="hero"'); - expect(frame.srcdoc).toBe(srcdocBefore); - // Selection chrome is gone with the element. - await waitFor(() => { - expect(screen.queryByTestId('manual-edit-selection-frame')).toBeNull(); - }); - }); - - it('inserts a pasted image in place and hands the selection to it', async () => { - const source = '
Hero
'; - const { fetchMock, savedBodies } = manualEditWriteMock(source); - vi.stubGlobal('fetch', fetchMock); - - render( - , - ); - clickManualTool('manual-edit-mode-toggle'); - const frame = await previewFrame(); - const postSpy = vi.spyOn(frame.contentWindow!, 'postMessage'); - await selectManualEditTarget(); - const srcdocBefore = frame.srcdoc; - + fireEvent.change(screen.getByLabelText('Text'), { target: { value: 'Edited from panel' } }); + fireEvent.click(screen.getByText('Save')); act(() => { window.dispatchEvent(new MessageEvent('message', { data: { - type: 'od-edit-paste-image', + type: 'od-edit-text-session', id: 'hero', - name: 'pasted-image.png', - mime: 'image/png', - buffer: new Uint8Array([137, 80, 78, 71]).buffer, + active: false, + changed: false, + committed: false, }, source: frame.contentWindow, })); }); - const applied = await ackApplyDom(frame, postSpy); - expect(applied.op).toBe('insert-after'); - expect(applied.id).toBe('hero'); - expect(applied.html).toContain(' expect(savedBodies).toHaveLength(1)); - expect(frame.srcdoc).toBe(srcdocBefore); - - // The bridge would re-broadcast targets after the in-place insert; the - // armed hand-off must select the new image element (positional path id - // read back from the saved source: hero is body child 0 → img is 1). - const imageTarget: ManualEditTarget = { - ...heroTarget(), - id: 'path-1', - kind: 'image', - label: 'Pasted image', - tagName: 'img', - text: '', - fields: { src: 'pasted-image.png', alt: '' }, - attributes: {}, - outerHtml: '', - }; - // A newly inserted image may still be 0x0 before its asset load, so the - // bridge's first non-empty target pass can legitimately omit it. Keep the - // pending hand-off alive until that exact target is announced later. - act(() => { - window.dispatchEvent(new MessageEvent('message', { - data: { type: 'od-edit-targets', targets: [heroTarget()] }, - source: frame.contentWindow, - })); - }); - act(() => { - window.dispatchEvent(new MessageEvent('message', { - data: { type: 'od-edit-targets', targets: [heroTarget(), imageTarget] }, - source: frame.contentWindow, - })); - }); - // Image selection exposes the crop affordance in the action bar. await waitFor(() => { - expect(screen.getByTestId('manual-edit-crop-start')).toBeTruthy(); + expect(savedBodies.length).toBe(1); }); + const payload = JSON.parse(savedBodies[0]!) as { content: string }; + expect(payload.content).toContain('
Edited from panel
'); + expect(payload.content).not.toContain('
Hero
'); }); - it('shows localized upload, processing, and success toasts for pasted or dropped images', async () => { - const source = '
Hero
'; - const { fetchMock: baseFetchMock } = manualEditWriteMock(source); - let resolveUpload!: (response: Response) => void; - const uploadResponse = new Promise((resolve) => { - resolveUpload = resolve; - }); - const fetchMock = vi.fn((input: string | URL | Request, init?: RequestInit) => { + it('keeps the preview mounted and does not save when deleting the only rendered root', async () => { + const source = '
App
'; + const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input); - if (url.includes('/api/projects/project-1/upload') && init?.method === 'POST') { - return uploadResponse; + if (url.includes('/api/projects/project-1/files') && init?.method === 'POST') { + return new Response(JSON.stringify({ file: htmlPreviewFile() }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); } - return baseFetchMock(input, init); + return new Response(source, { status: 200, headers: { 'Content-Type': 'text/html' } }); }); vi.stubGlobal('fetch', fetchMock); @@ -1149,909 +673,26 @@ describe('FileViewer manual edit regressions', () => { liveHtml={source} />, ); - clickManualTool('manual-edit-mode-toggle'); - const frame = await previewFrame(); - const postSpy = vi.spyOn(frame.contentWindow!, 'postMessage'); - await selectManualEditTarget(); - - act(() => { - window.dispatchEvent(new MessageEvent('message', { - data: { - type: 'od-edit-paste-image', - id: 'hero', - name: 'pasted-image.png', - mime: 'image/png', - buffer: new Uint8Array([137, 80, 78, 71]).buffer, - }, - source: frame.contentWindow, - })); - }); - - expect(await screen.findByText('Uploading image…')).toBeTruthy(); - - await act(async () => { - resolveUpload(new Response(JSON.stringify({ - files: [{ name: 'pasted-image.png', path: 'pasted-image.png' }], - }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - })); - await uploadResponse; - }); - - expect(await screen.findByText('Processing image…')).toBeTruthy(); - await ackApplyDom(frame, postSpy); - expect(await screen.findByText('Image added')).toBeTruthy(); - }); - it('applies brand-kit text commits in place instead of reloading (runtime-annotated ids)', async () => { - // Brand-kit targets get their data-od-id from the bridge at runtime — the - // saved source has no markup for them; edits persist into the payload. - const source = '
'; - const { fetchMock, savedBodies } = manualEditWriteMock(source); - vi.stubGlobal('fetch', fetchMock); - - render( - , - ); - clickManualTool('manual-edit-mode-toggle'); - const frame = await previewFrame(); - const postSpy = vi.spyOn(frame.contentWindow!, 'postMessage'); + await enterManualEditMode(); await selectManualEditTarget({ ...heroTarget(), - id: 'brand-name', - label: 'Brand name', - text: 'Acme', - fields: { text: 'Acme' }, - attributes: { 'data-od-id': 'brand-name' }, - outerHtml: '

Acme

', - }); - const srcdocBefore = frame.srcdoc; - - act(() => { - window.dispatchEvent(new MessageEvent('message', { - data: { type: 'od-edit-text-commit', id: 'brand-name', value: 'Acme Studios' }, - source: frame.contentWindow, - })); + id: 'app-root', + label: 'App root', + text: 'App', + outerHtml: '
App
', }); - const applied = await ackApplyDom(frame, postSpy); - expect(applied.op).toBe('apply-content'); - expect((applied as unknown as { fields?: { text?: string } }).fields?.text).toBe('Acme Studios'); - - await waitFor(() => expect(savedBodies).toHaveLength(1)); - // The edit persisted into the brand payload… - expect(savedBodies[0]!.content).toContain('Acme Studios'); - // …and the canvas was NOT reloaded. - expect(frame.srcdoc).toBe(srcdocBefore); - }); - - it('replays runtime-only brand-kit text history in place without reloading', async () => { - const source = '
'; - const { fetchMock, savedBodies } = manualEditWriteMock(source); - vi.stubGlobal('fetch', fetchMock); - - render( - , - ); - clickManualTool('manual-edit-mode-toggle'); - const frame = await previewFrame(); - const postSpy = vi.spyOn(frame.contentWindow!, 'postMessage'); - await selectManualEditTarget({ - ...heroTarget(), - id: 'brand-name', - label: 'Brand name', - text: 'Acme', - fields: { text: 'Acme' }, - attributes: { 'data-od-id': 'brand-name' }, - outerHtml: '

Acme

', - }); - const srcdocBefore = frame.srcdoc; + fireEvent.click(screen.getByLabelText('Delete element')); - act(() => { - window.dispatchEvent(new MessageEvent('message', { - data: { type: 'od-edit-text-commit', id: 'brand-name', value: 'Acme Studios' }, - source: frame.contentWindow, - })); - }); - await ackApplyDom(frame, postSpy); - await waitFor(() => expect(savedBodies).toHaveLength(1)); - - postSpy.mockClear(); - fireEvent.click(screen.getByTestId('manual-edit-undo')); - const undoApplied = await ackApplyDom(frame, postSpy); - expect(undoApplied).toMatchObject({ - id: 'brand-name', - op: 'apply-content', - fields: { text: 'Acme' }, - }); - await waitFor(() => expect(savedBodies).toHaveLength(2)); - expect(savedBodies[1]!.content).toBe(source); - expect(frame.srcdoc).toBe(srcdocBefore); - - postSpy.mockClear(); - fireEvent.click(screen.getByTestId('manual-edit-redo')); - const redoApplied = await ackApplyDom(frame, postSpy); - expect(redoApplied).toMatchObject({ - id: 'brand-name', - op: 'apply-content', - fields: { text: 'Acme Studios' }, + await waitFor(() => { + expect(screen.getByText('Cannot remove the last rendered element in the document.')).toBeTruthy(); }); - await waitFor(() => expect(savedBodies).toHaveLength(3)); - expect(savedBodies[2]!.content).toContain('Acme Studios'); - expect(frame.srcdoc).toBe(srcdocBefore); - }); - - it('replays runtime-only brand-kit style history in place with the persisted values', async () => { - // Runtime-only targets persist set-style in the runtime-overrides Poster'} + />, + teamWorkspaceContext(), + ); + + fireEvent.click(screen.getByTestId('comment-panel-toggle')); + + const pendingFrame = await waitFor(() => { + const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; + expect(frame.getAttribute('data-od-render-mode')).toBe('srcdoc'); + return frame; + }); + expect(pendingFrame.srcdoc).not.toContain('../../fonts/inter-variable-400.woff2'); + + filesResponse.resolve(new Response(JSON.stringify({ + files: [ + htmlPreviewFile({ + name: 'system/artifacts/poster.html', + path: 'system/artifacts/poster.html', + }), + baseFile({ + name: fontPath, + path: fontPath, + mime: 'font/woff2', + }), + ], + }), { status: 200 })); + + await waitFor(() => { + const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; + expect(frame.srcdoc).toContain( + `/api/projects/${projectId}/raw/${fontPath}?workspaceId=ws-1&workspaceMemberId=wm-1`, + ); + expect(frame.srcdoc).not.toContain('../../fonts/inter-variable-400.woff2'); + }); + } finally { + filesResponse.resolve(new Response(JSON.stringify({ files: [] }), { status: 200 })); + } + }); - await waitFor(() => { - expect(screen.getByTestId('artifact-preview-frame')).toBe(urlFrame); - expect(urlFrame.getAttribute('data-od-render-mode')).toBe('url-load'); - expect(urlFrame.getAttribute('data-od-active')).toBe('true'); - expect(srcDocFrame.getAttribute('data-od-active')).toBe('false'); - expect(postSpy).toHaveBeenCalledWith( - { type: 'od:comment-mode', enabled: true, mode: 'inspect' }, - '*', - ); + it('materializes Team deck relative assets into scoped raw URLs before showing srcDoc', async () => { + const filesResponse = deferredResponse(); + const projectId = 'scoped-deck-assets-project'; + const deckPath = 'system/deck.html'; + const imagePath = 'images/hero.png'; + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = typeof input === 'string' + ? input + : input instanceof Request + ? input.url + : String(input); + if (url === `/api/projects/${projectId}/files`) return filesResponse.promise; + return new Response(JSON.stringify({ deployments: [] }), { status: 200 }); }); - }); + vi.stubGlobal('fetch', fetchMock); - it('falls back to srcDoc comments when the URL selection bridge is not ready', async () => { - render( - , - ); + try { + renderWithProjectWorkspace( +
'} + />, + teamWorkspaceContext(), + ); - const urlFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; - expect(urlFrame.getAttribute('data-od-render-mode')).toBe('url-load'); + const pendingFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; + expect(pendingFrame.getAttribute('data-od-render-mode')).toBe('srcdoc'); + expect(pendingFrame.srcdoc).not.toContain('../images/hero.png'); - fireEvent.click(screen.getByTestId('comment-panel-toggle')); + filesResponse.resolve(new Response(JSON.stringify({ + files: [ + htmlPreviewFile({ name: deckPath, path: deckPath }), + baseFile({ name: imagePath, path: imagePath }), + ], + }), { status: 200 })); - const srcDocFrame = await waitFor(() => { - const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; - expect(frame.getAttribute('data-od-render-mode')).toBe('srcdoc'); - return frame; - }); - expect(srcDocFrame.srcdoc).toContain('data-od-selection-bridge'); + await waitFor(() => { + const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; + expect(frame.srcdoc).toContain( + `/api/projects/${projectId}/raw/${imagePath}?workspaceId=ws-1`, + ); + expect(frame.srcdoc).toContain('workspaceMemberId=wm-1'); + expect(frame.srcdoc).not.toContain('../images/hero.png'); + }); + } finally { + filesResponse.resolve(new Response(JSON.stringify({ files: [] }), { status: 200 })); + } }); it('lets Draw direct send emit a queued annotation while a task is running', async () => { @@ -5330,9 +7250,8 @@ describe('FileViewer tweaks toolbar', () => { // so the annotation is staged for the next turn rather than sent mid-run. const send = screen.getByRole('button', { name: 'Send' }) as HTMLButtonElement; expect(send.disabled).toBe(true); - // Queue now lives in the submit dropdown; open it to reach the fallback. - fireEvent.click(screen.getByRole('button', { name: 'Submit options' })); - const queue = screen.getByRole('menuitemradio', { name: 'Queue' }) as HTMLButtonElement; + // Queue is its own always-visible button next to Send. + const queue = screen.getByRole('button', { name: 'Queue' }) as HTMLButtonElement; expect(queue.disabled).toBe(false); fireEvent.click(send); @@ -5545,6 +7464,9 @@ describe('FileViewer tweaks toolbar', () => { vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') .mockImplementation(function getBoundingClientRectMock(this: HTMLElement) { if (this.classList.contains('viewer-body')) return testRect(0, 0, viewerBodyWidth, 700); + if (this instanceof HTMLIFrameElement || this.classList.contains('comment-preview-canvas')) { + return testRect(0, 0, viewerBodyWidth, 700); + } return testRect(0, 0, 0, 0); }); @@ -5571,11 +7493,26 @@ describe('FileViewer tweaks toolbar', () => { const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; const previewWindow = installSandboxedPreviewWindow(frame); fireEvent.load(frame); - act(() => postPreviewContentWidth(previewWindow, 1440)); + const neutralRequest = latestPreviewContentSizeRequest(previewWindow); + expect(neutralRequest.measurementId).toMatch(/^preview-host-\d+:measurement-\d+$/); + expect(neutralRequest.generation).toMatch(/^preview-host-\d+:generation-\d+$/); + expect(neutralRequest.documentEpoch).toMatch(/^preview-document-\d+$/); + expect(JSON.stringify(neutralRequest)).not.toContain('project-1'); + act(() => postPreviewContentSizeResponse(previewWindow, neutralRequest, 1440, 900)); await waitFor(() => { expect(screen.getByRole('button', { name: '63%' })).toBeTruthy(); }); + let scaledRequest = latestPreviewContentSizeRequest(previewWindow); + await waitFor(() => { + scaledRequest = latestPreviewContentSizeRequest(previewWindow); + expect(scaledRequest.measurementId).not.toBe(neutralRequest.measurementId); + }); + act(() => { + postPreviewContentSizeResponse(previewWindow, neutralRequest, 90_000, 90_000); + postPreviewContentSizeResponse(previewWindow, scaledRequest, 90_000, 90_000); + }); + expect(screen.getByRole('button', { name: '63%' })).toBeTruthy(); const scaledShell = Array.from(container.querySelectorAll('div')).find( (node) => node.style.transform === 'scale(0.625)', ); @@ -5598,10 +7535,70 @@ describe('FileViewer tweaks toolbar', () => { }); }); + it('updates auto-fit when the overflow witness changes at the same measured width', async () => { + let viewerBodyWidth = 900; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function getBoundingClientRectMock(this: HTMLElement) { + if ( + this.classList.contains('viewer-body') || + this.classList.contains('comment-preview-canvas') || + this instanceof HTMLIFrameElement + ) { + return testRect(0, 0, viewerBodyWidth, 700); + } + return testRect(0, 0, 0, 0); + }); + + render( + , + ); + + const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; + const previewWindow = installSandboxedPreviewWindow(frame); + fireEvent.load(frame); + const responsiveRequest = latestPreviewContentSizeRequest(previewWindow); + act(() => postPreviewContentSizeResponse(previewWindow, responsiveRequest, 900, 900)); + expect(screen.getByRole('button', { name: '100%' })).toBeTruthy(); + + viewerBodyWidth = 720; + window.dispatchEvent(new Event('resize')); + let overflowRequest = latestPreviewContentSizeRequest(previewWindow); + await waitFor(() => { + overflowRequest = latestPreviewContentSizeRequest(previewWindow); + expect(overflowRequest.canvasWidth).toBe(720); + expect(overflowRequest.measurementId).not.toBe(responsiveRequest.measurementId); + }); + // The resize schedules several legitimate follow-up measurements (rAF, + // then 80/260ms). Under a loaded full-suite worker, one can supersede the + // request observed by waitFor before this continuation resumes. Reply to + // the latest witnessed request synchronously so the test exercises the + // overflow-state rerender instead of intentionally sending a stale nonce. + act(() => { + overflowRequest = latestPreviewContentSizeRequest(previewWindow); + expect(overflowRequest.canvasWidth).toBe(720); + postPreviewContentSizeResponse(previewWindow, overflowRequest, 900, 720); + }); + + await waitFor(() => { + expect(screen.getByRole('button', { name: '80%' })).toBeTruthy(); + }); + }); + it('requests the content-size bridge for powered desktop previews before auto-fitting', async () => { vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') .mockImplementation(function getBoundingClientRectMock(this: HTMLElement) { if (this.classList.contains('viewer-body')) return testRect(0, 0, 900, 700); + if (this instanceof HTMLIFrameElement || this.classList.contains('comment-preview-canvas')) { + return testRect(0, 0, 900, 700); + } return testRect(0, 0, 0, 0); }); vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => { @@ -5642,15 +7639,20 @@ describe('FileViewer tweaks toolbar', () => { await waitFor(() => { const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; expect(frame.getAttribute('data-od-powered')).toBe('true'); - expect(frame.getAttribute('src')).toBe( - 'http://localhost:48123/api/projects/project-1/powered/powered-wide.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot', + const src = new URL(frame.getAttribute('src') ?? ''); + expect(`${src.origin}${src.pathname}`).toBe( + 'http://localhost:48123/api/projects/project-1/powered/powered-wide.html', ); + expect(src.searchParams.get('v')).toBe('1710000000'); + expect(src.searchParams.get('r')).toBe('0'); + expect(src.searchParams.getAll('odPreviewBridge')).toEqual(['scroll', 'selection', 'snapshot']); + expect(src.searchParams.get('odPreviewEpoch')).toMatch(/^preview-document-\d+$/); }); const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; const previewWindow = installSandboxedPreviewWindow(frame); fireEvent.load(frame); - act(() => postPreviewContentWidth(previewWindow, 1440)); + act(() => postPreviewContentWidth(previewWindow, 1440, 900)); await waitFor(() => { expect(screen.getByRole('button', { name: '63%' })).toBeTruthy(); @@ -5658,9 +7660,13 @@ describe('FileViewer tweaks toolbar', () => { }); it('keeps desktop HTML previews at 100% when measured content already fits', async () => { + let viewerBodyWidth = 900; vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') .mockImplementation(function getBoundingClientRectMock(this: HTMLElement) { - if (this.classList.contains('viewer-body')) return testRect(0, 0, 900, 700); + if (this.classList.contains('viewer-body')) return testRect(0, 0, viewerBodyWidth, 700); + if (this instanceof HTMLIFrameElement || this.classList.contains('comment-preview-canvas')) { + return testRect(0, 0, viewerBodyWidth, 700); + } return testRect(0, 0, 0, 0); }); @@ -5689,6 +7695,12 @@ describe('FileViewer tweaks toolbar', () => { fireEvent.load(responsiveFrame); act(() => postPreviewContentWidth(previewWindow, 900)); + await waitFor(() => { + expect(screen.getByRole('button', { name: '100%' })).toBeTruthy(); + }); + viewerBodyWidth = 720; + window.dispatchEvent(new Event('resize')); + expect(screen.getByRole('button', { name: '100%' })).toBeTruthy(); await waitFor(() => { expect(screen.getByRole('button', { name: '100%' })).toBeTruthy(); }); @@ -5698,6 +7710,126 @@ describe('FileViewer tweaks toolbar', () => { expect(scaledShell).toBeTruthy(); }); + it('reuses a confirmed fixed-width witness only for the same file revision', async () => { + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function getBoundingClientRectMock(this: HTMLElement) { + if ( + this.classList.contains('viewer-body') || + this.classList.contains('comment-preview-canvas') || + this instanceof HTMLIFrameElement + ) { + return testRect(0, 0, 900, 700); + } + return testRect(0, 0, 0, 0); + }); + const file = htmlPreviewFile({ + name: 'cached-fixed-width.html', + path: 'cached-fixed-width.html', + mtime: 1710000000, + }); + const props = { + projectId: 'project-1', + projectKind: 'prototype' as const, + file, + liveHtml: '
Fixed
', + }; + + const first = render(); + const firstFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; + const firstWindow = installSandboxedPreviewWindow(firstFrame); + fireEvent.load(firstFrame); + act(() => postPreviewContentWidth(firstWindow, 1440, 900)); + await waitFor(() => { + expect(screen.getByRole('button', { name: '63%' })).toBeTruthy(); + }); + const preReloadRequest = latestPreviewContentSizeRequest(firstWindow); + const preReloadRequestCount = previewContentSizeRequests(firstWindow).length; + fireEvent.click(screen.getByRole('button', { name: /reload preview/i })); + await waitFor(() => { + expect(screen.getByRole('button', { name: '100%' })).toBeTruthy(); + }); + await Promise.resolve(); + expect(previewContentSizeRequests(firstWindow)).toHaveLength(preReloadRequestCount); + act(() => { + postPreviewContentSizeResponse(firstWindow, preReloadRequest, 96_400, 96_400); + }); + expect(screen.getByRole('button', { name: '100%' })).toBeTruthy(); + first.unmount(); + + const sameRevision = render(); + expect(screen.getByRole('button', { name: '63%' })).toBeTruthy(); + const remountedFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; + const remountedWindow = installSandboxedPreviewWindow(remountedFrame); + fireEvent.load(remountedFrame); + const remountedRequest = latestPreviewContentSizeRequest(remountedWindow); + expect(remountedRequest.measurementId).not.toBe(preReloadRequest.measurementId); + expect(remountedRequest.generation).not.toBe(preReloadRequest.generation); + expect(remountedRequest.documentEpoch).toBe(preReloadRequest.documentEpoch); + sameRevision.unmount(); + + render(); + expect(screen.getByRole('button', { name: '100%' })).toBeTruthy(); + }); + + it.each([ + ['Comment', 'board-mode-toggle'], + ['Draw', 'draw-overlay-toggle'], + ['Edit', 'manual-edit-mode-toggle'], + ])('keeps fixed-width auto-fit stable while %s freezes an older revision', async (_mode, toggleTestId) => { + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockImplementation(function getBoundingClientRectMock(this: HTMLElement) { + if ( + this.classList.contains('viewer-body') || + this.classList.contains('comment-preview-canvas') || + this instanceof HTMLIFrameElement + ) { + return testRect(0, 0, 900, 700); + } + return testRect(0, 0, 0, 0); + }); + const file = htmlPreviewFile({ + name: `annotation-frozen-width-${toggleTestId}.html`, + path: `annotation-frozen-width-${toggleTestId}.html`, + mtime: 1710000000, + }); + const view = render( + , + ); + const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; + const previewWindow = installSandboxedPreviewWindow(frame); + fireEvent.load(frame); + act(() => postPreviewContentWidth(previewWindow, 1440, 900)); + await waitFor(() => { + expect(screen.getByRole('button', { name: '63%' })).toBeTruthy(); + }); + + fireEvent.click(screen.getByTestId(toggleTestId)); + await waitFor(() => { + expect(screen.getByTestId(toggleTestId).getAttribute('aria-pressed')).toBe('true'); + }); + view.rerender( + , + ); + await Promise.resolve(); + + expect(screen.getByRole('button', { name: '63%' })).toBeTruthy(); + fireEvent.click(screen.getByTestId(toggleTestId)); + await waitFor(() => { + expect(screen.getByRole('button', { name: '100%' })).toBeTruthy(); + }); + }); + it('portals the comment composer to the preview viewport instead of the clipped canvas', async () => { vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') .mockImplementation(function getBoundingClientRectMock(this: HTMLElement) { @@ -5744,6 +7876,66 @@ describe('FileViewer tweaks toolbar', () => { }); }); + it('keeps the Comment CTA for a new element annotation in a viewer-only team project', async () => { + const collab: CollabContextValue = { + workspaceContext: teamWorkspaceContext(), + workspaceContextLoading: false, + enabled: true, + member: { memberId: 'wm-1', name: 'Member', role: 'member' }, + present: [], + publishedVersion: 1, + syncState: 'synced', + viewerOnly: true, + writerAuthority: 'denied', + isOwner: false, + isEffectiveOwner: false, + isSharedNonOwner: true, + ownerDisplayName: 'Owner', + ownerRole: 'owner', + downloadPending: false, + reportChange: () => {}, + requestPublish: () => {}, + refreshPresence: () => {}, + checkStatusNow: () => {}, + }; + + render( + + + , + ); + + clickAgentTool('board-mode-toggle'); + const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; + window.dispatchEvent(new MessageEvent('message', { + source: frame.contentWindow, + data: { + type: 'od:comment-target', + elementId: 'hero', + selector: '[data-od-id="hero"]', + label: 'Hero', + text: 'Hero', + position: { x: 8, y: 12, width: 120, height: 48 }, + hoverPoint: { x: 12, y: 16 }, + htmlHint: '
Hero
', + }, + })); + + const input = await screen.findByTestId('comment-popover-input'); + fireEvent.change(input, { target: { value: 'Please tighten this heading.' } }); + + expect(input).not.toHaveAttribute('readonly'); + expect(screen.getByTestId('comment-popover-save')).toHaveTextContent('Comment'); + expect(screen.queryByTestId('comment-add-send')).toBeNull(); + }); + it('docks the comment side panel outside the clickable preview canvas', () => { vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect') .mockImplementation(function getBoundingClientRectMock(this: HTMLElement) { @@ -6049,7 +8241,13 @@ describe('FileViewer tweaks toolbar', () => { expect(screen.queryByTestId('comment-saved-marker-slide-one-title')).toBeNull(); }); - it('orders side comments by creation time while keeping activity timestamps', () => { + it('orders side comments by creation time (newest first) while keeping activity timestamps', () => { + // recvq5BVsolIxi: default sidebar order is "newest CREATED first", not + // "most recently ACTIVE first" — the comment updated most recently + // (`createdFirstUpdatedLast`, note "Latest edit") is actually the OLDER + // of the two by creation time, so it must sort SECOND despite its + // updatedAt being the most recent ("just now" still renders on it, just + // not first in the list). const createdFirstUpdatedLast: PreviewComment = { id: 'comment-updated-last', projectId: 'project-1', @@ -6093,9 +8291,13 @@ describe('FileViewer tweaks toolbar', () => { const [firstItem, secondItem] = items; expect(firstItem).toBeDefined(); expect(secondItem).toBeDefined(); - expect(firstItem!.textContent).toContain('Latest edit'); - expect(firstItem!.textContent).toContain('just now'); - expect(secondItem!.textContent).toContain('Older edit'); + // Created most recently (5 minutes ago) → shows first by default, even + // though ITS OWN last activity ("Older edit"'s updatedAt) is older. + expect(firstItem!.textContent).toContain('Older edit'); + // Created earliest (20 minutes ago) → sorts second, despite being the + // most recently ACTIVE comment ("just now"). + expect(secondItem!.textContent).toContain('Latest edit'); + expect(secondItem!.textContent).toContain('just now'); }); it('does not preload non-open element comments into the picker composer', async () => { @@ -6347,6 +8549,11 @@ describe('FileViewer tweaks toolbar', () => { }); it('keeps saved marker numbers stable after saving another comment', async () => { + // pinSeq is what actually pins the marker number now (recvq5BVsolIxi) — + // set explicitly here exactly as the daemon would assign it at creation + // (1, 2, then 3), independent of each fixture's deliberately-out-of-order + // createdAt below (which exists only to prove the number does NOT + // recompute from creation time or array position). const olderComment: PreviewComment = { id: 'comment-older', projectId: 'project-1', @@ -6362,6 +8569,7 @@ describe('FileViewer tweaks toolbar', () => { status: 'open', createdAt: 10, updatedAt: 10, + pinSeq: 1, }; const newerComment: PreviewComment = { ...olderComment, @@ -6373,6 +8581,7 @@ describe('FileViewer tweaks toolbar', () => { note: 'Newer comment', createdAt: 20, updatedAt: 20, + pinSeq: 2, }; function Harness() { @@ -6404,6 +8613,7 @@ describe('FileViewer tweaks toolbar', () => { status: 'open', createdAt: 5, updatedAt: 30, + pinSeq: 3, }; setComments((current) => [saved, ...current]); return saved; @@ -6446,7 +8656,10 @@ describe('FileViewer tweaks toolbar', () => { }); it('lets element comments queue to chat while a task is running', async () => { - const onSendBoardCommentAttachments = vi.fn().mockResolvedValue(undefined); + const onSendBoardCommentAttachments = vi.fn().mockResolvedValue({ + status: 'queued', + commentIds: ['hero-board-1'], + }); render( { }); it('keeps the comment draft when chat queueing declines the send', async () => { - const onSendBoardCommentAttachments = vi.fn().mockResolvedValue(false); + const onSendBoardCommentAttachments = vi.fn().mockResolvedValue({ + status: 'rejected', + commentIds: [], + }); render( { fireEvent.mouseLeave(card); expect(screen.queryByTestId('annotation-hover-popover')).not.toBeNull(); - // A re-hover (pointer landed back on the element) cancels the pending - // dismiss, so the card stays put rather than blinking out. - window.dispatchEvent(new MessageEvent('message', { - source: frame.contentWindow, - data: { ...target, type: 'od:comment-hover' }, + // A re-hover (pointer landed back on the element) cancels the pending + // dismiss, so the card stays put rather than blinking out. + window.dispatchEvent(new MessageEvent('message', { + source: frame.contentWindow, + data: { ...target, type: 'od:comment-hover' }, + })); + + await new Promise((resolve) => setTimeout(resolve, 140)); + expect(screen.queryByTestId('annotation-hover-popover')).not.toBeNull(); + }); + + it('closes an open saved-comment composer when that comment leaves the open state', async () => { + const openComment: PreviewComment = { + id: 'comment-status-transition', + projectId: 'project-1', + conversationId: 'conversation-1', + filePath: 'preview.html', + elementId: 'pin-transition', + selector: '[data-od-pin="pin-transition"]', + label: 'pin-transition', + text: '', + htmlHint: '', + position: { x: 40, y: 52, width: 18, height: 18 }, + note: 'Do not recreate this stale comment', + status: 'open', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByTestId('comment-panel-toggle')); + fireEvent.click(screen.getByRole('button', { name: 'Open comment for pin-transition' })); + + expect((await screen.findByTestId('comment-popover-input') as HTMLTextAreaElement).value) + .toBe('Do not recreate this stale comment'); + + rerender( + , + ); + + await waitFor(() => { + expect(screen.queryByTestId('comment-popover-input')).toBeNull(); + }); + expect(screen.queryByTestId('comment-saved-marker-pin-transition')).toBeNull(); + expect(screen.queryByText('Do not recreate this stale comment')).toBeNull(); + }); + + it('keeps a saved comment open when deletion is rejected', async () => { + const comment: PreviewComment = { + id: 'comment-delete-rejected', + projectId: 'project-1', + conversationId: 'conversation-1', + filePath: 'preview.html', + elementId: 'pin-delete-rejected', + selector: '[data-od-pin="pin-delete-rejected"]', + label: 'pin-delete-rejected', + text: '', + htmlHint: '', + position: { x: 40, y: 52, width: 18, height: 18 }, + note: 'Retain me', + status: 'open', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + const onRemovePreviewComment = vi.fn().mockResolvedValue(false); + + render( + , + ); + + fireEvent.click(screen.getByTestId('comment-panel-toggle')); + fireEvent.click(screen.getByRole('button', { + name: 'Open comment for pin-delete-rejected', })); + fireEvent.click(await screen.findByRole('button', { name: 'Delete' })); - await new Promise((resolve) => setTimeout(resolve, 140)); - expect(screen.queryByTestId('annotation-hover-popover')).not.toBeNull(); + await waitFor(() => expect(onRemovePreviewComment).toHaveBeenCalledWith(comment.id)); + expect(screen.getByTestId('comment-popover-input')).toBeTruthy(); + expect(screen.getByTestId('comment-saved-marker-pin-delete-rejected')).toBeTruthy(); }); - it('closes an open saved-comment composer when that comment leaves the open state', async () => { - const openComment: PreviewComment = { - id: 'comment-status-transition', + it('keeps a saved comment when send is rejected and removes it only after queue acceptance', async () => { + const comment: PreviewComment = { + id: 'comment-send-result', projectId: 'project-1', conversationId: 'conversation-1', filePath: 'preview.html', - elementId: 'pin-transition', - selector: '[data-od-pin="pin-transition"]', - label: 'pin-transition', + elementId: 'pin-send-result', + selector: '[data-od-pin="pin-send-result"]', + label: 'pin-send-result', text: '', htmlHint: '', position: { x: 40, y: 52, width: 18, height: 18 }, - note: 'Do not recreate this stale comment', + note: 'Send me safely', status: 'open', createdAt: Date.now(), updatedAt: Date.now(), }; + const onSendBoardCommentAttachments = vi.fn() + .mockResolvedValueOnce({ status: 'rejected', commentIds: [] }) + .mockResolvedValueOnce({ status: 'queued', commentIds: [comment.id] }); + const onRemovePreviewComment = vi.fn().mockResolvedValue(true); - const { rerender } = render( + render( , ); fireEvent.click(screen.getByTestId('comment-panel-toggle')); - fireEvent.click(screen.getByRole('button', { name: 'Open comment for pin-transition' })); + fireEvent.click(screen.getByRole('button', { + name: 'Open comment for pin-send-result', + })); + fireEvent.click(screen.getByTestId('comment-add-send')); - expect((await screen.findByTestId('comment-popover-input') as HTMLTextAreaElement).value) - .toBe('Do not recreate this stale comment'); + await waitFor(() => expect(onSendBoardCommentAttachments).toHaveBeenCalledTimes(1)); + expect(onRemovePreviewComment).not.toHaveBeenCalled(); + expect(screen.getByTestId('comment-popover-input')).toBeTruthy(); - rerender( + await waitFor(() => { + expect((screen.getByTestId('comment-add-send') as HTMLButtonElement).disabled).toBe(false); + }); + fireEvent.click(screen.getByTestId('comment-add-send')); + await waitFor(() => expect(onSendBoardCommentAttachments).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(onRemovePreviewComment).toHaveBeenCalledWith(comment.id)); + await waitFor(() => expect(screen.queryByTestId('comment-popover')).toBeNull()); + }); + + it('keeps a queued saved comment visible when no persistence removal callback exists', async () => { + const comment: PreviewComment = { + id: 'comment-send-without-removal', + projectId: 'project-1', + conversationId: 'conversation-1', + filePath: 'preview.html', + elementId: 'pin-send-without-removal', + selector: '[data-od-pin="pin-send-without-removal"]', + label: 'pin-send-without-removal', + text: '', + htmlHint: '', + position: { x: 40, y: 52, width: 18, height: 18 }, + note: 'Keep until persistence can remove me', + status: 'open', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + const onSendBoardCommentAttachments = vi.fn().mockResolvedValue({ + status: 'queued', + commentIds: [comment.id], + }); + + render( , ); - await waitFor(() => { - expect(screen.queryByTestId('comment-popover-input')).toBeNull(); + fireEvent.click(screen.getByTestId('comment-panel-toggle')); + fireEvent.click(screen.getByRole('button', { + name: 'Open comment for pin-send-without-removal', + })); + fireEvent.click(screen.getByTestId('comment-add-send')); + + await waitFor(() => expect(onSendBoardCommentAttachments).toHaveBeenCalledTimes(1)); + expect(screen.getByTestId('comment-popover-input')).toBeTruthy(); + expect(screen.getByTestId('comment-saved-marker-pin-send-without-removal')).toBeTruthy(); + }); + + it('removes only comments that were queued before a later selected send is rejected', async () => { + const comments: PreviewComment[] = [ + { + id: 'comment-partial-first', + projectId: 'project-1', + conversationId: 'conversation-1', + filePath: 'preview.html', + elementId: 'pin-partial-first', + selector: '[data-od-pin="pin-partial-first"]', + label: 'pin-partial-first', + text: '', + htmlHint: '', + position: { x: 20, y: 24, width: 18, height: 18 }, + note: 'First queued comment', + status: 'open', + createdAt: 10, + updatedAt: 10, + }, + { + id: 'comment-partial-second', + projectId: 'project-1', + conversationId: 'conversation-1', + filePath: 'preview.html', + elementId: 'pin-partial-second', + selector: '[data-od-pin="pin-partial-second"]', + label: 'pin-partial-second', + text: '', + htmlHint: '', + position: { x: 48, y: 24, width: 18, height: 18 }, + note: 'Second rejected comment', + status: 'open', + createdAt: 20, + updatedAt: 20, + }, + ]; + const removed: string[] = []; + const onSendBoardCommentAttachments = vi.fn().mockResolvedValue({ + status: 'rejected', + commentIds: [comments[0]!.id], }); - expect(screen.queryByTestId('comment-saved-marker-pin-transition')).toBeNull(); - expect(screen.queryByText('Do not recreate this stale comment')).toBeNull(); + + function Harness() { + const [previewComments, setPreviewComments] = useState(comments); + return ( + { + removed.push(commentId); + setPreviewComments((current) => ( + current.filter((comment) => comment.id !== commentId) + )); + return true; + }} + /> + ); + } + + render(); + fireEvent.click(screen.getByTestId('comment-panel-toggle')); + const selectButtons = screen.getAllByRole('button', { name: 'Select' }); + expect(selectButtons).toHaveLength(2); + for (const button of selectButtons) fireEvent.click(button); + fireEvent.click(screen.getByTestId('comment-side-send-claude')); + + await waitFor(() => expect(onSendBoardCommentAttachments).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(removed).toEqual([comments[0]!.id])); + expect(screen.queryByText('First queued comment')).toBeNull(); + expect(screen.getByText('Second rejected comment')).toBeTruthy(); + expect(screen.getByTestId('comment-side-selectbar').textContent).toContain('1 selected'); }); it('moves focus between comment side panel toggles when collapsing and expanding without a pre-focused click target', async () => { @@ -6914,6 +9344,119 @@ describe('FileViewer tweaks toolbar', () => { expect(showComments.getAttribute('aria-expanded')).toBe('false'); }); + it('renders the signed-in user own avatar and name on their comment when the member roster is empty', async () => { + // A personal workspace (and the cold window before a team roster lands) + // answers `/api/workspace/members` 200 with an empty list, so NOTHING + // resolves through the directory — including the viewer themselves. The + // viewer's own identity must still render: it comes from the workspace + // context the caller already holds, not from this roster. + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/api/workspace/members')) { + return new Response(JSON.stringify({ members: [] }), { status: 200 }); + } + return new Response(JSON.stringify({}), { status: 200 }); + }), + ); + + const comment: PreviewComment = { + id: 'comment-mine', + projectId: 'project-1', + conversationId: 'conversation-1', + filePath: 'preview.html', + elementId: 'hero-copy', + selector: '[data-od-id="hero-copy"]', + label: 'Hero copy', + text: 'Hero copy', + htmlHint: '

', + position: { x: 16, y: 24, width: 320, height: 48 }, + note: 'Tighten this headline.', + status: 'open', + authorMemberId: 'wm-self', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + render( + {}} + onToggleSelect={() => {}} + onSelectAll={() => {}} + onClearSelection={() => {}} + onReply={() => {}} + onSendSelected={() => {}} + sending={false} + t={t} + />, + ); + + const item = await screen.findByTestId('comment-side-item'); + await waitFor(() => { + expect(item.querySelector('.comment-side-avatar')?.textContent).toBe('琼'); + }); + expect(within(item).getByText(/琼羽/)).toBeTruthy(); + }); + + it('leaves a comment by an unresolved other member on its id-only rendering', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString(); + if (url.includes('/api/workspace/members')) { + return new Response(JSON.stringify({ members: [] }), { status: 200 }); + } + return new Response(JSON.stringify({}), { status: 200 }); + }), + ); + + const comment: PreviewComment = { + id: 'comment-theirs', + projectId: 'project-1', + conversationId: 'conversation-1', + filePath: 'preview.html', + elementId: 'hero-copy', + selector: '[data-od-id="hero-copy"]', + label: 'Hero copy', + text: 'Hero copy', + htmlHint: '

', + position: { x: 16, y: 24, width: 320, height: 48 }, + note: 'Tighten this headline.', + status: 'open', + authorMemberId: 'wm-someone-else', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + render( + {}} + onToggleSelect={() => {}} + onSelectAll={() => {}} + onClearSelection={() => {}} + onReply={() => {}} + onSendSelected={() => {}} + sending={false} + t={t} + />, + ); + + const item = await screen.findByTestId('comment-side-item'); + expect(item.querySelector('.comment-side-avatar')).toBeNull(); + expect(within(item).queryByText(/琼羽/)).toBeNull(); + }); + it('lets the inspect panel shrink inside narrow preview layouts', () => { const css = readFileSync(join(process.cwd(), 'src/styles/viewer/core.css'), 'utf8'); const rule = css.match(/\.inspect-panel\s*\{[^}]+\}/)?.[0] ?? ''; @@ -6993,33 +9536,161 @@ describe('FileViewer tweaks toolbar', () => { fireDragEventWithClientY('dragOver', items[0]!, { dataTransfer, clientY: 0 }); fireDragEventWithClientY('drop', items[0]!, { dataTransfer, clientY: 0 }); - expect(onReorder).toHaveBeenCalledWith(['comment-2', 'comment-1']); + // recvq5BVsolIxi Phase 2: onReorder now also reports WHICH comment moved, + // so the caller can persist just that one row's sort_key. + expect(onReorder).toHaveBeenCalledWith(['comment-2', 'comment-1'], 'comment-2'); }); - it('appends a newly saved comment to the current visible comment order', () => { + it('computes a persisted sort_key for a drag-reorder as a midpoint between the new neighbors', () => { + const older: PreviewComment = { + id: 'comment-older', + projectId: 'project-1', + conversationId: 'conversation-1', + filePath: 'preview.html', + elementId: 'a', + selector: '[data-od-id="a"]', + label: 'A', + text: '', + htmlHint: '', + position: { x: 0, y: 0, width: 0, height: 0 }, + note: 'Older', + status: 'open', + createdAt: 10, + updatedAt: 10, + sortKey: 10, + }; + const middle: PreviewComment = { ...older, id: 'comment-middle', note: 'Middle', createdAt: 20, updatedAt: 20, sortKey: 20 }; + const newest: PreviewComment = { ...older, id: 'comment-newest', note: 'Newest', createdAt: 30, updatedAt: 30, sortKey: 30 }; + // Sidebar's current (pre-drag) display order is sortKey descending. + const comments = [newest, middle, older]; + + // Drag "older" (sortKey 10) between "newest" (30) and "middle" (20) — + // a midpoint, and neither existing sortKey is disturbed. expect( - appendSavedPreviewCommentOrder( - [], - [{ id: 'comment-1' }, { id: 'comment-2' }], - 'comment-3', - ), - ).toEqual(['comment-1', 'comment-2', 'comment-3']); + computeReorderedSortKey(comments, ['comment-newest', 'comment-older', 'comment-middle'], 'comment-older'), + ).toBe(25); + // Drag "middle" to the very front (past "newest", the new sole neighbor + // below it) — one past the current max, so it's now the front-most. expect( - appendSavedPreviewCommentOrder( - ['comment-2', 'comment-1'], - [{ id: 'comment-1' }, { id: 'comment-2' }], - 'comment-3', - ), - ).toEqual(['comment-2', 'comment-1', 'comment-3']); + computeReorderedSortKey(comments, ['comment-middle', 'comment-newest', 'comment-older'], 'comment-middle'), + ).toBe(31); + // Drag "newest" to the very back (past "older", its new sole neighbor + // above) — one below the current min. expect( - appendSavedPreviewCommentOrder( - ['comment-1', 'comment-2'], - [{ id: 'comment-1' }, { id: 'comment-2' }], - 'comment-2', - ), - ).toEqual(['comment-1', 'comment-2']); + computeReorderedSortKey(comments, ['comment-middle', 'comment-older', 'comment-newest'], 'comment-newest'), + ).toBe(9); + }); + + it('shows the newest comment first by default (recvq5BVsolIxi)', () => { + const older: PreviewComment = { + id: 'comment-older', + projectId: 'project-1', + conversationId: 'conversation-1', + filePath: 'preview.html', + elementId: 'a', + selector: '[data-od-id="a"]', + label: 'A', + text: '', + htmlHint: '', + position: { x: 0, y: 0, width: 0, height: 0 }, + note: 'First comment ever', + status: 'open', + createdAt: 10, + updatedAt: 10, + }; + const newer: PreviewComment = { ...older, id: 'comment-newer', note: 'Just posted', createdAt: 20, updatedAt: 20 }; + + render( + , + ); + fireEvent.click(screen.getByTestId('comment-panel-toggle')); + + const items = screen.getAllByTestId('comment-side-item'); + // Neither fixture sets `sortKey` — the default falls back to createdAt, + // so the more-recently-created comment ("Just posted") leads the list + // even though it was passed SECOND in `previewComments`. + expect(items[0]!.textContent).toContain('Just posted'); + expect(items[1]!.textContent).toContain('First comment ever'); + }); + + it('persists a drag reorder via sort_key and keeps it after the comment list refreshes (recvq5BVsolIxi)', async () => { + const onReorderPreviewComment = vi.fn().mockResolvedValue(undefined); + const commentA: PreviewComment = { + id: 'comment-a', + projectId: 'project-1', + conversationId: 'conversation-1', + filePath: 'preview.html', + elementId: 'a', + selector: '[data-od-id="a"]', + label: 'A', + text: '', + htmlHint: '', + position: { x: 0, y: 0, width: 0, height: 0 }, + note: 'Comment A', + status: 'open', + createdAt: 10, + updatedAt: 10, + sortKey: 10, + }; + const commentB: PreviewComment = { ...commentA, id: 'comment-b', note: 'Comment B', createdAt: 20, updatedAt: 20, sortKey: 20 }; + + const { rerender } = render( + , + ); + fireEvent.click(screen.getByTestId('comment-panel-toggle')); + + // Default order: B (sortKey 20) first, A (sortKey 10) second. + let items = screen.getAllByTestId('comment-side-item'); + expect(items[0]!.getAttribute('data-comment-id')).toBe('comment-b'); + expect(items[1]!.getAttribute('data-comment-id')).toBe('comment-a'); + + // Drag A (currently second, the drag handle at index 1) above B. + items[0]!.getBoundingClientRect = vi.fn(() => ({ + x: 0, y: 0, top: 0, left: 0, right: 300, bottom: 40, width: 300, height: 40, toJSON: () => ({}), + })); + const dataTransfer = createDragDataTransfer(); + // FileViewer renders through the real i18n default (English), unlike the + // CommentSidePanel-direct tests above that inject a key-echoing `t` stub — + // so the accessible label is the actual translated copy, not the raw key. + fireEvent.dragStart(screen.getAllByLabelText('Drag to reorder')[1]!, { dataTransfer }); + fireDragEventWithClientY('dragOver', items[0]!, { dataTransfer, clientY: 0 }); + fireDragEventWithClientY('drop', items[0]!, { dataTransfer, clientY: 0 }); + + // No neighbor above A's new (front) position, so its sort_key becomes + // one past B's — a PATCH request, not a whole-list renumber. + await waitFor(() => expect(onReorderPreviewComment).toHaveBeenCalledWith('comment-a', 21)); + + // Simulate the daemon having persisted it and the parent re-fetching: + // re-render with the updated sortKey already applied, standing in for a + // refresh/tab-switch. The dragged order must survive it. + rerender( + , + ); + items = screen.getAllByTestId('comment-side-item'); + expect(items[0]!.getAttribute('data-comment-id')).toBe('comment-a'); + expect(items[1]!.getAttribute('data-comment-id')).toBe('comment-b'); }); it('does not classify text labels containing a standalone article as links', () => { @@ -7110,6 +9781,7 @@ describe('FileViewer tweaks toolbar', () => { onRemovePreviewComment={async (commentId) => { removed.push(commentId); setComments((current) => current.filter((comment) => comment.id !== commentId)); + return true; }} /> ); diff --git a/apps/web/tests/components/FileWorkspace.design-system.test.tsx b/apps/web/tests/components/FileWorkspace.design-system.test.tsx index 7d9b2237a2e..64524aac9d3 100644 --- a/apps/web/tests/components/FileWorkspace.design-system.test.tsx +++ b/apps/web/tests/components/FileWorkspace.design-system.test.tsx @@ -212,6 +212,7 @@ describe('FileWorkspace design-system project surface', () => { designSystemProject={designSystem()} designSystemBrandId="brand-acme" designSystemEditable={false} + designSystemExtractionInProgress />, ); @@ -233,6 +234,40 @@ describe('FileWorkspace design-system project surface', () => { expect(container.textContent).not.toContain('Edit DESIGN.md'); }); + // recvqb6mfyqXLD: `designSystemEditable=false` now also covers "the caller + // may not manage this team-synced design system" (ProjectView's + // `canMutate` gate), not just "extraction still running". The status pill + // must key off the separate `designSystemExtractionInProgress` flag so a + // finished, published teammate's design system reads as complete — not as + // still extracting — while the Publish toggle and edit affordances stay + // locked. + it('reads as extraction-complete (not "still extracting") when locked only because the caller cannot manage a team-synced system', async () => { + registryMocks.fetchProjectFileText.mockResolvedValue(null); + + const container = renderWorkspace( + , + ); + + await flushKit(); + + expect(container.querySelector('[data-testid="design-system-project-tab"]')).toBeTruthy(); + expect(container.textContent).not.toContain('Extracting design system'); + expect(container.textContent).toContain('Extraction complete'); + expect(container.querySelector('[data-testid="design-system-publish"]')?.disabled).toBe(true); + }); + it('edits and resets palette colors through the color editor dialog', async () => { let designMdBody = [ '# Acme', @@ -294,6 +329,8 @@ describe('FileWorkspace design-system project surface', () => { 'ds-acme', 'DESIGN.md', expect.stringContaining('`#FF6A3D`'), + undefined, + null, )); await flushKit(); @@ -316,6 +353,8 @@ describe('FileWorkspace design-system project surface', () => { 'ds-acme', 'DESIGN.md', expect.stringContaining('`#10B981`'), + undefined, + null, )); }); @@ -356,6 +395,24 @@ describe('FileWorkspace design-system project surface', () => { if (url.includes('/raw/fonts/') || url.includes('/raw/system/tokens.')) { return new Response(null, { status: 404 }); } + if (url === '/api/workspace/projects/team') { + return new Response(JSON.stringify({ projects: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url === '/api/projects/ds-acme/collab/status') { + return new Response(JSON.stringify({ syncState: 'local_only' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url === '/api/workspace/context') { + return new Response(JSON.stringify({ context: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } events.push(url); return new Response(JSON.stringify({ id: 'brand-acme' }), { status: 200, @@ -412,6 +469,8 @@ describe('FileWorkspace design-system project surface', () => { 'ds-acme', 'brand.json', expect.stringContaining('"hex": "#FF6A3D"'), + undefined, + null, )); await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( '/api/brands/brand-acme/finalize', @@ -469,6 +528,24 @@ describe('FileWorkspace design-system project surface', () => { if (url.includes('/raw/fonts/') || url.includes('/raw/system/tokens.')) { return new Response(null, { status: 404 }); } + if (url === '/api/workspace/projects/team') { + return new Response(JSON.stringify({ projects: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url === '/api/projects/ds-acme/collab/status') { + return new Response(JSON.stringify({ syncState: 'local_only' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url === '/api/workspace/context') { + return new Response(JSON.stringify({ context: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } events.push(url); return new Response(JSON.stringify({ id: 'brand-acme' }), { status: 200, @@ -512,8 +589,14 @@ describe('FileWorkspace design-system project surface', () => { 'ds-acme', 'brand.json', expect.not.stringContaining('imagery/hero.png'), + undefined, + null, )); - expect(registryMocks.deleteProjectFile).toHaveBeenCalledWith('ds-acme', 'imagery/hero.png'); + expect(registryMocks.deleteProjectFile).toHaveBeenCalledWith( + 'ds-acme', + 'imagery/hero.png', + null, + ); expect(fetchMock).toHaveBeenCalledWith( '/api/brands/brand-acme/finalize', expect.objectContaining({ @@ -551,6 +634,24 @@ describe('FileWorkspace design-system project surface', () => { if (url.includes('/raw/fonts/') || url.includes('/raw/system/tokens.')) { return new Response(null, { status: 404 }); } + if (url === '/api/workspace/projects/team') { + return new Response(JSON.stringify({ projects: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url === '/api/projects/ds-acme/collab/status') { + return new Response(JSON.stringify({ syncState: 'local_only' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url === '/api/workspace/context') { + return new Response(JSON.stringify({ context: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } events.push(url); if (url === '/api/brands/brand-acme/finalize') { return new Response(JSON.stringify({ id: 'brand-acme' }), { @@ -836,7 +937,7 @@ describe('FileWorkspace design-system project surface', () => { expect(registryMocks.updateDesignSystemDraft).toHaveBeenCalledWith('user:acme', { status: 'published', - }); + }, null); expect(onRefresh).toHaveBeenCalledOnce(); }); diff --git a/apps/web/tests/components/FileWorkspace.test.tsx b/apps/web/tests/components/FileWorkspace.test.tsx index d408bf522ac..21c6a9f2c57 100644 --- a/apps/web/tests/components/FileWorkspace.test.tsx +++ b/apps/web/tests/components/FileWorkspace.test.tsx @@ -3,16 +3,24 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import { act } from 'react'; +import { act, useState } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { renderToStaticMarkup } from 'react-dom/server'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + buildWorkspacePermissions, + buildWorkspaceSeatSummary, + type WorkspaceCollabContext, +} from '@open-design/contracts'; import { DESIGN_FILES_TAB, FileWorkspace, + settleManualEditFiles, scrollWorkspaceTabsWithWheel, + settleManualEditExit, } from '../../src/components/FileWorkspace'; +import { ENABLE_BLANK_PAGE_WORKSPACE_ENTRYPOINT } from '../../src/components/workspace/tab-launcher'; import { I18nProvider } from '../../src/i18n'; import { DesignFilesPanel } from '../../src/components/DesignFilesPanel'; import { projectSplitClassName, projectSplitStyle } from '../../src/components/ProjectView'; @@ -22,7 +30,35 @@ import { writeProjectTextFile, fetchProjectFolders, } from '../../src/providers/registry'; -import type { ChatMessage, ProjectFile, ProjectFolder } from '../../src/types'; +import type { ChatMessage, OpenTabsState, ProjectFile, ProjectFolder } from '../../src/types'; +import { + CollabProvider, + type CollabContextValue, +} from '../../src/collab/collab-context'; +import { IframeKeepAliveProvider } from '../../src/components/IframeKeepAlivePool'; +import { navigate } from '../../src/router'; + +describe('settleManualEditExit', () => { + it.each([ + ['asynchronously', () => Promise.reject(new Error('save failed'))], + ['synchronously', () => { throw new Error('save failed'); }], + ])('treats an exit handler that rejects %s as an unsafe exit', async (_label, exit) => { + await expect(settleManualEditExit(exit)).resolves.toBe(false); + }); + + it('settles every protected file instead of trusting only the active tab', async () => { + const settle = vi.fn(async (fileName: string) => fileName !== 'offscreen.html'); + + await expect(settleManualEditFiles( + ['active.html', 'offscreen.html', 'offscreen.html'], + settle, + )).resolves.toBe(false); + expect(settle.mock.calls.map(([fileName]) => fileName)).toEqual([ + 'active.html', + 'offscreen.html', + ]); + }); +}); vi.mock('../../src/providers/registry', async () => { const actual = await vi.importActual( @@ -227,6 +263,7 @@ afterEach(() => { composerCssStyle = null; host?.remove(); host = null; + window.history.replaceState(null, '', '/'); vi.clearAllMocks(); vi.restoreAllMocks(); vi.useRealTimers(); @@ -258,6 +295,50 @@ function workspaceFile(name: string): ProjectFile { }; } +function teamContext( + workspaceId: string, + workspaceMemberId: string, +): WorkspaceCollabContext { + return { + workspaceId, + workspaceType: 'team', + workspaceMemberId, + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + billingState: 'active', + planId: 'team_plus', + providerMode: 'platform_credits', + teamId: `team-${workspaceId}`, + seatSummary: buildWorkspaceSeatSummary({ seatLimit: 3, usedSeats: 1 }), + permissions: buildWorkspacePermissions({ role: 'owner', lifecycleState: 'active' }), + }; +} + +function collabValue(workspaceContext: WorkspaceCollabContext): CollabContextValue { + return { + workspaceContext, + workspaceContextLoading: false, + enabled: false, + member: null, + present: [], + publishedVersion: null, + syncState: null, + viewerOnly: false, + writerAuthority: 'allowed', + isOwner: true, + isEffectiveOwner: true, + isSharedNonOwner: false, + ownerDisplayName: null, + ownerRole: null, + downloadPending: false, + reportChange: () => {}, + requestPublish: () => {}, + refreshPresence: () => {}, + checkStatusNow: () => {}, + }; +} + function cssDeclarations(css: string, selector: string): string { const blocks: string[] = []; const rulePattern = /([^{}]+)\{([^}]*)\}/g; @@ -592,7 +673,11 @@ describe('FileWorkspace quick switcher visual isolation', () => { }); expect(getComputedStyle(composerControl).pointerEvents).toBe('auto'); expect(getComputedStyle(composerLayer).opacity).not.toBe('0.58'); - expect(getComputedStyle(composerInputWrap).background).toBe('var(--bg-panel)'); + // Once the quick switcher closes, the composer input returns to its resting + // background (no longer the dimmed --bg-fill-tertiary isolation wash). The + // #5517 restyle makes that resting fill a subtle color-mix tint of + // --bg-panel/--bg-subtle, which resolves to white in the test theme. + expect(getComputedStyle(composerInputWrap).background).toBe('rgb(255, 255, 255)'); }); }); @@ -680,7 +765,10 @@ describe('FileWorkspace upload input', () => { ); }); - it('creates slide template pages without default speaker notes', async () => { + // PageCreator flows are unreachable while the 新建空白页面 launcher entry + // is paused (see ENABLE_BLANK_PAGE_WORKSPACE_ENTRYPOINT); these suites + // revive automatically when the switch flips back. + it.skipIf(!ENABLE_BLANK_PAGE_WORKSPACE_ENTRYPOINT)('creates slide template pages without default speaker notes', async () => { const onRefreshFiles = vi.fn(); const onTabsStateChange = vi.fn(); vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { @@ -736,8 +824,8 @@ describe('FileWorkspace upload input', () => { />, ); - fireEvent.click(screen.getByTestId('workspace-pages-menu-trigger')); - fireEvent.click(screen.getByRole('menuitem', { name: /New blank page/i })); + fireEvent.click(screen.getByTestId('workspace-add-tab')); + fireEvent.click(screen.getByRole('button', { name: /New blank page/i })); const title = await screen.findByText('Clean Deck'); const card = title.closest('article'); expect(card).not.toBeNull(); @@ -756,7 +844,10 @@ describe('FileWorkspace upload input', () => { await waitFor(() => expect(onRefreshFiles).toHaveBeenCalledTimes(1)); }); - it('localizes page creator content and saves template query as the first version prompt', async () => { + // PageCreator flows are unreachable while the 新建空白页面 launcher entry + // is paused (see ENABLE_BLANK_PAGE_WORKSPACE_ENTRYPOINT); these suites + // revive automatically when the switch flips back. + it.skipIf(!ENABLE_BLANK_PAGE_WORKSPACE_ENTRYPOINT)('localizes page creator content and saves template query as the first version prompt', async () => { const onRefreshFiles = vi.fn(); const onTabsStateChange = vi.fn(); vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { @@ -822,8 +913,8 @@ describe('FileWorkspace upload input', () => { , ); - fireEvent.click(screen.getByTestId('workspace-pages-menu-trigger')); - fireEvent.click(screen.getByRole('menuitem', { name: /新建空白页面/ })); + fireEvent.click(screen.getByTestId('workspace-add-tab')); + fireEvent.click(screen.getByRole('button', { name: /新建空白页面/ })); const dialog = await screen.findByRole('dialog', { name: '新建页面' }); const dialogScope = within(dialog); @@ -857,7 +948,10 @@ describe('FileWorkspace upload input', () => { await waitFor(() => expect(onRefreshFiles).toHaveBeenCalledTimes(1)); }); - it('hides blank cards and media category entries in the page creator dialog', async () => { + // PageCreator flows are unreachable while the 新建空白页面 launcher entry + // is paused (see ENABLE_BLANK_PAGE_WORKSPACE_ENTRYPOINT); these suites + // revive automatically when the switch flips back. + it.skipIf(!ENABLE_BLANK_PAGE_WORKSPACE_ENTRYPOINT)('hides blank cards and media category entries in the page creator dialog', async () => { render( { />, ); - fireEvent.click(screen.getByTestId('workspace-pages-menu-trigger')); - fireEvent.click(screen.getByRole('menuitem', { name: /New blank page/i })); + fireEvent.click(screen.getByTestId('workspace-add-tab')); + fireEvent.click(screen.getByRole('button', { name: /New blank page/i })); const dialog = await screen.findByRole('dialog', { name: 'Create page' }); const dialogScope = within(dialog); @@ -882,8 +976,9 @@ describe('FileWorkspace upload input', () => { expect(dialogScope.queryByRole('button', { name: /^Audio\b/i })).toBeNull(); }); - it('hides upload failure details during in-panel preview and restores them after closing preview', async () => { + it('reports an upload failure until dismissed, and opens a file on a single card click', async () => { mockedUploadProjectFiles.mockRejectedValueOnce(new Error('storage offline')); + const onTabsStateChange = vi.fn(); render( { onRefreshFiles={vi.fn()} isDeck={false} tabsState={{ tabs: [], active: null }} - onTabsStateChange={vi.fn()} + onTabsStateChange={onTabsStateChange} />, ); @@ -908,25 +1003,21 @@ describe('FileWorkspace upload input', () => { ); }); - const row = screen.getByTestId('design-file-row-mock.png'); - const nameButton = row.querySelector('.df-row-name-btn'); - if (!nameButton) throw new Error('Could not find file name button'); - fireEvent.click(nameButton); - - expect(screen.getByTestId('design-file-preview')).toBeTruthy(); + fireEvent.click(screen.getByTestId('upload-error-dismiss')); expect(screen.queryByTestId('upload-error-banner')).toBeNull(); - fireEvent.click(screen.getByRole('button', { name: 'Close preview' })); - - await waitFor(() => { - expect(screen.getByTestId('upload-error-banner').textContent).toContain( - 'storage offline', - ); - }); - - fireEvent.click(screen.getByTestId('upload-error-dismiss')); + // Images render as masonry cards; a single click on the thumb opens the + // file in a workspace tab (there is no in-panel preview pane to land in). + const row = screen.getByTestId('design-file-row-mock.png'); + const thumbButton = row.querySelector('.df-card-thumb'); + if (!thumbButton) throw new Error('Could not find file thumb button'); + fireEvent.click(thumbButton); - expect(screen.queryByTestId('upload-error-banner')).toBeNull(); + await waitFor(() => + expect(onTabsStateChange).toHaveBeenCalledWith( + expect.objectContaining({ active: 'mock.png' }), + ), + ); }); it('keeps partial upload failures visible after a successful file opens', async () => { @@ -989,6 +1080,9 @@ describe('FileWorkspace upload input', () => { const { container, rerender } = render(); + // Folder rows live behind the Folders category tab (the default tab is + // Pages whenever HTML files exist at the current level). + fireEvent.click(screen.getByTestId('design-files-tab-folders')); fireEvent.click(container.querySelector('.df-dir-row .df-row-name-btn')!); expect(container.querySelector('.df-breadcrumb-current')?.textContent).toBe('assets'); @@ -1003,7 +1097,9 @@ describe('FileWorkspace upload input', () => { />, ); - expect(container.querySelector('.df-breadcrumb-current')?.textContent).toBe('All project files'); + // #5517: the breadcrumb root falls back to designFiles.crumbs ("Project") + // instead of the removed workspace.allProjectFiles label. + expect(container.querySelector('.df-breadcrumb-current')?.textContent).toBe('Project'); expect(screen.getByTestId('design-file-row-home.html')).toBeTruthy(); }); @@ -1245,7 +1341,7 @@ describe('FileWorkspace upload input', () => { expect(markup).toContain('class="ws-tabs-bar"'); expect(markup).toMatch( - /role="tablist"[\s\S]*data-testid="workspace-pages-menu-trigger"[\s\S]*artifact\.html/, + /role="tablist"[\s\S]*data-testid="design-files-tab"[\s\S]*artifact\.html/, ); }); @@ -1270,6 +1366,1066 @@ describe('FileWorkspace upload input', () => { }); describe('FileWorkspace launcher tab creation', () => { + it('keeps the active HTML preview mounted across repeated Design Files round-trips', async () => { + const file = workspaceFile('artifact.html'); + mockedFetchProjectFileText.mockResolvedValue('artifact'); + + function Harness() { + const [tabsState, setTabsState] = useState({ + tabs: [file.name], + active: file.name, + }); + return ( + + + + + + ); + } + + const { container } = render(); + await waitFor(() => { + expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(1); + }); + const firstFrame = screen.getByTestId('artifact-preview-frame'); + const retainedViewer = screen.getByTestId('retained-file-viewer'); + expect(retainedViewer.style.display).toBe('flex'); + + for (let round = 0; round < 10; round += 1) { + fireEvent.click(screen.getByTestId('design-files-tab')); + expect(screen.getByTestId('retained-file-viewer')).toBe(retainedViewer); + expect(retainedViewer.getAttribute('aria-hidden')).toBe('true'); + expect(retainedViewer.hasAttribute('inert')).toBe(true); + expect(retainedViewer.hasAttribute('hidden')).toBe(false); + expect(retainedViewer.style.display).toBe('flex'); + expect(retainedViewer.style.position).toBe('absolute'); + expect(retainedViewer.style.visibility).toBe('hidden'); + expect(container.querySelector('.iframe-keep-alive-pool iframe')).toBeNull(); + + fireEvent.click(screen.getByRole('tab', { name: /artifact\.html/i })); + expect(screen.getByTestId('artifact-preview-frame')).toBe(firstFrame); + expect(screen.getByTestId('retained-file-viewer')).toBe(retainedViewer); + expect(retainedViewer.style.display).toBe('flex'); + expect(retainedViewer.style.visibility).toBe(''); + expect(retainedViewer.hasAttribute('inert')).toBe(false); + } + + expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(1); + }); + + it('keeps warmed HTML preview frames connected while switching between files', async () => { + const alpha = workspaceFile('alpha.html'); + const beta = workspaceFile('beta.html'); + mockedFetchProjectFileText.mockImplementation(async (_projectId, fileName) => ( + `${fileName}` + )); + + function Harness() { + const [tabsState, setTabsState] = useState({ + tabs: [alpha.name, beta.name], + active: alpha.name, + }); + return ( + + + + + + ); + } + + const { container } = render(); + await waitFor(() => expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(1)); + const alphaFrame = screen.getByTestId('artifact-preview-frame'); + + fireEvent.click(screen.getByRole('tab', { name: /beta\.html/i })); + await waitFor(() => expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(2)); + const betaFrame = screen.getByTestId('artifact-preview-frame'); + expect(betaFrame).not.toBe(alphaFrame); + expect(container.querySelector('.iframe-keep-alive-pool iframe')).toBeNull(); + expect(document.body.contains(alphaFrame)).toBe(true); + const retainedAfterBeta = screen.getAllByTestId('retained-file-viewer'); + expect(retainedAfterBeta.map((viewer) => viewer.getAttribute('data-file-name'))).toEqual([ + alpha.name, + beta.name, + ]); + + fireEvent.click(screen.getByRole('tab', { name: /alpha\.html/i })); + expect(screen.getByTestId('artifact-preview-frame')).toBe(alphaFrame); + expect(container.querySelector('.iframe-keep-alive-pool iframe')).toBeNull(); + expect(document.body.contains(betaFrame)).toBe(true); + expect(screen.getAllByTestId('retained-file-viewer')).toEqual(retainedAfterBeta); + expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(2); + }); + + it('evicts the fourth HTML tab without reattaching the three surviving preview frames', async () => { + const files = ['alpha.html', 'beta.html', 'gamma.html', 'delta.html'].map(workspaceFile); + mockedFetchProjectFileText.mockImplementation(async (_projectId, fileName) => ( + `${fileName}` + )); + + function Harness() { + const [tabsState, setTabsState] = useState({ + tabs: files.map((file) => file.name), + active: 'alpha.html', + }); + return ( + + + + + + ); + } + + render(); + fireEvent.click(screen.getByRole('tab', { name: /beta\.html/i })); + await waitFor(() => expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(2)); + fireEvent.click(screen.getByRole('tab', { name: /gamma\.html/i })); + await waitFor(() => expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(3)); + const survivingFrames = ['beta.html', 'gamma.html'].map((name) => ( + document.querySelector(`iframe[title="${name}"][data-od-render-mode="url-load"]`) + )); + expect(survivingFrames.every(Boolean)).toBe(true); + const appendSpy = vi.spyOn(Node.prototype, 'appendChild'); + + fireEvent.click(screen.getByRole('tab', { name: /delta\.html/i })); + await waitFor(() => expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(4)); + await waitFor(() => expect(document.querySelector('iframe[title="alpha.html"]')).toBeNull()); + + for (const [index, name] of ['beta.html', 'gamma.html'].entries()) { + const frame = survivingFrames[index]; + expect(document.querySelector( + `iframe[title="${name}"][data-od-render-mode="url-load"]`, + )).toBe(frame); + expect(appendSpy.mock.calls.filter(([node]) => node === frame)).toHaveLength(0); + } + }); + + it('deletes the active HTML viewer without reattaching a surviving warm iframe', async () => { + const alpha = workspaceFile('alpha.html'); + const beta = workspaceFile('beta.html'); + mockedFetchProjectFileText.mockImplementation(async (_projectId, fileName) => ( + `${fileName}` + )); + + function Harness({ files, generation }: { files: ProjectFile[]; generation: number }) { + const [tabsState, setTabsState] = useState({ + tabs: [alpha.name, beta.name], + active: alpha.name, + }); + return ( + + + + + + ); + } + + const { rerender } = render(); + await waitFor(() => expect(document.querySelector( + 'iframe[title="alpha.html"][data-od-render-mode="url-load"]', + )).not.toBeNull()); + fireEvent.click(screen.getByRole('tab', { name: /beta\.html/i })); + await waitFor(() => expect(document.querySelector( + 'iframe[title="alpha.html"][data-od-render-mode="url-load"]', + )).not.toBeNull()); + const alphaFrame = document.querySelector( + 'iframe[title="alpha.html"][data-od-render-mode="url-load"]', + ); + expect(alphaFrame).not.toBeNull(); + const readsBeforeDelete = mockedFetchProjectFileText.mock.calls.length; + const appendSpy = vi.spyOn(Node.prototype, 'appendChild'); + + rerender(); + + await waitFor(() => expect(document.querySelector('iframe[title="beta.html"]')).toBeNull()); + expect(document.querySelector( + 'iframe[title="alpha.html"][data-od-render-mode="url-load"]', + )).toBe(alphaFrame); + expect(appendSpy.mock.calls.filter(([node]) => node === alphaFrame)).toHaveLength(0); + expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(readsBeforeDelete); + }); + + it('keeps warmed HTML preview frames through equivalent context refreshes and transient empty file snapshots', async () => { + const alphaName = 'alpha.html'; + const betaName = 'beta.html'; + mockedFetchProjectFileText.mockImplementation(async (_projectId, fileName) => ( + `${fileName}` + )); + + function Harness({ + active, + files, + tabs, + workspaceContext, + filesRefreshKey = 0, + }: { + active: string; + files: ProjectFile[]; + tabs: string[]; + workspaceContext: WorkspaceCollabContext; + filesRefreshKey?: number; + }) { + return ( + + + + + + ); + } + + const workspaceContext = teamContext('workspace-a', 'member-a'); + const { rerender } = render( + , + ); + await waitFor(() => expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(1)); + const alphaFrame = screen.getByTestId('artifact-preview-frame'); + + rerender( + , + ); + await waitFor(() => expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(2)); + const betaFrame = screen.getByTestId('artifact-preview-frame'); + + // Ambient workspace refreshes can briefly publish an empty file snapshot. + // Open tabs are the durable witness that these files were not closed or + // deleted, so both warmed iframe nodes must stay connected through it. + rerender( + , + ); + expect(document.body.contains(alphaFrame)).toBe(true); + expect(document.body.contains(betaFrame)).toBe(true); + expect(alphaFrame.closest('[data-testid="retained-file-viewer"]')).not.toBeNull(); + expect(betaFrame.closest('[data-testid="retained-file-viewer"]')).not.toBeNull(); + expect(document.querySelector('.iframe-keep-alive-pool iframe')).toBeNull(); + + rerender( + , + ); + expect(screen.getByTestId('artifact-preview-frame')).toBe(alphaFrame); + expect(document.body.contains(betaFrame)).toBe(true); + expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(2); + + // Removing a tab is an explicit permanent close/delete witness and must + // still evict that viewer rather than retaining it forever. + rerender( + , + ); + await waitFor(() => expect(document.querySelector('iframe[title="beta.html"]')).toBeNull()); + expect(screen.getByTestId('artifact-preview-frame')).toBe(alphaFrame); + }); + + it('evicts a deleted HTML viewer after a committed file refresh even when its tab persists', async () => { + const alphaName = 'alpha.html'; + const betaName = 'beta.html'; + const workspaceContext = teamContext('workspace-a', 'member-a'); + const tabs = [alphaName, betaName]; + + function Harness({ files, refreshKey }: { files: ProjectFile[]; refreshKey: number }) { + return ( + + + + + + ); + } + + const initialFiles = [workspaceFile(alphaName), workspaceFile(betaName)]; + const { rerender } = render( + , + ); + fireEvent.click(screen.getByRole('tab', { name: /beta\.html/i })); + await waitFor(() => expect(document.querySelector('iframe[title="beta.html"]')).not.toBeNull()); + const betaFrame = document.querySelector('iframe[title="beta.html"]'); + + // Authorization-scoped keep-alive keys append metadata after `beta.html:`. + // A committed refresh that still contains the file must retain that frame. + rerender(); + await waitFor(() => expect(document.querySelector('iframe[title="beta.html"]')).toBe(betaFrame)); + fireEvent.click(screen.getByRole('tab', { name: /alpha\.html/i })); + + rerender(); + + await waitFor(() => expect(document.querySelector('iframe[title="beta.html"]')).toBeNull()); + expect(screen.getByTestId('artifact-preview-frame').getAttribute('title')).toBe(alphaName); + }); + + describe('protected viewer deletion revalidation', () => { + const fileName = 'page.html'; + const initialFiles = [workspaceFile(fileName)]; + + function Harness({ + revalidatedFiles, + onFresh, + freshFailure = null, + acceptedGeneration = 3, + committedGeneration = acceptedGeneration, + }: { + revalidatedFiles: ProjectFile[]; + onFresh: (options?: { fresh?: boolean }) => void; + freshFailure?: 'throw' | 'null' | null; + acceptedGeneration?: number; + committedGeneration?: number; + }) { + const [snapshot, setSnapshot] = useState({ files: initialFiles, generation: 1 }); + return ( + + + + + { + onFresh(options); + if (options?.fresh) { + if (freshFailure === 'throw') throw new Error('fresh read failed'); + if (freshFailure === 'null') return { acceptedGeneration: null }; + setSnapshot({ files: revalidatedFiles, generation: committedGeneration }); + return { acceptedGeneration }; + } + return { acceptedGeneration: null }; + }} + isDeck={false} + tabsState={{ tabs: [fileName], active: fileName }} + onTabsStateChange={vi.fn()} + /> + + + ); + } + + async function enterManualEdit() { + const toggle = await screen.findByTestId('manual-edit-mode-toggle'); + fireEvent.click(toggle); + await waitFor(() => expect(toggle.getAttribute('aria-pressed')).toBe('true')); + await waitFor(() => { + expect(screen.getByTestId('artifact-preview-frame').getAttribute('data-od-render-mode')).toBe('srcdoc'); + }); + return toggle; + } + + it('purges a protected no-op editor only after the fresh R2 still reports it missing', async () => { + mockedFetchProjectFileText.mockResolvedValue('Page'); + const onFresh = vi.fn(); + render(); + await enterManualEdit(); + const frame = screen.getByTestId('artifact-preview-frame'); + + fireEvent.click(screen.getByTestId('commit-r1-missing')); + + await waitFor(() => expect(onFresh).toHaveBeenCalledWith({ fresh: true })); + await waitFor(() => expect(document.body.contains(frame)).toBe(false)); + }); + + it('keeps a successfully saved viewer when fresh R2 recreates it at the same refresh key', async () => { + const initialSource = '

Copy

'; + mockedFetchProjectFileText.mockResolvedValue(initialSource); + let writes = 0; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/api/projects/project-1/files') && init?.method === 'POST') { + writes += 1; + return new Response(JSON.stringify({ file: workspaceFile(fileName) }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url.includes('/versions')) { + return new Response(JSON.stringify({ versions: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url.includes('/raw/page.html')) return new Response(initialSource, { status: 200 }); + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + vi.stubGlobal('fetch', fetchMock); + const onFresh = vi.fn(); + render(); + await enterManualEdit(); + const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; + act(() => { + window.dispatchEvent(new MessageEvent('message', { + source: frame.contentWindow, + data: { + type: 'od-edit-drag-commit', + id: 'copy', + transform: 'translate(12px, 8px)', + display: 'block', + }, + })); + }); + + fireEvent.click(screen.getByTestId('commit-r1-missing')); + + await waitFor(() => expect(writes).toBe(1)); + await waitFor(() => expect(onFresh).toHaveBeenCalledWith({ fresh: true })); + expect(document.body.contains(frame)).toBe(true); + }); + + it('does not let the save-triggered ordinary refresh adjudicate deletion before fresh R2 completes', async () => { + const initialSource = '

Copy

'; + mockedFetchProjectFileText.mockResolvedValue(initialSource); + let resolveFresh!: () => void; + const freshGate = new Promise((resolve) => { resolveFresh = resolve; }); + const refreshCalls = vi.fn(); + + function RacingHarness() { + const [snapshot, setSnapshot] = useState({ files: initialFiles, generation: 1 }); + return ( + + + + { + refreshCalls(options); + if (!options?.fresh) { + // applyManualEdit -> onFileSaved performs this cached + // refresh before safeExit resolves. + setSnapshot({ files: [], generation: 3 }); + return { acceptedGeneration: 3 }; + } + await freshGate; + setSnapshot({ files: initialFiles, generation: 4 }); + return { acceptedGeneration: 4 }; + }} + isDeck={false} + tabsState={{ tabs: [fileName], active: fileName }} + onTabsStateChange={vi.fn()} + /> + + + ); + } + + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/api/projects/project-1/files') && init?.method === 'POST') { + return new Response(JSON.stringify({ file: workspaceFile(fileName) }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url.includes('/versions')) { + return new Response(JSON.stringify({ versions: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url.includes('/raw/page.html')) return new Response(initialSource, { status: 200 }); + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + vi.stubGlobal('fetch', fetchMock); + render(); + await enterManualEdit(); + const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; + act(() => { + window.dispatchEvent(new MessageEvent('message', { + source: frame.contentWindow, + data: { + type: 'od-edit-drag-commit', + id: 'copy', + transform: 'translate(12px, 8px)', + display: 'block', + }, + })); + }); + + fireEvent.click(screen.getByTestId('commit-racing-r1-missing')); + + await waitFor(() => expect(refreshCalls).toHaveBeenCalledWith(undefined)); + await waitFor(() => expect(refreshCalls).toHaveBeenCalledWith({ fresh: true })); + expect(document.body.contains(frame)).toBe(true); + + await act(async () => { resolveFresh(); }); + await waitFor(() => expect(document.body.contains(frame)).toBe(true)); + }); + + function FailedR2Harness({ + mode, + ordinaryGate, + freshGate, + onRefresh, + }: { + mode: 'throw' | 'null'; + ordinaryGate: Promise; + freshGate: Promise; + onRefresh: (options?: { fresh?: boolean }) => void; + }) { + const [snapshot, setSnapshot] = useState({ files: initialFiles, generation: 1 }); + return ( + + + {snapshot.generation} + + + + + { + onRefresh(options); + if (!options?.fresh) { + setSnapshot({ files: [], generation: 3 }); + await ordinaryGate; + return { acceptedGeneration: 3 }; + } + await freshGate; + if (mode === 'throw') throw new Error('fresh read failed'); + return { acceptedGeneration: null }; + }} + isDeck={false} + tabsState={{ tabs: [fileName], active: fileName }} + onTabsStateChange={vi.fn()} + /> + + + ); + } + + function stubManualEditSave(source: string) { + mockedFetchProjectFileText.mockResolvedValue(source); + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/api/projects/project-1/files') && init?.method === 'POST') { + return new Response(JSON.stringify({ file: workspaceFile(fileName) }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url.includes('/versions')) { + return new Response(JSON.stringify({ versions: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url.includes('/raw/page.html')) return new Response(source, { status: 200 }); + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + })); + } + + async function dirtyActiveViewer(frame: HTMLIFrameElement) { + act(() => { + window.dispatchEvent(new MessageEvent('message', { + source: frame.contentWindow, + data: { + type: 'od-edit-drag-commit', + id: 'copy', + transform: 'translate(12px, 8px)', + display: 'block', + }, + })); + }); + } + + it('waits beyond the pre-R2 save generation before a failed R2 can purge', async () => { + const source = '

Copy

'; + stubManualEditSave(source); + let resolveOrdinary!: () => void; + const ordinaryGate = new Promise((resolve) => { resolveOrdinary = resolve; }); + const onRefresh = vi.fn(); + render( + , + ); + await enterManualEdit(); + const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; + await dirtyActiveViewer(frame); + fireEvent.click(screen.getByTestId('failed-r2-r1-missing')); + + await waitFor(() => expect(screen.getByTestId('failed-r2-generation').textContent).toBe('3')); + await act(async () => { resolveOrdinary(); }); + await waitFor(() => expect(onRefresh).toHaveBeenCalledWith({ fresh: true })); + expect(document.body.contains(frame)).toBe(true); + + fireEvent.click(screen.getByTestId('failed-r2-later-missing')); + await waitFor(() => expect(document.body.contains(frame)).toBe(false)); + }); + + it('uses a later missing generation that overtakes an in-flight null R2', async () => { + const source = '

Copy

'; + stubManualEditSave(source); + let resolveOrdinary!: () => void; + let resolveFresh!: () => void; + const ordinaryGate = new Promise((resolve) => { resolveOrdinary = resolve; }); + const freshGate = new Promise((resolve) => { resolveFresh = resolve; }); + const onRefresh = vi.fn(); + render( + , + ); + await enterManualEdit(); + const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; + await dirtyActiveViewer(frame); + fireEvent.click(screen.getByTestId('failed-r2-r1-missing')); + + await waitFor(() => expect(screen.getByTestId('failed-r2-generation').textContent).toBe('3')); + await act(async () => { resolveOrdinary(); }); + await waitFor(() => expect(onRefresh).toHaveBeenCalledWith({ fresh: true })); + fireEvent.click(screen.getByTestId('failed-r2-later-missing')); + expect(document.body.contains(frame)).toBe(true); + + await act(async () => { resolveFresh(); }); + await waitFor(() => expect(document.body.contains(frame)).toBe(false)); + }); + + it('clears a failed R2 decision when a later generation contains the file', async () => { + const source = '

Copy

'; + stubManualEditSave(source); + let resolveOrdinary!: () => void; + let resolveFresh!: () => void; + const ordinaryGate = new Promise((resolve) => { resolveOrdinary = resolve; }); + const freshGate = new Promise((resolve) => { resolveFresh = resolve; }); + const onRefresh = vi.fn(); + render( + , + ); + await enterManualEdit(); + const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; + await dirtyActiveViewer(frame); + fireEvent.click(screen.getByTestId('failed-r2-r1-missing')); + + await waitFor(() => expect(screen.getByTestId('failed-r2-generation').textContent).toBe('3')); + await act(async () => { resolveOrdinary(); }); + await waitFor(() => expect(onRefresh).toHaveBeenCalledWith({ fresh: true })); + fireEvent.click(screen.getByTestId('failed-r2-later-present')); + await act(async () => { resolveFresh(); }); + await waitFor(() => expect(document.body.contains(frame)).toBe(true)); + + fireEvent.click(screen.getByTestId('failed-r2-after-present-missing')); + await waitFor(() => expect(document.body.contains(frame)).toBe(false)); + }); + + it('purges when a later accepted missing snapshot overtakes the accepted fresh R2 generation', async () => { + mockedFetchProjectFileText.mockResolvedValue('Page'); + const onFresh = vi.fn(); + render( + , + ); + await enterManualEdit(); + const frame = screen.getByTestId('artifact-preview-frame'); + + fireEvent.click(screen.getByTestId('commit-r1-missing')); + + await waitFor(() => expect(onFresh).toHaveBeenCalledWith({ fresh: true })); + await waitFor(() => expect(document.body.contains(frame)).toBe(false)); + }); + + it('retains the protected viewer and skips R2 when its dirty flush fails', async () => { + const initialSource = '

Copy

'; + mockedFetchProjectFileText.mockResolvedValue(initialSource); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith('/api/projects/project-1/files') && init?.method === 'POST') { + return new Response(JSON.stringify({ error: { message: 'conflict' } }), { + status: 409, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url.includes('/versions')) { + return new Response(JSON.stringify({ versions: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url.includes('/raw/page.html')) return new Response(initialSource, { status: 200 }); + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + vi.stubGlobal('fetch', fetchMock); + const onFresh = vi.fn(); + render(); + const toggle = await enterManualEdit(); + const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; + act(() => { + window.dispatchEvent(new MessageEvent('message', { + source: frame.contentWindow, + data: { + type: 'od-edit-drag-commit', + id: 'copy', + transform: 'translate(12px, 8px)', + display: 'block', + }, + })); + }); + + fireEvent.click(screen.getByTestId('commit-r1-missing')); + + await waitFor(() => expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(true)); + expect(onFresh).not.toHaveBeenCalledWith({ fresh: true }); + expect(document.body.contains(frame)).toBe(true); + expect(toggle.getAttribute('aria-pressed')).toBe('true'); + }); + }); + + it('keeps manual-edit viewers within the hard cap by flushing before each tab switch', async () => { + const files = ['alpha.html', 'beta.html', 'gamma.html', 'delta.html'].map(workspaceFile); + mockedFetchProjectFileText.mockImplementation(async (_projectId, fileName) => ( + `

${fileName}

` + )); + + function Harness() { + const [tabsState, setTabsState] = useState({ + tabs: files.map((file) => file.name), + active: files[0]!.name, + }); + return ( + + + + + + ); + } + + render(); + for (let index = 0; index < files.length; index += 1) { + const currentName = files[index]!.name; + await waitFor(() => { + expect(screen.getByTestId('artifact-preview-frame').getAttribute('title')).toBe(currentName); + }); + const activeViewer = document.querySelector( + `[data-testid="retained-file-viewer"][data-file-name="${currentName}"]`, + ); + expect(activeViewer).not.toBeNull(); + const editToggle = within(activeViewer!).getByTestId('manual-edit-mode-toggle'); + fireEvent.click(editToggle); + await waitFor(() => { + expect(editToggle.getAttribute('aria-pressed')).toBe('true'); + }); + if (index < files.length - 1) { + const nextName = files[index + 1]!.name; + fireEvent.click(screen.getByRole('tab', { name: new RegExp(nextName.replace('.', '\\.')) })); + await waitFor(() => { + expect(screen.getByTestId('artifact-preview-frame').getAttribute('title')).toBe(nextName); + }); + } + expect(screen.getAllByTestId('retained-file-viewer').length).toBeLessThanOrEqual(3); + } + + expect(document.querySelector('iframe[title="alpha.html"]')).toBeNull(); + expect(screen.getAllByTestId('manual-edit-mode-toggle').length).toBeLessThanOrEqual(3); + expect(screen.getAllByTestId('manual-edit-mode-toggle').filter( + (toggle) => toggle.getAttribute('aria-pressed') === 'true', + )).toHaveLength(1); + }); + + it('waits for the active inline edit before navigating away from the project', async () => { + const alpha = workspaceFile('alpha.html'); + mockedFetchProjectFileText.mockResolvedValue( + '

alpha.html

', + ); + window.history.replaceState(null, '', '/projects/project-1'); + + render( + + + + + , + ); + + const toggle = await screen.findByTestId('manual-edit-mode-toggle'); + fireEvent.click(toggle); + await waitFor(() => expect(toggle.getAttribute('aria-pressed')).toBe('true')); + const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; + act(() => { + window.dispatchEvent(new MessageEvent('message', { + source: frame.contentWindow, + data: { type: 'od-edit-text-session', id: 'copy', active: true }, + })); + }); + await act(async () => { + await Promise.resolve(); + }); + + const beforeUnload = new Event('beforeunload', { cancelable: true }); + expect(window.dispatchEvent(beforeUnload)).toBe(false); + expect(beforeUnload.defaultPrevented).toBe(true); + + navigate({ kind: 'home', view: 'projects' }); + expect(window.location.pathname).toBe('/projects/project-1'); + + act(() => { + window.dispatchEvent(new MessageEvent('message', { + source: frame.contentWindow, + data: { + type: 'od-edit-text-session', + id: 'copy', + active: false, + committed: true, + changed: false, + }, + })); + }); + await waitFor(() => expect(window.location.pathname).toBe('/projects')); + expect(toggle.getAttribute('aria-pressed')).toBe('false'); + }); + + it('evicts the previous project preview pool when the workspace changes', async () => { + const workspaceContext = teamContext('workspace-a', 'member-a'); + function Harness({ projectId, fileName }: { projectId: string; fileName: string }) { + const file = workspaceFile(fileName); + return ( + + + + + + ); + } + + const { rerender } = render(); + await waitFor(() => expect(document.querySelector('iframe[title="old.html"]')).not.toBeNull()); + const oldFrame = document.querySelector('iframe[title="old.html"]'); + + rerender(); + + await waitFor(() => expect(document.querySelector('iframe[title="new.html"]')).not.toBeNull()); + expect(document.body.contains(oldFrame)).toBe(false); + expect(document.querySelector('.iframe-keep-alive-pool iframe[title="old.html"]')).toBeNull(); + }); + + it('evicts the least-recently-used HTML viewer after the fourth warm tab', async () => { + const files = ['alpha.html', 'beta.html', 'gamma.html', 'delta.html'].map(workspaceFile); + mockedFetchProjectFileText.mockImplementation(async (_projectId, fileName) => ( + `${fileName}` + )); + + function Harness() { + const [tabsState, setTabsState] = useState({ + tabs: files.map((file) => file.name), + active: files[0]!.name, + }); + return ( + + + + + + ); + } + + render(); + for (const name of files.slice(1).map((file) => file.name)) { + fireEvent.click(screen.getByRole('tab', { name: new RegExp(name.replace('.', '\\.')) })); + await waitFor(() => expect(screen.getByTestId('artifact-preview-frame').getAttribute('title')).toBe(name)); + } + await waitFor(() => { + expect(screen.getAllByTestId('retained-file-viewer').map((viewer) => viewer.getAttribute('data-file-name'))) + .toEqual(['beta.html', 'gamma.html', 'delta.html']); + }); + expect(document.querySelector('iframe[title="alpha.html"]')).toBeNull(); + expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(4); + + fireEvent.click(screen.getByRole('tab', { name: /alpha\.html/i })); + await waitFor(() => expect(screen.getByTestId('artifact-preview-frame').getAttribute('title')).toBe('alpha.html')); + expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(5); + await waitFor(() => { + expect(screen.getAllByTestId('retained-file-viewer').map((viewer) => viewer.getAttribute('data-file-name'))) + .toEqual(['alpha.html', 'gamma.html', 'delta.html']); + }); + }); + it('does not report a Design Files context for an empty project', async () => { // A brand-new project has no files, live artifacts, or folders. The // composer must not auto-stage a "Design files" chip that points at @@ -1340,11 +2496,10 @@ describe('FileWorkspace launcher tab creation', () => { fireEvent.click(screen.getByTestId('workspace-add-tab')); - expect(screen.queryByRole('button', { name: /New Terminal/i })).toBeNull(); - expect(screen.getByRole('button', { name: /New Browser/i })).toBeTruthy(); - expect( - screen.getByText('Sketch rough layouts and notes for the agent to use as design context'), - ).toBeTruthy(); + const launcherMenu = within(screen.getByTestId('tab-launcher-menu')); + expect(launcherMenu.queryByRole('button', { name: /New Terminal/i })).toBeNull(); + expect(launcherMenu.getByRole('button', { name: /New Browser/i })).toBeTruthy(); + expect(launcherMenu.getByRole('button', { name: /New sketch/i })).toBeTruthy(); expect(screen.getByText('Create new')).toBeTruthy(); }); @@ -1381,8 +2536,39 @@ describe('FileWorkspace launcher tab creation', () => { />, ); - expect(screen.getByTestId('workspace-pages-menu-trigger').textContent).toContain('Pages'); - expect(renderedTabLabels()).toEqual(['Browser', 'New Terminal', 'Side chat']); + expect(screen.getByTestId('design-files-tab').textContent).toContain('Design Files'); + // Design Files is a plain tab in the strip (role="tab"), so it is part of + // the rendered tab list rather than a dropdown trigger sitting outside it. + expect(renderedTabLabels()).toEqual([ + 'Design Files', + 'Browser', + 'New Terminal', + 'Side chat', + ]); + }); + + it('shows project sync progress on the Design Files root tab without hiding materialized files', () => { + render( + , + ); + + const rootTab = screen.getByTestId('design-files-tab'); + expect(rootTab.title).toContain('Downloading from the team'); + expect(rootTab.getAttribute('aria-label')).toContain('Downloading from the team'); + expect(rootTab.querySelector('svg')).toBeTruthy(); + expect(screen.getByText('notes.txt')).toBeTruthy(); + expect(screen.queryByTestId('design-files-syncing')).toBeNull(); }); it('opens Design Files from the browser snapshot toast action instead of the manifest file', async () => { @@ -1832,6 +3018,61 @@ describe('FileWorkspace launcher tab creation', () => { }); }); + it('reloads design-system source files under the complete pinned Workspace identity', async () => { + const workspaceA = teamContext('workspace-a', 'member-a'); + const workspaceB = teamContext('workspace-b', 'member-b'); + const props = { + projectId: 'project-1', + projectKind: 'prototype' as const, + files: [workspaceFile('DESIGN.md'), workspaceFile('brand.json')], + liveArtifacts: [], + onRefreshFiles: vi.fn(), + isDeck: false, + tabsState: { tabs: [], active: '__design_system__' }, + onTabsStateChange: vi.fn(), + designSystemProject: { + id: 'neutral-modern', + title: 'Neutral Modern', + category: 'Starter', + source: 'bundled', + updatedAt: 1, + } as never, + }; + + const { rerender } = render( + + + , + ); + await waitFor(() => { + expect(mockedFetchProjectFileText).toHaveBeenCalledWith( + 'project-1', + 'DESIGN.md', + { cache: 'no-store', workspaceContext: workspaceA }, + ); + }); + + mockedFetchProjectFileText.mockClear(); + rerender( + + + , + ); + + await waitFor(() => { + expect(mockedFetchProjectFileText).toHaveBeenCalledWith( + 'project-1', + 'DESIGN.md', + { cache: 'no-store', workspaceContext: workspaceB }, + ); + expect(mockedFetchProjectFileText).toHaveBeenCalledWith( + 'project-1', + 'brand.json', + { cache: 'no-store', workspaceContext: workspaceB }, + ); + }); + }); + it('focuses an already-open file tab without adding a duplicate tab', async () => { const onTabsStateChange = vi.fn(); @@ -2075,10 +3316,14 @@ describe('projectSplitClassName', () => { }); it('uses CSS variables for split widths so pointer resize can update layout without rerendering workspace content', () => { + // `.split`'s grid-template-columns is always + // `var(--project-chat-panel-width) var(--project-chat-handle-width) var(--project-workspace-panel-track)` + // (see shell.css), so the style object only needs to carry the three + // custom properties — no more concatenated `gridTemplateColumns` string. expect(projectSplitStyle(false, 512, 'minmax(420px, 1fr)')).toEqual({ '--project-chat-panel-width': '512px', + '--project-chat-handle-width': '8px', '--project-workspace-panel-track': 'minmax(420px, 1fr)', - gridTemplateColumns: '512px 8px minmax(420px, 1fr)', }); expect(projectSplitStyle(true, 512, 'minmax(420px, 1fr)')).toBeUndefined(); }); diff --git a/apps/web/tests/components/GenUIInbox.test.tsx b/apps/web/tests/components/GenUIInbox.test.tsx new file mode 100644 index 00000000000..8b634e016cd --- /dev/null +++ b/apps/web/tests/components/GenUIInbox.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { WorkspaceCollabContext } from '@open-design/contracts'; + +import { GenUIInbox } from '../../src/components/GenUIInbox'; + +const WORKSPACE_CONTEXT = { + workspaceId: 'workspace-team', + workspaceType: 'team', + workspaceMemberId: 'member-1', + role: 'owner', + memberStatus: 'active', + lifecycleState: 'active', + permissions: { + canShareProjects: true, + canWriteSyncedFiles: true, + }, +} as WorkspaceCollabContext; + +const SURFACE = { + id: 'row-1', + surfaceId: 'approval-1', + projectId: 'project-1', + kind: 'confirmation', + persist: 'project', + status: 'resolved', + requestedAt: 1, +}; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('GenUIInbox Workspace transport', () => { + it('sends exact Workspace authority for list and revoke', async () => { + const fetchMock = vi.fn(async (input, init) => { + const url = String(input); + if (url.endsWith('/revoke')) return Response.json({ ok: true }); + return Response.json({ surfaces: [SURFACE] }); + }); + vi.stubGlobal('fetch', fetchMock); + + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Revoke' })); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); + for (const [, init] of fetchMock.mock.calls) { + const headers = new Headers(init?.headers); + expect(headers.get('x-od-workspace-id')).toBe('workspace-team'); + expect(headers.get('x-od-workspace-member-id')).toBe('member-1'); + expect(headers.get('x-od-workspace-can-write-synced-files')).toBe('true'); + } + }); + + it('keeps legacy unbound requests headerless', async () => { + const fetchMock = vi.fn(async () => Response.json({ surfaces: [] })); + vi.stubGlobal('fetch', fetchMock); + + render(); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expect(new Headers(fetchMock.mock.calls[0]?.[1]?.headers).has('x-od-workspace-id')).toBe(false); + }); +}); diff --git a/apps/web/tests/components/GenUISurfaceRenderer.test.tsx b/apps/web/tests/components/GenUISurfaceRenderer.test.tsx index 9b6cb70b5f1..8d7e92537a9 100644 --- a/apps/web/tests/components/GenUISurfaceRenderer.test.tsx +++ b/apps/web/tests/components/GenUISurfaceRenderer.test.tsx @@ -12,7 +12,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { GenUISurfaceRenderer } from '../../src/components/GenUISurfaceRenderer'; -import type { GenUISurfaceSpec } from '@open-design/contracts'; +import type { + GenUISurfaceSpec, + WorkspaceCollabContext, +} from '@open-design/contracts'; afterEach(() => cleanup()); @@ -60,4 +63,37 @@ describe('GenUISurfaceRenderer', () => { }), ); }); + + it('keeps a bundled component iframe on the captured Workspace identity', () => { + const surface: GenUISurfaceSpec = { + id: 'review', + kind: 'form', + persist: 'run', + component: { path: './surfaces/review.html', sandbox: 'iframe' }, + }; + const workspaceContext = { + workspaceId: 'workspace-a', + workspaceMemberId: 'member-a', + workspaceType: 'team', + role: 'member', + memberStatus: 'active', + lifecycleState: 'active', + permissions: { + canShareProjects: true, + canWriteSyncedFiles: true, + }, + } as WorkspaceCollabContext; + render( + undefined} + />, + ); + + const src = screen.getByTestId('genui-component-iframe').getAttribute('src'); + const parsed = new URL(src ?? '', 'https://od.local'); + expect(parsed.searchParams.get('workspaceId')).toBe('workspace-a'); + expect(parsed.searchParams.get('workspaceMemberId')).toBe('member-a'); + }); }); diff --git a/apps/web/tests/components/HandoffButton.fallback-reveal.test.tsx b/apps/web/tests/components/HandoffButton.fallback-reveal.test.tsx index 261d317a51b..68d8ad59207 100644 --- a/apps/web/tests/components/HandoffButton.fallback-reveal.test.tsx +++ b/apps/web/tests/components/HandoffButton.fallback-reveal.test.tsx @@ -51,7 +51,7 @@ describe('HandoffButton zero-editors fallback', () => { const fallback = (await screen.findByText('Finder')).closest('button') as HTMLButtonElement; fireEvent.click(fallback); - await waitFor(() => expect(openProjectInEditor).toHaveBeenCalledWith('p1', 'finder')); + await waitFor(() => expect(openProjectInEditor).toHaveBeenCalledWith('p1', 'finder', null)); }); it('surfaces a daemon spawn failure inline so the fallback is not a silent no-op', async () => { @@ -79,8 +79,6 @@ describe('HandoffButton zero-editors fallback', () => { }); it('copies a framework-specific CLI handoff prompt with the local project path', async () => { - const fetchMock = vi.fn(async () => new Response('{}', { status: 202 })); - vi.stubGlobal('fetch', fetchMock); fetchHostEditors.mockResolvedValue({ platform: 'darwin', editors: [ @@ -122,18 +120,9 @@ describe('HandoffButton zero-editors fallback', () => { fireEvent.click(await screen.findByTestId('handoff-caret')); fireEvent.click(await screen.findByRole('tab', { name: '复制给 CLI' })); - const amrWebsiteLink = screen.getByRole('link', { name: /打开 Open Design Cloud 官网/ }) as HTMLAnchorElement; - expect(amrWebsiteLink.getAttribute('href')) - .toBe('https://open-design.ai/amr'); - fireEvent.click(amrWebsiteLink); - const amrWebsiteUrl = new URL(amrWebsiteLink.href); - expect(amrWebsiteUrl.searchParams.get('od_origin')).toBe('open_design'); - expect(amrWebsiteUrl.searchParams.get('od_entry_source')).toBe('handoff_amr_website'); - expect(amrWebsiteUrl.searchParams.get('od_device_id')).toBe('od-install-abc'); - expect(fetchMock).toHaveBeenCalledWith( - '/api/integrations/vela/analytics-entry', - expect.objectContaining({ method: 'POST' }), - ); + // The "Open Design Cloud website" link was removed from the CLI tab + // (acceptance #101); the CLI agent cards remain the surface here. + expect(screen.queryByRole('link', { name: /打开 Open Design Cloud 官网/ })).toBeNull(); expect(screen.getByTestId('handoff-cli-item-amr').textContent).toContain('Open Design'); expect(screen.getByTestId('handoff-cli-item-amr').textContent).not.toContain('未安装'); expect( diff --git a/apps/web/tests/components/HandoffButton.loading.test.tsx b/apps/web/tests/components/HandoffButton.loading.test.tsx new file mode 100644 index 00000000000..fabac78159b --- /dev/null +++ b/apps/web/tests/components/HandoffButton.loading.test.tsx @@ -0,0 +1,108 @@ +// @vitest-environment jsdom + +// Acceptance defect #100: handing a project off to an editor or a CLI agent is +// an async operation — the daemon spawns the editor, or we build and write the +// CLI prompt to the clipboard — but the control gave no in-flight feedback. It +// just sat there until the result arrived, which reads as "clicking did +// nothing". Every handoff control now swaps its icon for the shared spinner +// while its own action is pending, so the click is always acknowledged. +// +// The invariant these lock in: the control that was clicked shows a spinner +// (`.icon-spin`, the canonical ``) for exactly as long as +// its own handoff promise is outstanding, then returns to its resting icon. + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { HandoffButton } from '../../src/components/HandoffButton'; +import { I18nProvider } from '../../src/i18n'; +import type { HostEditorsResponse } from '@open-design/contracts'; + +const fetchHostEditors = vi.fn<() => Promise>(); +const openProjectInEditor = vi.fn(); +const copyToClipboard = vi.fn(); + +vi.mock('../../src/providers/registry', () => ({ + fetchHostEditors: () => fetchHostEditors(), + openProjectInEditor: (...args: unknown[]) => openProjectInEditor(...args), +})); + +vi.mock('../../src/lib/copy-to-clipboard', () => ({ + copyToClipboard: (...args: unknown[]) => copyToClipboard(...args), +})); + +afterEach(() => { + cleanup(); + window.localStorage.clear(); + fetchHostEditors.mockReset(); + openProjectInEditor.mockReset(); + copyToClipboard.mockReset(); +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +const oneEditor: HostEditorsResponse = { + platform: 'darwin', + editors: [{ id: 'vscode', label: 'VS Code', available: true }], +}; + +describe('HandoffButton loading feedback (#100)', () => { + it('spins the primary trigger while the editor launch is in flight', async () => { + fetchHostEditors.mockResolvedValue(oneEditor); + const gate = deferred(); + openProjectInEditor.mockReturnValue(gate.promise); + + render( + + + , + ); + + const trigger = await screen.findByTestId('handoff-trigger'); + expect(trigger.querySelector('.icon-spin')).toBeNull(); + + fireEvent.click(trigger); + + // The click is acknowledged immediately with an in-flight spinner. + await waitFor(() => expect(trigger.querySelector('.icon-spin')).not.toBeNull()); + expect(openProjectInEditor).toHaveBeenCalledWith('p1', 'vscode', null); + + // Once the launch settles, the spinner is gone. + gate.resolve(); + await waitFor(() => expect(trigger.querySelector('.icon-spin')).toBeNull()); + }); + + it('spins a CLI agent card while its prompt copy is in flight', async () => { + fetchHostEditors.mockResolvedValue(oneEditor); + const gate = deferred(); + copyToClipboard.mockReturnValue(gate.promise); + + render( + + + , + ); + + // Open the picker (the split trigger launches the editor, the caret opens + // the menu) and switch to the CLI / agents tab. + fireEvent.click(await screen.findByTestId('handoff-caret')); + fireEvent.click(screen.getByText('Copy for CLI')); + + const claude = await screen.findByTestId('handoff-cli-item-claude'); + expect(claude.querySelector('.icon-spin')).toBeNull(); + + fireEvent.click(claude); + + await waitFor(() => expect(claude.querySelector('.icon-spin')).not.toBeNull()); + expect(copyToClipboard).toHaveBeenCalled(); + + gate.resolve(true); + await waitFor(() => expect(claude.querySelector('.icon-spin')).toBeNull()); + }); +}); diff --git a/apps/web/tests/components/HomeHero.rail.test.tsx b/apps/web/tests/components/HomeHero.rail.test.tsx index 2362237b026..cf430cd3445 100644 --- a/apps/web/tests/components/HomeHero.rail.test.tsx +++ b/apps/web/tests/components/HomeHero.rail.test.tsx @@ -102,25 +102,45 @@ function renderHero(overrides: Partial> = return { onPickChip, onPickPlugin, onPickExamplePlugin, onOpenPluginDetails, onClearActiveChip }; } +// #5517 drops the inline template card rail (and the "Start with a template… / +// or start a blank project" bar that used to hold it) from Home. The composer +// footer's radial template picker is now the only in-hero scenario surface, so +// tests reach templates through the pill instead of `home-hero-rail-*` cards. +function openTemplatePicker() { + fireEvent.click(screen.getByTestId('home-hero-template-trigger')); +} + +function pickTemplate(chipId: string) { + openTemplatePicker(); + fireEvent.click(screen.getByTestId(`home-hero-template-wedge-${chipId}`)); +} + describe('HomeHero intent rail', () => { - it('renders creation chips as composer tabs and collapses shortcuts behind More', () => { + it('offers every scenario template through the composer template picker', () => { renderHero(); - const tabs = screen.getByTestId('home-hero-type-tabs'); + openTemplatePicker(); for (const chip of HOME_HERO_CHIPS) { - if (chip.group === 'create') { - const node = screen.getByTestId(`home-hero-rail-${chip.id}`); - expect(node).toBeTruthy(); - expect(tabs.contains(node)).toBe(true); + const wedge = screen.queryByTestId(`home-hero-template-wedge-${chip.id}`); + if (chip.group === 'create' && chip.action.kind === 'apply-scenario') { + expect(wedge).toBeTruthy(); } else { - expect(screen.queryByTestId(`home-hero-rail-${chip.id}`)).toBeNull(); + // Brand Kit (its own action) and the migrate shortcuts are reached from + // the Brand Kit tab, the Extensions tab, and the composer + menu. + expect(wedge).toBeNull(); } } - fireEvent.click(screen.getByTestId('home-hero-shortcuts-trigger')); - const menu = screen.getByTestId('home-hero-shortcuts-menu'); - for (const chip of HOME_HERO_CHIPS.filter((item) => item.group === 'migrate')) { - const node = screen.getByTestId(`home-hero-rail-${chip.id}`); - expect(node).toBeTruthy(); - expect(menu.contains(node)).toBe(true); + }); + + it('no longer renders the inline template rail below the composer', () => { + renderHero({ onStartBlankProject: vi.fn() }); + + expect(screen.queryByTestId('home-hero-template-section')).toBeNull(); + expect(screen.queryByTestId('home-hero-template-toggle')).toBeNull(); + expect(screen.queryByTestId('home-hero-blank-project')).toBeNull(); + expect(screen.queryByTestId('home-hero-type-tabs')).toBeNull(); + expect(screen.queryByTestId('home-hero-shortcuts-trigger')).toBeNull(); + for (const chip of HOME_HERO_CHIPS) { + expect(screen.queryByTestId(`home-hero-rail-${chip.id}`)).toBeNull(); } }); @@ -140,7 +160,7 @@ describe('HomeHero intent rail', () => { it('forwards the matching chip descriptor when clicked', () => { const { onPickChip } = renderHero(); - fireEvent.click(screen.getByTestId('home-hero-rail-image')); + pickTemplate('image'); expect(onPickChip).toHaveBeenCalledTimes(1); expect(onPickChip).toHaveBeenCalledWith(findChip('image')); }); @@ -153,22 +173,6 @@ describe('HomeHero intent rail', () => { expect(node.textContent).toContain('Video'); }); - it('keeps the blank project entry visible after a template is selected', () => { - const onStartBlankProject = vi.fn(); - renderHero({ activeChipId: 'deck', onStartBlankProject }); - - expect(screen.queryByTestId('home-hero-template-section')).toBeNull(); - const promptExamples = screen.getByTestId('home-hero-prompt-examples'); - const blankProject = screen.getByTestId('home-hero-blank-project'); - expect(blankProject.textContent).toContain('start a blank project'); - expect( - promptExamples.compareDocumentPosition(blankProject) & Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); - - fireEvent.click(blankProject); - expect(onStartBlankProject).toHaveBeenCalledTimes(1); - }); - it('does not reserve an empty active-context row for a hidden chip-bound plugin', () => { renderHero({ activeChipId: 'wireframe', @@ -184,15 +188,14 @@ describe('HomeHero intent rail', () => { it('lets the active creation chip be removed from the composer', () => { const { onClearActiveChip } = renderHero({ activeChipId: 'prototype' }); fireEvent.click(screen.getByTestId('home-hero-template-trigger')); - fireEvent.click(screen.getByTestId('home-hero-template-clear')); + fireEvent.click(screen.getByTestId('home-hero-template-radial-clear')); expect(onClearActiveChip).toHaveBeenCalledTimes(1); }); - it('clears the template pill to None even after a hovered rail card was picked', () => { - // The rail owns the hover-preview and unmounts the instant a template - // becomes active, so its mouseleave never fires — the preview must not - // outlive the committed selection or Clear leaves a stale pill (issue: the - // pill stayed "Slide deck" after Clear). + it('tracks the committed template on the footer pill and resets it on clear', () => { + // The pill mirrors the committed chip: it must pick the label up when a + // template becomes active and fall back to the empty "Template" kicker the + // moment the chip is cleared (issue: the pill stayed "Slide deck"). const baseProps = { prompt: '', onPromptChange: () => undefined, @@ -213,41 +216,18 @@ describe('HomeHero intent rail', () => { } as React.ComponentProps; const { rerender } = render(); + expect(screen.getByTestId('home-hero-template-trigger').textContent).not.toContain('Slide deck'); - // Hover the Slide deck card → the footer pill previews it. - fireEvent.mouseEnter(screen.getByTestId('home-hero-rail-deck')); - expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('Slide deck'); - - // Pick commits the chip; the rail unmounts without firing mouseleave. + // Picking a template from the radial commits the chip through the host. rerender(); expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('Slide deck'); - // Clear nulls the active chip — the pill must fall back to None. + // Clear nulls the active chip — the pill must fall back to the empty + // state. Round-4 skin: no "None" placeholder text at rest; the gray + // The creation-type kicker alone reads as empty. rerender(); const trigger = screen.getByTestId('home-hero-template-trigger'); - expect(trigger.textContent).toContain('None'); - expect(trigger.textContent).not.toContain('Slide deck'); - }); - - it('clears the template pill to None when Clear is pressed on a stale hover-preview', () => { - // Hovering a rail card previews it in the footer pill while the active chip - // is still null. The reset that drops the preview keys on the *committed* - // chip changing, so when the pointer never leaves the card (or the rail - // unmounts mid-hover) the preview outlives the hover with activeChipId still - // null. Pressing Clear there is a no-op on the active chip, so the pill must - // drop the preview itself or it stays stuck on the hovered template. - // (Reported: pill stayed on the picked template after Clear.) - const { onClearActiveChip } = renderHero({ activeChipId: null }); - - fireEvent.mouseEnter(screen.getByTestId('home-hero-rail-deck')); - expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('Slide deck'); - - fireEvent.click(screen.getByTestId('home-hero-template-trigger')); - fireEvent.click(screen.getByTestId('home-hero-template-clear')); - - expect(onClearActiveChip).toHaveBeenCalledTimes(1); - const trigger = screen.getByTestId('home-hero-template-trigger'); - expect(trigger.textContent).toContain('None'); + expect(trigger.textContent).toContain('Creation type'); expect(trigger.textContent).not.toContain('Slide deck'); }); @@ -310,21 +290,20 @@ describe('HomeHero intent rail', () => { const presets = screen.getAllByTestId('home-hero-plugin-preset'); expect(presets).toHaveLength(1); // The preset card is now a thumbnail + name only; the prompt blurb was - // dropped from the card face but is still passed through on Use below. + // dropped from the card face but is still passed through on click below. expect(presets[0]?.textContent).toContain('Investor deck'); - // Clicking the card body opens the preview (detail modal), not the seed. + // The whole card is the single click-to-use affordance (2026-07 removed + // the hover-revealed Use/Remix overlay and the card-click-opens-details + // behavior, restoring the #5517 baseline) — clicking it directly seeds + // the composer with the preset's brief. fireEvent.click(presets[0]!); - expect(onOpenPluginDetails).toHaveBeenCalledWith(deckPlugin); - expect(onPickExamplePlugin).not.toHaveBeenCalled(); - - // The Use button is what seeds the composer with the preset's brief. - fireEvent.click(screen.getByTestId('home-hero-plugin-preset-use-example-deck-a')); expect(onPickExamplePlugin).toHaveBeenCalledWith( deckPlugin, 'deck', 'Create with a focused brief using Investor deck', ); + expect(onOpenPluginDetails).not.toHaveBeenCalled(); }); it('maps powered WebGL presets to the WebGL chip without exposing a Worker chip', () => { @@ -481,30 +460,21 @@ describe('HomeHero intent rail', () => { ]); }); - it('disables every visible chip while a plugin apply is in flight', () => { - renderHero({ pendingPluginId: 'od-figma-migration', pendingChipId: 'figma' }); - for (const chip of HOME_HERO_CHIPS.filter((item) => item.group === 'create')) { - const node = screen.getByTestId(`home-hero-rail-${chip.id}`); - expect((node as HTMLButtonElement).disabled).toBe(true); - } - const trigger = screen.getByTestId('home-hero-shortcuts-trigger') as HTMLButtonElement; - expect(trigger.disabled).toBe(true); - expect(trigger.className).toContain('is-pending'); - }); - - it('shows plugin authoring with the starter shortcuts after More opens', () => { - renderHero(); - fireEvent.click(screen.getByTestId('home-hero-shortcuts-trigger')); - const createPluginGroup = screen - .getByTestId('home-hero-rail-create-plugin') - .closest('[data-rail-group]'); - - expect(createPluginGroup?.getAttribute('data-rail-group')).toBe('migrate'); - for (const id of ['figma', 'template']) { - expect(screen.getByTestId(`home-hero-rail-${id}`).closest('[data-rail-group]')) - .toBe(createPluginGroup); + it('disables every template while a plugin apply is in flight', () => { + const { onPickChip } = renderHero({ + pendingPluginId: 'od-figma-migration', + pendingChipId: 'figma', + }); + openTemplatePicker(); + const scenarioChips = HOME_HERO_CHIPS.filter( + (item) => item.group === 'create' && item.action.kind === 'apply-scenario', + ); + for (const chip of scenarioChips) { + const wedge = screen.getByTestId(`home-hero-template-wedge-${chip.id}`); + expect(wedge.getAttribute('aria-disabled')).toBe('true'); } - expect(screen.queryByTestId('home-hero-rail-folder')).toBeNull(); + fireEvent.click(screen.getByTestId(`home-hero-template-wedge-${scenarioChips[0]!.id}`)); + expect(onPickChip).not.toHaveBeenCalled(); }); it('keeps the generic fallback in the free-form prompt instead of an Other chip', () => { diff --git a/apps/web/tests/components/HomeHero.scenario-cards.test.tsx b/apps/web/tests/components/HomeHero.scenario-cards.test.tsx index 6771e4d1a6d..ffa21a2bc53 100644 --- a/apps/web/tests/components/HomeHero.scenario-cards.test.tsx +++ b/apps/web/tests/components/HomeHero.scenario-cards.test.tsx @@ -70,15 +70,22 @@ function renderHero(overrides: Partial> = render(); } +// #5517 removed the illustrated scenario-card rail from Home; scenarios are +// picked from the composer footer's radial template picker instead. +function openTemplatePicker() { + fireEvent.click(screen.getByTestId('home-hero-template-trigger')); +} + describe('HomeHero scenario cards', () => { - it('renders each create scenario card with a title and a description', () => { + it('labels each create scenario in the composer template picker', () => { renderHero(); - const prototype = screen.getByTestId('home-hero-rail-prototype'); - expect(prototype.textContent).toContain('Prototype'); - expect(prototype.textContent).toContain('Interactive app mockups'); - - const deck = screen.getByTestId('home-hero-rail-deck'); - expect(deck.textContent).toContain('Presentations & pitch decks'); + openTemplatePicker(); + expect( + screen.getByTestId('home-hero-template-wedge-prototype').getAttribute('aria-label'), + ).toContain('UI Mockup'); + expect( + screen.getByTestId('home-hero-template-wedge-deck').getAttribute('aria-label'), + ).toContain('Slide deck'); }); it('leads the create rail with Website clone, then the slide deck', () => { @@ -87,12 +94,11 @@ describe('HomeHero scenario cards', () => { expect(ordered[1]?.id).toBe('deck'); }); - it('adds the finer-grained scenarios as create cards routed to a scenario plugin', () => { + it('adds the finer-grained scenarios as templates routed to a scenario plugin', () => { renderHero(); + openTemplatePicker(); for (const id of ['wireframe', 'mobile', 'document']) { - const card = screen.getByTestId(`home-hero-rail-${id}`); - const tabs = screen.getByTestId('home-hero-type-tabs'); - expect(tabs.contains(card)).toBe(true); + expect(screen.getByTestId(`home-hero-template-wedge-${id}`)).toBeTruthy(); expect(findChip(id)?.action.kind).toBe('apply-scenario'); } // Wireframe reuses the web-prototype seed at lo-fi fidelity. diff --git a/apps/web/tests/components/HomeView.chip-restore.test.tsx b/apps/web/tests/components/HomeView.chip-restore.test.tsx new file mode 100644 index 00000000000..ef1cf9c8a2f --- /dev/null +++ b/apps/web/tests/components/HomeView.chip-restore.test.tsx @@ -0,0 +1,267 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/components/home-hero/PlaceholderCarousel', () => ({ + PlaceholderCarousel: () => null, +})); + +vi.mock('../../src/collab/useWorkspaceContext', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useWorkspaceContext: () => ({ + context: null, + loading: false, + failure: 'unsupported' as const, + }), + }; +}); + +import { HomeView } from '../../src/components/HomeView'; + +// Regression coverage for 飞书 recvqg21bqVuvE (P0): the selected creation-type +// chip and its bound example-prompt plugin were lost whenever the user +// visited Settings and came back. Settings is a standalone page — App.tsx +// swaps the whole `appMain` slot — so `EntryView` (and the `HomeView` inside +// it) really unmounts and remounts, unlike the visibility-toggled +// Home<->Community/... switches EntryShell otherwise uses (covered by +// HomeView.seed-while-mounted.test.tsx). The prompt text and design-system +// pick already survived that round trip via their own localStorage draft; +// the chip/plugin selection (`active` in HomeView) did not, because `active` +// holds a live `InstalledPluginRecord` + apply result that cannot round-trip +// through JSON. This test drives a REAL `unmount()` + fresh `render()` (not +// just a re-render) to reproduce the actual teardown Settings causes. +const DEFAULT_PLUGIN = { + id: 'od-new-generation', + title: 'New generation', + version: '0.1.0', + trust: 'bundled' as const, + sourceKind: 'bundled' as const, + source: '/tmp/new-generation', + capabilitiesGranted: ['prompt:inject'], + fsPath: '/tmp/new-generation', + installedAt: 0, + updatedAt: 0, + manifest: { + name: 'od-new-generation', + title: 'New generation', + version: '0.1.0', + description: 'Create new design artifacts', + od: { + kind: 'scenario', + taskKind: 'new-generation', + useCase: { query: 'Create a plugin.' }, + }, + }, +}; + +// The Prototype chip binds to the bundled `example-web-prototype` plugin +// (mirrors HomeView.prefill.test.tsx's fixture of the same name). +const WEB_PROTOTYPE_PLUGIN = { + ...DEFAULT_PLUGIN, + id: 'example-web-prototype', + title: 'Web Prototype', + source: '/tmp/web-prototype', + fsPath: '/tmp/web-prototype', + manifest: { + ...DEFAULT_PLUGIN.manifest, + name: 'example-web-prototype', + title: 'Web Prototype', + description: 'General-purpose desktop web prototype.', + od: { + kind: 'scenario', + taskKind: 'new-generation', + useCase: { + query: 'Build a {{fidelity}} {{artifactKind}} for {{audience}} using {{designSystem}} from {{template}}.', + }, + inputs: [ + { name: 'artifactKind', type: 'string', required: true, default: 'web prototype', label: 'Artifact kind' }, + { + name: 'fidelity', + type: 'select', + required: true, + options: ['wireframe', 'high-fidelity'], + default: 'high-fidelity', + label: 'Fidelity', + }, + { name: 'audience', type: 'string', required: true, default: 'product evaluators', label: 'Audience' }, + { + name: 'designSystem', + type: 'string', + default: 'the active project design system', + label: 'Design system', + }, + { name: 'template', type: 'string', default: 'the bundled web prototype seed', label: 'Template' }, + ], + }, + }, +}; + +const WEB_PROTOTYPE_APPLY_RESULT = { + query: WEB_PROTOTYPE_PLUGIN.manifest.od.useCase.query, + contextItems: [], + inputs: WEB_PROTOTYPE_PLUGIN.manifest.od.inputs, + assets: [], + mcpServers: [], + trust: 'trusted', + capabilitiesGranted: ['prompt:inject'], + capabilitiesRequired: ['prompt:inject'], + appliedPlugin: { + snapshotId: 'snap-web-prototype', + pluginId: 'example-web-prototype', + pluginVersion: '0.1.0', + manifestSourceDigest: 'a'.repeat(64), + inputs: { + artifactKind: 'web prototype', + fidelity: 'high-fidelity', + audience: 'product evaluators', + designSystem: 'the active project design system', + template: 'the bundled web prototype seed', + }, + resolvedContext: { items: [] }, + capabilitiesGranted: ['prompt:inject'], + capabilitiesRequired: ['prompt:inject'], + assetsStaged: [], + taskKind: 'new-generation', + appliedAt: 0, + connectorsRequired: [], + connectorsResolved: [], + mcpServers: [], + status: 'fresh', + }, +}; + +function stubAnimationFrame() { + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + const id = window.setTimeout(() => cb(window.performance.now()), 0); + return id; + }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + window.clearTimeout(id); + }); +} + +// Mirrors HomeView.prefill.test.tsx's local helper: the inline template rail +// was replaced by the composer footer's radial Template picker (#5517). +async function pickHomeTemplate(id: string) { + const trigger = await screen.findByTestId('home-hero-template-trigger'); + await waitFor(() => expect((trigger as HTMLButtonElement).disabled).toBe(false)); + fireEvent.click(trigger); + const wedge = await screen.findByTestId(`home-hero-template-wedge-${id}`); + await waitFor(() => + expect(screen.getByTestId(`home-hero-template-wedge-${id}`).getAttribute('aria-disabled')).not.toBe('true'), + ); + fireEvent.click(wedge); +} + +function fetchMockFor(plugins: unknown[]) { + return vi.fn(async (url) => { + if (typeof url === 'string' && url === '/api/plugins') { + return new Response(JSON.stringify({ plugins }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')) { + return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + throw new Error(`unexpected fetch ${url}`); + }); +} + +describe('HomeView chip/plugin selection survives a real unmount+remount', () => { + afterEach(() => { + vi.unstubAllGlobals(); + cleanup(); + window.localStorage.clear(); + window.sessionStorage.clear(); + }); + + it('restores the selected creation-type chip and its bound plugin after Home fully unmounts and remounts', async () => { + const fetchMock = fetchMockFor([WEB_PROTOTYPE_PLUGIN]); + vi.stubGlobal('fetch', fetchMock); + stubAnimationFrame(); + + const { unmount } = render( + undefined} + onOpenProject={() => undefined} + onViewAllProjects={() => undefined} + />, + ); + + await screen.findByTestId('home-hero-input'); + await pickHomeTemplate('prototype'); + + // 'UI Mockup' is the current en localization of the `prototype` chip + // (homeHero.chip.prototype) — not literal string 'Prototype'. + await waitFor(() => { + expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('UI Mockup'); + }); + + // Real teardown — the same kind of unmount App.tsx performs when + // `route.view === 'settings'` replaces the whole `appMain` slot (a + // distinct scenario from EntryShell's visibility-toggled tab switches, + // which never unmount HomeView at all). + unmount(); + + render( + undefined} + onOpenProject={() => undefined} + onViewAllProjects={() => undefined} + />, + ); + + await screen.findByTestId('home-hero-input'); + await waitFor(() => { + expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('UI Mockup'); + }); + // The restore resolves the plugin's snapshot the same way a fresh chip + // click does (see the effect's docblock for why it doesn't defer). + await waitFor(() => { + expect( + fetchMock.mock.calls.some( + ([url]) => typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply'), + ), + ).toBe(true); + }); + }); + + it('silently drops a persisted chip pointing at a since-uninstalled plugin', async () => { + // Seed localStorage as if a prior mount had bound the Prototype chip, + // then remount with a catalog that no longer has that plugin installed. + window.localStorage.setItem( + 'open-design:home-composer:chip', + JSON.stringify({ chipId: 'prototype', pluginId: 'example-web-prototype', projectKind: 'prototype' }), + ); + const fetchMock = fetchMockFor([]); + vi.stubGlobal('fetch', fetchMock); + stubAnimationFrame(); + + render( + undefined} + onOpenProject={() => undefined} + onViewAllProjects={() => undefined} + />, + ); + + await screen.findByTestId('home-hero-input'); + + // No crash, no error banner, and the stale pointer is cleared so it does + // not keep retrying on every future mount. + await waitFor(() => { + expect(window.localStorage.getItem('open-design:home-composer:chip')).toBeNull(); + }); + expect(screen.queryByRole('alert')).toBeNull(); + }); +}); diff --git a/apps/web/tests/components/HomeView.community-filter-decouple.test.tsx b/apps/web/tests/components/HomeView.community-filter-decouple.test.tsx index b8ddd5f08a1..da1360dd1c4 100644 --- a/apps/web/tests/components/HomeView.community-filter-decouple.test.tsx +++ b/apps/web/tests/components/HomeView.community-filter-decouple.test.tsx @@ -12,14 +12,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { HomeView } from '../../src/components/HomeView'; +import type { InstalledPluginRecord } from '@open-design/contracts'; +import { PluginsHomeSection } from '../../src/components/PluginsHomeSection'; import { I18nProvider } from '../../src/i18n'; function makeHomePlugin( id: string, mode: string, preview?: Record, -) { +): InstalledPluginRecord { return { id, title: id, @@ -69,93 +70,66 @@ describe('HomeView community filter decoupling', () => { }); it('keeps the Community category selection independent from the hero type chips', async () => { - const fetchMock = vi.fn(async (url) => { - if (typeof url === 'string' && url === '/api/plugins') { - return new Response(JSON.stringify({ plugins: PLUGINS }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }); - } - throw new Error(`unexpected fetch ${url}`); - }); - vi.stubGlobal('fetch', fetchMock); - render( - undefined} - onOpenProject={() => undefined} - onViewAllProjects={() => undefined} + undefined} + onOpenDetails={() => undefined} + preferDefaultFacet + cardLayout="gallery" /> , ); - // Home boots with a default active type chip, but the Community grid starts - // from its own All selection so users can browse the full catalog first. - // Wait for the selected state, not just the pill mount, so the assertion - // stays stable under full-suite CI load. + // The Community gallery owns its own facet state, so it is mounted on its + // own here — there is no hero rail for it to couple to. It boots on its own + // All selection (the gallery layout keeps the All bucket, per #5762's + // sibling change to PluginsHomeSection) no matter which type chip Home + // would have started on. Wait for the selected state, not just the pill + // mount, so the assertion stays stable under full-suite CI load. await waitFor(() => { expect(ariaSelected('plugins-home-pill-category-all')).toBe('true'); }); expect(ariaSelected('plugins-home-pill-category-deck')).toBe('false'); expect(ariaSelected('plugins-home-pill-category-prototype')).toBe('false'); - // Picking another chip drives the composer, not the gallery filter. - fireEvent.click(await screen.findByTestId('home-hero-rail-deck')); - await waitFor(() => { - expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('Slide deck'); - }); - expect(ariaSelected('plugins-home-pill-category-all')).toBe('true'); - expect(ariaSelected('plugins-home-pill-category-deck')).toBe('false'); + // The gallery's own pills still work locally, independent of any hero chip. + fireEvent.click(screen.getByTestId('plugins-home-pill-category-deck')); + expect(ariaSelected('plugins-home-pill-category-all')).toBe('false'); + expect(ariaSelected('plugins-home-pill-category-deck')).toBe('true'); - // And the gallery's own pills still work locally. + // And switching to a different gallery pill selects it locally. fireEvent.click(screen.getByTestId('plugins-home-pill-category-prototype')); - expect(ariaSelected('plugins-home-pill-category-all')).toBe('false'); + expect(ariaSelected('plugins-home-pill-category-deck')).toBe('false'); expect(ariaSelected('plugins-home-pill-category-prototype')).toBe('true'); }); it('opens duplicated gallery examples at the copied entry file', async () => { const onOpenProject = vi.fn(); - const fetchMock = vi.fn(async (url, init) => { - if (typeof url === 'string' && url === '/api/plugins') { - return new Response(JSON.stringify({ plugins: DUPLICABLE_PLUGINS }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }); - } + const onDuplicate = vi.fn((record: InstalledPluginRecord) => { if ( - typeof url === 'string' && - url === '/api/plugins/example-html-prototype/duplicate-project' && - init?.method === 'POST' + record.id === 'example-html-prototype' ) { - return new Response( - JSON.stringify({ - ok: true, - projectId: 'duplicated-project', - conversationId: 'duplicated-conversation', - relPath: 'index.html', - project: { id: 'duplicated-project', name: 'Duplicated' }, - sourcePluginId: 'example-html-prototype', - sourceEntry: 'example.html', - copiedFiles: 1, - skippedFiles: 0, - warnings: [], - }), - { status: 201, headers: { 'content-type': 'application/json' } }, - ); + onOpenProject('duplicated-project', 'index.html'); } - throw new Error(`unexpected fetch ${url}`); }); - vi.stubGlobal('fetch', fetchMock); render( - undefined} - onOpenProject={onOpenProject} - onViewAllProjects={() => undefined} + undefined} + onDuplicate={onDuplicate} + onOpenDetails={() => undefined} + preferDefaultFacet={false} + cardLayout="gallery" /> , ); diff --git a/apps/web/tests/components/HomeView.composer-sending-state.test.tsx b/apps/web/tests/components/HomeView.composer-sending-state.test.tsx index ee028decb79..f5ea76f8886 100644 --- a/apps/web/tests/components/HomeView.composer-sending-state.test.tsx +++ b/apps/web/tests/components/HomeView.composer-sending-state.test.tsx @@ -17,6 +17,18 @@ vi.mock('../../src/components/home-hero/PlaceholderCarousel', () => ({ PlaceholderCarousel: () => null, })); +vi.mock('../../src/collab/useWorkspaceContext', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useWorkspaceContext: () => ({ + context: null, + loading: false, + failure: 'unsupported' as const, + }), + }; +}); + import { HomeView } from '../../src/components/HomeView'; import { I18nProvider } from '../../src/i18n'; import { writeHomeGuideStage } from '../../src/components/home-hero/firstRunGuide'; @@ -94,7 +106,10 @@ describe('home composer sending state', () => { await waitFor(() => { expect(submit.disabled).toBe(true); }); - expect(submit.textContent).toContain('Sending…'); + // #5517 made the submit button icon-only (spinner while sending); the + // Sending… state now lives on the accessible name instead of a label span. + expect(submit.getAttribute('aria-label')).toBe('Sending…'); + expect(submit.getAttribute('aria-busy')).toBe('true'); expect(submit.className).toContain('is-sending'); // A second click during the in-flight window must not start a second run. @@ -128,7 +143,9 @@ describe('home composer sending state', () => { await waitFor(() => { expect(submit.disabled).toBe(false); }); - expect(submit.textContent).toContain('Send'); + // Icon-only button (#5517): the idle accessible name replaces the old + // visible Send label. + expect(submit.getAttribute('aria-label')).toBe('Run'); expect(submit.className).not.toContain('is-sending'); expect((await screen.findByRole('alert')).textContent).toMatch(/try again/i); @@ -176,9 +193,12 @@ describe('home composer sending state', () => { , ); + // #5517 removed the inline template rail; templates are picked from the + // composer footer's radial Template picker. + fireEvent.click(await screen.findByTestId('home-hero-template-trigger')); // Seeding through a fallback prompt-example card is what arms the // examplePromptContext marker. - fireEvent.click(await screen.findByTestId('home-hero-rail-prototype')); + fireEvent.click(await screen.findByTestId('home-hero-template-wedge-prototype')); const exampleCards = await screen.findAllByTestId('home-hero-prompt-example'); fireEvent.click(exampleCards[0]!); diff --git a/apps/web/tests/components/HomeView.context-picker.test.tsx b/apps/web/tests/components/HomeView.context-picker.test.tsx index 38d1a17fd51..9ce4bf91df1 100644 --- a/apps/web/tests/components/HomeView.context-picker.test.tsx +++ b/apps/web/tests/components/HomeView.context-picker.test.tsx @@ -14,6 +14,18 @@ vi.mock('../../src/components/home-hero/PlaceholderCarousel', () => ({ PlaceholderCarousel: () => null, })); +vi.mock('../../src/collab/useWorkspaceContext', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useWorkspaceContext: () => ({ + context: null, + loading: false, + failure: 'unsupported' as const, + }), + }; +}); + import { HomeView } from '../../src/components/HomeView'; import { homeHeroPromptText, setHomeHeroPrompt } from '../helpers/home-hero-lexical'; @@ -112,6 +124,15 @@ afterEach(() => { vi.unstubAllGlobals(); }); +// #5517 removed the inline template rail from Home; scenario templates are +// picked from the composer footer's radial Template picker instead. +async function pickHomeTemplate(id: string) { + const trigger = await screen.findByTestId('home-hero-template-trigger'); + await waitFor(() => expect((trigger as HTMLButtonElement).disabled).toBe(false)); + fireEvent.click(trigger); + fireEvent.click(await screen.findByTestId(`home-hero-template-wedge-${id}`)); +} + describe('HomeView context picker', () => { it('stages pasted files on Home and submits them as first-turn context', async () => { const fetchMock = vi.fn(async (url) => { @@ -335,9 +356,9 @@ describe('HomeView context picker', () => { />, ); - fireEvent.click(await screen.findByTestId('home-hero-rail-prototype')); + await pickHomeTemplate('prototype'); await waitFor(() => { - expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('Prototype'); + expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('UI Mockup'); }); screen.getByTestId('home-hero-input'); @@ -347,7 +368,10 @@ describe('HomeView context picker', () => { await waitFor(() => { expect(screen.getByTestId('home-hero-active-skill')).toBeTruthy(); - expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('None'); + // Round-4 skin: the cleared template pill shows the gray creation-type + // kicker instead of a "None" placeholder label. + expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('Creation type'); + expect(screen.getByTestId('home-hero-template-trigger').textContent).not.toContain('Slide deck'); }); fireEvent.click(screen.getByTestId('home-hero-submit')); @@ -415,9 +439,9 @@ describe('HomeView context picker', () => { expect(screen.getByTestId('home-hero-active-skill')).toBeTruthy(); }); - fireEvent.click(await screen.findByTestId('home-hero-rail-prototype')); + await pickHomeTemplate('prototype'); await waitFor(() => { - expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('Prototype'); + expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('UI Mockup'); expect(screen.queryByTestId('home-hero-active-skill')).toBeNull(); }); diff --git a/apps/web/tests/components/HomeView.first-run-guide.test.tsx b/apps/web/tests/components/HomeView.first-run-guide.test.tsx index bec1c3ac0cb..8d5a2dfc836 100644 --- a/apps/web/tests/components/HomeView.first-run-guide.test.tsx +++ b/apps/web/tests/components/HomeView.first-run-guide.test.tsx @@ -59,23 +59,24 @@ afterEach(() => { window.localStorage.clear(); }); +// #5517 removed the inline template rail from Home, so beat 1 of the guide no +// longer has a chip card to sheen; the stage still arms on mount and advances +// when a template is picked from the composer footer's radial picker. +async function pickHomeTemplate(id: string) { + const trigger = await screen.findByTestId('home-hero-template-trigger'); + await waitFor(() => expect((trigger as HTMLButtonElement).disabled).toBe(false)); + fireEvent.click(trigger); + fireEvent.click(await screen.findByTestId(`home-hero-template-wedge-${id}`)); +} + describe('Home first-run guide trail', () => { - it('pulses the Prototype chip for a fresh user and advances on chip pick', async () => { + it('arms beat 1 for a fresh user and advances when a template is picked', async () => { stubPluginsFetch(); renderHome([]); expect(readHomeGuideStage()).toBe('chip'); - const chip = await screen.findByTestId('home-hero-rail-prototype'); - await waitFor( - () => { - expect(chip.className).toContain('home-hero__attention-sheen'); - }, - { timeout: 3000 }, - ); - - fireEvent.click(chip); + await pickHomeTemplate('prototype'); expect(readHomeGuideStage()).not.toBe('chip'); - expect(chip.className).not.toContain('home-hero__attention-sheen'); }); it('completes the trail silently for users who already have projects', async () => { @@ -86,8 +87,7 @@ describe('Home first-run guide trail', () => { await waitFor(() => { expect(readHomeGuideStage()).toBe('done'); }); - const chip = screen.queryByTestId('home-hero-rail-prototype'); - expect(chip?.className ?? '').not.toContain('home-hero__attention-sheen'); + expect(document.querySelector('.home-hero__attention-sheen')).toBeNull(); }); it('stays inert while projects are still loading', async () => { @@ -104,12 +104,10 @@ describe('Home first-run guide trail', () => { , ); - const chip = await screen.findByTestId('home-hero-rail-prototype'); + await screen.findByTestId('home-hero-input'); await new Promise((resolve) => setTimeout(resolve, 1200)); - // Unknown projects state: no pulse, and crucially the stage is NOT - // silently completed — a brand-new user still gets the trail once - // loading resolves. - expect(chip.className).not.toContain('home-hero__attention-sheen'); + // Unknown projects state: the stage is NOT silently completed — a + // brand-new user still gets the trail once loading resolves. expect(readHomeGuideStage()).toBe('chip'); }); @@ -129,7 +127,7 @@ describe('Home first-run guide trail', () => { // The user clicks a chip while projects are still loading — the stage // moves to 'card' before we know whether they are new. - fireEvent.click(await screen.findByTestId('home-hero-rail-prototype')); + await pickHomeTemplate('prototype'); expect(readHomeGuideStage()).toBe('card'); // Loading resolves: existing user. The stage must close so no chip's @@ -184,7 +182,7 @@ describe('Home first-run guide trail', () => { })); renderHome([]); - fireEvent.click(await screen.findByTestId('home-hero-rail-prototype')); + await pickHomeTemplate('prototype'); expect(readHomeGuideStage()).toBe('card'); const exampleCards = await screen.findAllByTestId('home-hero-prompt-example'); @@ -202,8 +200,9 @@ describe('Home first-run guide trail', () => { stubPluginsFetch(); renderHome([]); - const chip = await screen.findByTestId('home-hero-rail-prototype'); + await screen.findByTestId('home-hero-input'); await new Promise((resolve) => setTimeout(resolve, 1200)); - expect(chip.className).not.toContain('home-hero__attention-sheen'); + expect(readHomeGuideStage()).toBe('done'); + expect(document.querySelector('.home-hero__attention-sheen')).toBeNull(); }); }); diff --git a/apps/web/tests/components/HomeView.media-options.test.tsx b/apps/web/tests/components/HomeView.media-options.test.tsx index fe89b3d3bb8..9c3a3ca4f3e 100644 --- a/apps/web/tests/components/HomeView.media-options.test.tsx +++ b/apps/web/tests/components/HomeView.media-options.test.tsx @@ -7,6 +7,18 @@ vi.mock('../../src/components/home-hero/PlaceholderCarousel', () => ({ PlaceholderCarousel: () => null, })); +vi.mock('../../src/collab/useWorkspaceContext', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useWorkspaceContext: () => ({ + context: null, + loading: false, + failure: 'unsupported' as const, + }), + }; +}); + import { HomeView } from '../../src/components/HomeView'; import type { DesignSystemSummary, PromptTemplateSummary } from '../../src/types'; // HomeHero's prompt input migrated from a