diff --git a/.agents/skills/migrate-application-operation/SKILL.md b/.agents/skills/migrate-application-operation/SKILL.md new file mode 100644 index 00000000000..fa7923be6a7 --- /dev/null +++ b/.agents/skills/migrate-application-operation/SKILL.md @@ -0,0 +1,291 @@ +--- +name: migrate-application-operation +description: Migrate one existing Sim resource operation into the shared Principal and application-use-case architecture across internal APIs, public or versioned APIs, Copilot, and other trusted tool adapters. Use when removing route- or tool-local authorization and business logic, consolidating resource reads or writes behind semantic operation policies, or adding another surface to an existing application operation while preserving contracts, identity, errors, rate limits, audit, analytics, and compatibility behavior. Treat v1, uploads, streams, large bodies, bulk recursion, and polymorphic tools as explicitly scoped special cases. +--- + +# Migrate Application Operation + +Migrate one bounded semantic operation at a time. Share authorization and business behavior without forcing internal APIs, public APIs, Copilot, and other tools to share authentication, input schemas, or response shapes. + +## Enforce the application boundary + +Apply this invariant: + +> Every real operation on persisted or protected data enters through an authorized application use case. + +This includes mutations, content and metadata reads, canonical resource lookup, and reference-to-resource resolution when the lookup is authorization-sensitive. + +Surface helpers may: + +- Normalize an already-authenticated surface context into a `Principal`. +- Translate aliases or wire arguments into application input. +- Select a code-defined operation and application use case. +- Call the application use case. +- Translate typed results and errors into the surface contract. + +Surface helpers must not: + +- Query databases or storage. +- Decide workspace or resource authorization. +- Implement business transactions. +- Record semantic audit or shared domain notifications. +- Infer authoritative identity, workspace, audience, or scope from untrusted arguments. +- Substitute billing attribution for identity. + +A helper that resolves a path is valid only when the actual protected lookup runs through an authorized application resolver. If a helper begins doing real data work, move that work into an application use case. + +## Read the foundation first + +Read these files completely before editing: + +- `packages/auth/src/principal.ts` +- `apps/sim/lib/core/application/operation.ts` +- `apps/sim/lib/core/application/workspace-operation.ts` +- `apps/sim/lib/core/application/workspace-authorization.ts` +- `apps/sim/lib/core/application/authorized-workspace-use-case.ts` +- `apps/sim/lib/api/server/routes/internal-json-route.ts` +- `apps/sim/lib/api/server/routes/v2-json-route.ts` + +Use the file domain only as a representative golden slice: + +- `apps/sim/lib/workspace-files/application/operations.ts` +- `apps/sim/lib/workspace-files/application/authorized-workspace-file-use-case.ts` +- `apps/sim/lib/workspace-files/application/rename-workspace-file.ts` +- `apps/sim/lib/copilot/application/execute-file-use-case.ts` +- `apps/sim/lib/copilot/auth/file-delegation.ts` + +Then read the target domain's operation registry, application code, repositories, contracts, adapters, aliases, resume paths, and focused tests. Fail immediately if the shared foundation is absent. Do not recreate it inside the domain. + +## Bound the migration + +Inventory every entry point for the behavior before editing: + +- Internal HTTP routes and contracts. +- Public or versioned API routes and contracts. +- Copilot tools, aliases, resume paths, and polymorphic branches. +- Other tool servers, workflow executors, jobs, or service callers. +- Current authentication, authorization, workspace assertions, and concealment. +- Manager or orchestration call chains. +- Audit, notification, analytics, and billing side effects. +- Error/status/result behavior. +- Rate-limit identity, rollout gates, quota, and concurrency admission. + +Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent operations merely because they share a module. Do not modify v1 unless the request explicitly includes it. + +Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one. + +## Keep the layers distinct + +Use these responsibilities: + +1. Authentication adapter: verify the surface credential or trusted execution context and construct a `Principal`. +2. Route or tool adapter: select rate policy, parse its contract, translate input, call the application use case, and render its own result. +3. Application use case: load canonical context, compare asserted scope, authorize the semantic operation, execute business behavior, project semantic audit, and trigger shared domain effects. +4. Manager or repository: perform database and storage reads or writes using canonical identifiers and scope. Never accept credentials or principals. +5. Presenter: return only the surface success body or typed binary descriptor. Never construct auth, rate, or error behavior. + +For ordinary public JSON routes, preserve this order: + +```text +IP abuse limit + -> authenticate + -> build Principal + -> operation rate limit + -> parse surface contract + -> application use case + -> canonical load + -> asserted-scope concealment + -> current authorization + -> manager read or mutation + -> semantic audit + -> shared domain effects + -> surface presenter +``` + +Internal routes may omit the IP bucket or operation limit only through an explicit policy with a reason. Usage billing, storage quota, cost admission, and concurrency are separate from request-rate limiting. + +Never query API keys or sessions from the application layer. Never add fallback identity or authorization behavior. Propagate infrastructure failures instead of turning them into not-found or forbidden results. + +## Define the semantic operation once + +Add one stable entry to the target domain's operation registry: + +```ts +rename: defineWorkspaceOperation({ + id: 'widgets.rename', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], +}) +``` + +Do not create internal-, public-, or Copilot-specific versions of the same semantic operation. If two callers have materially different business or transactional semantics, define separate use cases and explain the distinction. + +Choose principal kinds from actual behavior. Do not accept every principal merely because the use case is shared. Workspace API keys have a write ceiling and cannot satisfy admin operations. The operation definition must fail fast when its role, workspace-key policy, and principal kinds disagree. + +Route declarations, tool adapters, and use cases must use the same literal operation. Runtime operation selection is permitted only from a trusted, code-defined registry. Never accept an operation ID or permission tag from an HTTP body, model argument, or other untrusted input. + +## Implement the application use case + +Use `defineAuthorizedWorkspaceUseCase` directly or a thin domain binding that supplies domain-specific authorization options: + +```ts +export const renameWidget = defineAuthorizedWorkspaceUseCase({ + operation: widgetOperations.rename, + resolveContext: ({ input }: { input: RenameWidgetInput }) => + loadCanonicalWidgetContext(input.id, input.assertedWorkspaceId), + authorizationOptions: { delegation: widgetDelegationPolicy }, + execute: async ({ input, context }) => renameWidgetRecord({ + workspaceId: context.workspaceId, + widgetId: context.resourceId, + name: input.name, + }), + projectAudit: ({ result }) => ({ + action: AuditAction.WIDGET_UPDATED, + resourceType: AuditResourceType.WIDGET, + resourceId: result.id, + resourceName: result.name, + }), + afterSuccess: ({ context }) => notifyWidgetsChanged(context.workspaceId), +}) +``` + +Adapt the example to the domain's real authorization options; do not copy invented field names. + +The wrapper must own this lifecycle: + +1. Reject disallowed principal kinds before protected loading. +2. Load canonical context and conceal asserted-scope mismatches as required. +3. Authorize the operation using current policy state. +4. Execute the manager or repository primitive. +5. Project audit from authoritative results. +6. Await shared post-success effects. + +Do not call shared authorization, principal audit attribution, or `recordAudit` manually from an ordinary migrated use-case body. Use `projectAudit` only when the operation has semantic audit. Return no audit entries for authoritative no-ops. Keep product analytics such as `captureServerEvent` surface-specific through the adapter's success hook. + +Inspect legacy orchestration before reusing it. If it already authorizes, audits, notifies, or captures analytics, call a lower-level primitive or remove duplicate responsibility for migrated callers. + +## Adapt internal APIs + +Use `defineInternalJsonRoute` for ordinary JSON routes. Explicitly declare the contract, session authentication policy, semantic operation, rate policy, error policy, input mapping, use case, and presenter when the wire result differs. + +The internal adapter owns session authentication and internal response envelopes. It must not implement workspace authorization. Preserve internal-only analytics through `onSuccess` after application success. + +Keep the route module declarative. If several internal routes repeat authentication, parsing, error rendering, or response construction, improve the shared internal route builder instead of adding a domain-specific route wrapper. + +## Adapt public or versioned APIs + +Use the appropriate public/versioned route builder, such as `defineV2JsonRoute`, with API-key authentication, explicit operation rate policy, rollout policy, external error projection, and an external presenter. + +Authentication and HTTP formatting may differ from internal APIs; authorization and business behavior must not. Rate-limit using the credential or principal subject, never a billed owner. Resolve billing attribution only for billing, quota, or legacy required-user fields. + +Keep surface contracts separate when their wire shapes differ. Reuse shared primitive schemas and domain validators for invariants such as IDs, names, bounds, and formats. Do not maintain duplicate internal and external schemas merely because the routes are separate; import the same schema when the wire shape is genuinely identical. Never cast one surface response into another. + +Keep v1 middleware and routes unchanged unless explicitly included. + +## Adapt Copilot + +Create one domain-level Copilot application adapter instead of constructing delegated principals in every tool: + +```ts +executeCopilotWidgetUseCase(context, renameWidget, input, { resourceId }) +``` + +That adapter must: + +- Require a trusted server-authored Copilot execution marker. +- Require the authenticated subject, canonical workspace, tool-call or execution identity, and required audience or lifecycle scope. +- Construct the shared delegated `Principal` in one place. +- Optionally bind the canonical resource scope after trusted resolution. +- Verify that the use case exposes a registered code-defined operation. +- Call the application use case directly. + +Never construct authoritative delegation from model-provided workspace IDs, user IDs, operation IDs, resource scope, or permission tags. Model arguments are requested targets only and must be checked against trusted execution context and canonical data. + +Tool handlers own argument aliases, resumable legacy names, abort checks, and tool-specific presentation. They must not query managers directly for protected operations or manually authorize. + +A Copilot reference helper may translate a path to a resource only by calling an authorized application resolver under the intended semantic operation. Passing a code-defined operation object is acceptable; passing a model-provided operation string is not. Reauthorizing during later execution is safe but redundant. When resolution and execution form one business operation, need a consistent snapshot, or appear repeatedly together, prefer a top-level application use case such as `renameWidgetByReference`. + +Special composition roots may resolve one principal and deliberately thread it through several application calls or lower-level admission stages. Keep this exceptional and explicit; ordinary tools should use the shared execution adapter. + +Map expected typed errors to safe tool results. Unknown errors must become generic system/retryable messages while retaining full causes in server logs. Never return raw database or storage errors to the model. + +## Adapt other internal or external tools + +Treat every tool runtime as a surface adapter: + +- Normalize its already-authenticated execution context into an existing `Principal` through one shared adapter for that runtime or domain. +- Call the same application use case used by HTTP and Copilot surfaces. +- Preserve the tool protocol's input, output, retry, and cancellation semantics. +- Keep authoritative workspace and subject scope server-authored. + +An internal caller is not automatically trusted to bypass authorization. It must supply an explicit principal or use a deliberately designed service/delegation principal. If the current principal model cannot express its authority, stop and extend the identity model intentionally; do not fall back to an owner, uploader, creator, or arbitrary user ID. + +External tool endpoints authenticate at their adapter exactly like public APIs. Do not authenticate again inside the application use case. + +## Preserve identity and attribution + +- Session and personal-key principals authorize through current human workspace permission. +- Personal API keys also respect the workspace's personal-key policy. +- Workspace keys authorize as the workspace under explicit operation policy and the write ceiling, independent of creator membership. +- Delegated principals re-check the current subject and their workspace, audience, expiry, execution, and resource scope. +- Billing owners are attribution for billing or legacy required columns only, never authorization, rate identity, delegated identity, audit actor, or human analytics identity. +- Preserve structured `PrincipalActor` metadata in semantic audit. + +If a required legacy user column cannot represent the real actor, label the compatibility attribution explicitly. Never pretend it is the acting human. + +## Handle special operations explicitly + +Do not force these through an ordinary JSON migration: + +- Upload or multipart lifecycles: bind immutable credential identity, reauthorize control legs and finalization, and make durable completion idempotent. +- Large bodies: authenticate and perform cheap admission before bounded buffering. +- Binary or streaming responses: use binary/stream builders and typed descriptors. +- Bulk or recursive operations: deduplicate and cap inputs and expansion, load all resources canonically, and define atomic versus best-effort behavior. +- Polymorphic tools: select the semantic operation only after trusted target-kind resolution; do not route unrelated branches through one domain registry. +- Multi-resource transactions: keep canonical scope predicates and derive audit from authoritative affected rows. + +Stop and report a missing design rather than weakening identity, authorization, limits, or errors. + +## Test the complete matrix + +Add focused tests for every migrated surface and principal kind allowed by the operation: + +- Application: allowed and disallowed roles, principal-kind rejection before canonical loading, workspace assertion mismatch, delegated scope, not found, conflict, no-op, and infrastructure propagation. +- Repository: canonical active lookup, workspace-predicated writes, archived resources, authoritative affected rows, and database error propagation. +- Internal API: authentication before parsing, exact contract, typed errors, and surface analytics only after success. +- Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers. +- Copilot or tools: trusted context, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes. +- Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op. + +Run at minimum: + +```bash +bunx vitest run +bunx biome check +bunx turbo run type-check --filter=sim --filter=@sim/auth +bun run check:api-validation:strict +git diff --check +``` + +Do not claim a check passed unless it completed successfully. + +## Work safely in parallel + +- Assign non-overlapping route modules and caller sets. Two methods in one route file are one ownership unit. +- Treat operation registries, contract families, route policies, and shared surface adapters as merge hotspots. +- Keep shared core foundations owned by one task; ordinary domain migrations should consume them without modifying them. +- Preserve unrelated working-tree changes. Never stage proposal docs, lockfile drift, or another agent's edits. +- Do not commit, push, or open a PR unless requested. + +## Hand off + +Report: + +1. Semantic operation, role, workspace-key policy, and principal kinds. +2. Migrated, deferred, and non-goal entry points. +3. Behavior preserved per internal, public, Copilot, and other tool surface. +4. Identity construction and authoritative scope source for each surface. +5. Files changed and shared merge hotspots. +6. Tests and checks run with results. +7. Remaining risks or blockers. Fail fast when an invariant could not be implemented. diff --git a/.agents/skills/migrate-application-operation/agents/openai.yaml b/.agents/skills/migrate-application-operation/agents/openai.yaml new file mode 100644 index 00000000000..625ce6954ca --- /dev/null +++ b/.agents/skills/migrate-application-operation/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Migrate Application Operation" + short_description: "Share one operation across API and tool surfaces" + default_prompt: "Use $migrate-application-operation to migrate one resource operation across internal APIs, public APIs, Copilot, and other tools." diff --git a/.agents/skills/ship/SKILL.md b/.agents/skills/ship/SKILL.md index 8f144a04aa6..946d235643a 100644 --- a/.agents/skills/ship/SKILL.md +++ b/.agents/skills/ship/SKILL.md @@ -66,11 +66,12 @@ When the user runs `/ship`: exit 1 } rm -f /tmp/ship-audit-results - for s in check:boundaries check:api-validation:strict check:desktop-bridge check:desktop-ipc \ + for s in check:boundaries check:api-validation:strict check:openapi \ + check:desktop-bridge check:desktop-ipc \ check:utils check:zustand-v5 \ check:react-query check:client-boundary check:bare-icons check:icon-paths \ check:realtime-prune check:tool-registry-boundary check:tool-request-boundary \ - tool-metadata:check \ + check:sql-date-binding tool-metadata:check \ integration-catalog:check skills:check agent-stream-docs:check; do ( bun run "$s" >"/tmp/ship-audit-${s//:/-}.log" 2>&1; echo "$? $s" >>/tmp/ship-audit-results ) & done diff --git a/.claude/commands/migrate-application-operation.md b/.claude/commands/migrate-application-operation.md new file mode 100644 index 00000000000..6411a56f3ad --- /dev/null +++ b/.claude/commands/migrate-application-operation.md @@ -0,0 +1,290 @@ +--- +description: Migrate one existing Sim resource operation into the shared Principal and application-use-case architecture across internal APIs, public or versioned APIs, Copilot, and other trusted tool adapters. Use when removing route- or tool-local authorization and business logic, consolidating resource reads or writes behind semantic operation policies, or adding another surface to an existing application operation while preserving contracts, identity, errors, rate limits, audit, analytics, and compatibility behavior. Treat v1, uploads, streams, large bodies, bulk recursion, and polymorphic tools as explicitly scoped special cases. +--- + +# Migrate Application Operation + +Migrate one bounded semantic operation at a time. Share authorization and business behavior without forcing internal APIs, public APIs, Copilot, and other tools to share authentication, input schemas, or response shapes. + +## Enforce the application boundary + +Apply this invariant: + +> Every real operation on persisted or protected data enters through an authorized application use case. + +This includes mutations, content and metadata reads, canonical resource lookup, and reference-to-resource resolution when the lookup is authorization-sensitive. + +Surface helpers may: + +- Normalize an already-authenticated surface context into a `Principal`. +- Translate aliases or wire arguments into application input. +- Select a code-defined operation and application use case. +- Call the application use case. +- Translate typed results and errors into the surface contract. + +Surface helpers must not: + +- Query databases or storage. +- Decide workspace or resource authorization. +- Implement business transactions. +- Record semantic audit or shared domain notifications. +- Infer authoritative identity, workspace, audience, or scope from untrusted arguments. +- Substitute billing attribution for identity. + +A helper that resolves a path is valid only when the actual protected lookup runs through an authorized application resolver. If a helper begins doing real data work, move that work into an application use case. + +## Read the foundation first + +Read these files completely before editing: + +- `packages/auth/src/principal.ts` +- `apps/sim/lib/core/application/operation.ts` +- `apps/sim/lib/core/application/workspace-operation.ts` +- `apps/sim/lib/core/application/workspace-authorization.ts` +- `apps/sim/lib/core/application/authorized-workspace-use-case.ts` +- `apps/sim/lib/api/server/routes/internal-json-route.ts` +- `apps/sim/lib/api/server/routes/v2-json-route.ts` + +Use the file domain only as a representative golden slice: + +- `apps/sim/lib/workspace-files/application/operations.ts` +- `apps/sim/lib/workspace-files/application/authorized-workspace-file-use-case.ts` +- `apps/sim/lib/workspace-files/application/rename-workspace-file.ts` +- `apps/sim/lib/copilot/application/execute-file-use-case.ts` +- `apps/sim/lib/copilot/auth/file-delegation.ts` + +Then read the target domain's operation registry, application code, repositories, contracts, adapters, aliases, resume paths, and focused tests. Fail immediately if the shared foundation is absent. Do not recreate it inside the domain. + +## Bound the migration + +Inventory every entry point for the behavior before editing: + +- Internal HTTP routes and contracts. +- Public or versioned API routes and contracts. +- Copilot tools, aliases, resume paths, and polymorphic branches. +- Other tool servers, workflow executors, jobs, or service callers. +- Current authentication, authorization, workspace assertions, and concealment. +- Manager or orchestration call chains. +- Audit, notification, analytics, and billing side effects. +- Error/status/result behavior. +- Rate-limit identity, rollout gates, quota, and concurrency admission. + +Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent operations merely because they share a module. Do not modify v1 unless the request explicitly includes it. + +Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one. + +## Keep the layers distinct + +Use these responsibilities: + +1. Authentication adapter: verify the surface credential or trusted execution context and construct a `Principal`. +2. Route or tool adapter: select rate policy, parse its contract, translate input, call the application use case, and render its own result. +3. Application use case: load canonical context, compare asserted scope, authorize the semantic operation, execute business behavior, project semantic audit, and trigger shared domain effects. +4. Manager or repository: perform database and storage reads or writes using canonical identifiers and scope. Never accept credentials or principals. +5. Presenter: return only the surface success body or typed binary descriptor. Never construct auth, rate, or error behavior. + +For ordinary public JSON routes, preserve this order: + +```text +IP abuse limit + -> authenticate + -> build Principal + -> operation rate limit + -> parse surface contract + -> application use case + -> canonical load + -> asserted-scope concealment + -> current authorization + -> manager read or mutation + -> semantic audit + -> shared domain effects + -> surface presenter +``` + +Internal routes may omit the IP bucket or operation limit only through an explicit policy with a reason. Usage billing, storage quota, cost admission, and concurrency are separate from request-rate limiting. + +Never query API keys or sessions from the application layer. Never add fallback identity or authorization behavior. Propagate infrastructure failures instead of turning them into not-found or forbidden results. + +## Define the semantic operation once + +Add one stable entry to the target domain's operation registry: + +```ts +rename: defineWorkspaceOperation({ + id: 'widgets.rename', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], +}) +``` + +Do not create internal-, public-, or Copilot-specific versions of the same semantic operation. If two callers have materially different business or transactional semantics, define separate use cases and explain the distinction. + +Choose principal kinds from actual behavior. Do not accept every principal merely because the use case is shared. Workspace API keys have a write ceiling and cannot satisfy admin operations. The operation definition must fail fast when its role, workspace-key policy, and principal kinds disagree. + +Route declarations, tool adapters, and use cases must use the same literal operation. Runtime operation selection is permitted only from a trusted, code-defined registry. Never accept an operation ID or permission tag from an HTTP body, model argument, or other untrusted input. + +## Implement the application use case + +Use `defineAuthorizedWorkspaceUseCase` directly or a thin domain binding that supplies domain-specific authorization options: + +```ts +export const renameWidget = defineAuthorizedWorkspaceUseCase({ + operation: widgetOperations.rename, + resolveContext: ({ input }: { input: RenameWidgetInput }) => + loadCanonicalWidgetContext(input.id, input.assertedWorkspaceId), + authorizationOptions: { delegation: widgetDelegationPolicy }, + execute: async ({ input, context }) => renameWidgetRecord({ + workspaceId: context.workspaceId, + widgetId: context.resourceId, + name: input.name, + }), + projectAudit: ({ result }) => ({ + action: AuditAction.WIDGET_UPDATED, + resourceType: AuditResourceType.WIDGET, + resourceId: result.id, + resourceName: result.name, + }), + afterSuccess: ({ context }) => notifyWidgetsChanged(context.workspaceId), +}) +``` + +Adapt the example to the domain's real authorization options; do not copy invented field names. + +The wrapper must own this lifecycle: + +1. Reject disallowed principal kinds before protected loading. +2. Load canonical context and conceal asserted-scope mismatches as required. +3. Authorize the operation using current policy state. +4. Execute the manager or repository primitive. +5. Project audit from authoritative results. +6. Await shared post-success effects. + +Do not call shared authorization, principal audit attribution, or `recordAudit` manually from an ordinary migrated use-case body. Use `projectAudit` only when the operation has semantic audit. Return no audit entries for authoritative no-ops. Keep product analytics such as `captureServerEvent` surface-specific through the adapter's success hook. + +Inspect legacy orchestration before reusing it. If it already authorizes, audits, notifies, or captures analytics, call a lower-level primitive or remove duplicate responsibility for migrated callers. + +## Adapt internal APIs + +Use `defineInternalJsonRoute` for ordinary JSON routes. Explicitly declare the contract, session authentication policy, semantic operation, rate policy, error policy, input mapping, use case, and presenter when the wire result differs. + +The internal adapter owns session authentication and internal response envelopes. It must not implement workspace authorization. Preserve internal-only analytics through `onSuccess` after application success. + +Keep the route module declarative. If several internal routes repeat authentication, parsing, error rendering, or response construction, improve the shared internal route builder instead of adding a domain-specific route wrapper. + +## Adapt public or versioned APIs + +Use the appropriate public/versioned route builder, such as `defineV2JsonRoute`, with API-key authentication, explicit operation rate policy, rollout policy, external error projection, and an external presenter. + +Authentication and HTTP formatting may differ from internal APIs; authorization and business behavior must not. Rate-limit using the credential or principal subject, never a billed owner. Resolve billing attribution only for billing, quota, or legacy required-user fields. + +Keep surface contracts separate when their wire shapes differ. Reuse shared primitive schemas and domain validators for invariants such as IDs, names, bounds, and formats. Do not maintain duplicate internal and external schemas merely because the routes are separate; import the same schema when the wire shape is genuinely identical. Never cast one surface response into another. + +Keep v1 middleware and routes unchanged unless explicitly included. + +## Adapt Copilot + +Create one domain-level Copilot application adapter instead of constructing delegated principals in every tool: + +```ts +executeCopilotWidgetUseCase(context, renameWidget, input, { resourceId }) +``` + +That adapter must: + +- Require a trusted server-authored Copilot execution marker. +- Require the authenticated subject, canonical workspace, tool-call or execution identity, and required audience or lifecycle scope. +- Construct the shared delegated `Principal` in one place. +- Optionally bind the canonical resource scope after trusted resolution. +- Verify that the use case exposes a registered code-defined operation. +- Call the application use case directly. + +Never construct authoritative delegation from model-provided workspace IDs, user IDs, operation IDs, resource scope, or permission tags. Model arguments are requested targets only and must be checked against trusted execution context and canonical data. + +Tool handlers own argument aliases, resumable legacy names, abort checks, and tool-specific presentation. They must not query managers directly for protected operations or manually authorize. + +A Copilot reference helper may translate a path to a resource only by calling an authorized application resolver under the intended semantic operation. Passing a code-defined operation object is acceptable; passing a model-provided operation string is not. Reauthorizing during later execution is safe but redundant. When resolution and execution form one business operation, need a consistent snapshot, or appear repeatedly together, prefer a top-level application use case such as `renameWidgetByReference`. + +Special composition roots may resolve one principal and deliberately thread it through several application calls or lower-level admission stages. Keep this exceptional and explicit; ordinary tools should use the shared execution adapter. + +Map expected typed errors to safe tool results. Unknown errors must become generic system/retryable messages while retaining full causes in server logs. Never return raw database or storage errors to the model. + +## Adapt other internal or external tools + +Treat every tool runtime as a surface adapter: + +- Normalize its already-authenticated execution context into an existing `Principal` through one shared adapter for that runtime or domain. +- Call the same application use case used by HTTP and Copilot surfaces. +- Preserve the tool protocol's input, output, retry, and cancellation semantics. +- Keep authoritative workspace and subject scope server-authored. + +An internal caller is not automatically trusted to bypass authorization. It must supply an explicit principal or use a deliberately designed service/delegation principal. If the current principal model cannot express its authority, stop and extend the identity model intentionally; do not fall back to an owner, uploader, creator, or arbitrary user ID. + +External tool endpoints authenticate at their adapter exactly like public APIs. Do not authenticate again inside the application use case. + +## Preserve identity and attribution + +- Session and personal-key principals authorize through current human workspace permission. +- Personal API keys also respect the workspace's personal-key policy. +- Workspace keys authorize as the workspace under explicit operation policy and the write ceiling, independent of creator membership. +- Delegated principals re-check the current subject and their workspace, audience, expiry, execution, and resource scope. +- Billing owners are attribution for billing or legacy required columns only, never authorization, rate identity, delegated identity, audit actor, or human analytics identity. +- Preserve structured `PrincipalActor` metadata in semantic audit. + +If a required legacy user column cannot represent the real actor, label the compatibility attribution explicitly. Never pretend it is the acting human. + +## Handle special operations explicitly + +Do not force these through an ordinary JSON migration: + +- Upload or multipart lifecycles: bind immutable credential identity, reauthorize control legs and finalization, and make durable completion idempotent. +- Large bodies: authenticate and perform cheap admission before bounded buffering. +- Binary or streaming responses: use binary/stream builders and typed descriptors. +- Bulk or recursive operations: deduplicate and cap inputs and expansion, load all resources canonically, and define atomic versus best-effort behavior. +- Polymorphic tools: select the semantic operation only after trusted target-kind resolution; do not route unrelated branches through one domain registry. +- Multi-resource transactions: keep canonical scope predicates and derive audit from authoritative affected rows. + +Stop and report a missing design rather than weakening identity, authorization, limits, or errors. + +## Test the complete matrix + +Add focused tests for every migrated surface and principal kind allowed by the operation: + +- Application: allowed and disallowed roles, principal-kind rejection before canonical loading, workspace assertion mismatch, delegated scope, not found, conflict, no-op, and infrastructure propagation. +- Repository: canonical active lookup, workspace-predicated writes, archived resources, authoritative affected rows, and database error propagation. +- Internal API: authentication before parsing, exact contract, typed errors, and surface analytics only after success. +- Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers. +- Copilot or tools: trusted context, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes. +- Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op. + +Run at minimum: + +```bash +bunx vitest run +bunx biome check +bunx turbo run type-check --filter=sim --filter=@sim/auth +bun run check:api-validation:strict +git diff --check +``` + +Do not claim a check passed unless it completed successfully. + +## Work safely in parallel + +- Assign non-overlapping route modules and caller sets. Two methods in one route file are one ownership unit. +- Treat operation registries, contract families, route policies, and shared surface adapters as merge hotspots. +- Keep shared core foundations owned by one task; ordinary domain migrations should consume them without modifying them. +- Preserve unrelated working-tree changes. Never stage proposal docs, lockfile drift, or another agent's edits. +- Do not commit, push, or open a PR unless requested. + +## Hand off + +Report: + +1. Semantic operation, role, workspace-key policy, and principal kinds. +2. Migrated, deferred, and non-goal entry points. +3. Behavior preserved per internal, public, Copilot, and other tool surface. +4. Identity construction and authoritative scope source for each surface. +5. Files changed and shared merge hotspots. +6. Tests and checks run with results. +7. Remaining risks or blockers. Fail fast when an invariant could not be implemented. diff --git a/.claude/commands/ship.md b/.claude/commands/ship.md index 6b673b18f8e..c4bb336288b 100644 --- a/.claude/commands/ship.md +++ b/.claude/commands/ship.md @@ -65,11 +65,12 @@ When the user runs `/ship`: exit 1 } rm -f /tmp/ship-audit-results - for s in check:boundaries check:api-validation:strict check:desktop-bridge check:desktop-ipc \ + for s in check:boundaries check:api-validation:strict check:openapi \ + check:desktop-bridge check:desktop-ipc \ check:utils check:zustand-v5 \ check:react-query check:client-boundary check:bare-icons check:icon-paths \ check:realtime-prune check:tool-registry-boundary check:tool-request-boundary \ - tool-metadata:check \ + check:sql-date-binding tool-metadata:check \ integration-catalog:check skills:check agent-stream-docs:check; do ( bun run "$s" >"/tmp/ship-audit-${s//:/-}.log" 2>&1; echo "$? $s" >>/tmp/ship-audit-results ) & done diff --git a/.cursor/commands/migrate-application-operation.md b/.cursor/commands/migrate-application-operation.md new file mode 100644 index 00000000000..f8c67ba42cd --- /dev/null +++ b/.cursor/commands/migrate-application-operation.md @@ -0,0 +1,286 @@ +# Migrate Application Operation + +Migrate one bounded semantic operation at a time. Share authorization and business behavior without forcing internal APIs, public APIs, Copilot, and other tools to share authentication, input schemas, or response shapes. + +## Enforce the application boundary + +Apply this invariant: + +> Every real operation on persisted or protected data enters through an authorized application use case. + +This includes mutations, content and metadata reads, canonical resource lookup, and reference-to-resource resolution when the lookup is authorization-sensitive. + +Surface helpers may: + +- Normalize an already-authenticated surface context into a `Principal`. +- Translate aliases or wire arguments into application input. +- Select a code-defined operation and application use case. +- Call the application use case. +- Translate typed results and errors into the surface contract. + +Surface helpers must not: + +- Query databases or storage. +- Decide workspace or resource authorization. +- Implement business transactions. +- Record semantic audit or shared domain notifications. +- Infer authoritative identity, workspace, audience, or scope from untrusted arguments. +- Substitute billing attribution for identity. + +A helper that resolves a path is valid only when the actual protected lookup runs through an authorized application resolver. If a helper begins doing real data work, move that work into an application use case. + +## Read the foundation first + +Read these files completely before editing: + +- `packages/auth/src/principal.ts` +- `apps/sim/lib/core/application/operation.ts` +- `apps/sim/lib/core/application/workspace-operation.ts` +- `apps/sim/lib/core/application/workspace-authorization.ts` +- `apps/sim/lib/core/application/authorized-workspace-use-case.ts` +- `apps/sim/lib/api/server/routes/internal-json-route.ts` +- `apps/sim/lib/api/server/routes/v2-json-route.ts` + +Use the file domain only as a representative golden slice: + +- `apps/sim/lib/workspace-files/application/operations.ts` +- `apps/sim/lib/workspace-files/application/authorized-workspace-file-use-case.ts` +- `apps/sim/lib/workspace-files/application/rename-workspace-file.ts` +- `apps/sim/lib/copilot/application/execute-file-use-case.ts` +- `apps/sim/lib/copilot/auth/file-delegation.ts` + +Then read the target domain's operation registry, application code, repositories, contracts, adapters, aliases, resume paths, and focused tests. Fail immediately if the shared foundation is absent. Do not recreate it inside the domain. + +## Bound the migration + +Inventory every entry point for the behavior before editing: + +- Internal HTTP routes and contracts. +- Public or versioned API routes and contracts. +- Copilot tools, aliases, resume paths, and polymorphic branches. +- Other tool servers, workflow executors, jobs, or service callers. +- Current authentication, authorization, workspace assertions, and concealment. +- Manager or orchestration call chains. +- Audit, notification, analytics, and billing side effects. +- Error/status/result behavior. +- Rate-limit identity, rollout gates, quota, and concurrency admission. + +Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent operations merely because they share a module. Do not modify v1 unless the request explicitly includes it. + +Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one. + +## Keep the layers distinct + +Use these responsibilities: + +1. Authentication adapter: verify the surface credential or trusted execution context and construct a `Principal`. +2. Route or tool adapter: select rate policy, parse its contract, translate input, call the application use case, and render its own result. +3. Application use case: load canonical context, compare asserted scope, authorize the semantic operation, execute business behavior, project semantic audit, and trigger shared domain effects. +4. Manager or repository: perform database and storage reads or writes using canonical identifiers and scope. Never accept credentials or principals. +5. Presenter: return only the surface success body or typed binary descriptor. Never construct auth, rate, or error behavior. + +For ordinary public JSON routes, preserve this order: + +```text +IP abuse limit + -> authenticate + -> build Principal + -> operation rate limit + -> parse surface contract + -> application use case + -> canonical load + -> asserted-scope concealment + -> current authorization + -> manager read or mutation + -> semantic audit + -> shared domain effects + -> surface presenter +``` + +Internal routes may omit the IP bucket or operation limit only through an explicit policy with a reason. Usage billing, storage quota, cost admission, and concurrency are separate from request-rate limiting. + +Never query API keys or sessions from the application layer. Never add fallback identity or authorization behavior. Propagate infrastructure failures instead of turning them into not-found or forbidden results. + +## Define the semantic operation once + +Add one stable entry to the target domain's operation registry: + +```ts +rename: defineWorkspaceOperation({ + id: 'widgets.rename', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], +}) +``` + +Do not create internal-, public-, or Copilot-specific versions of the same semantic operation. If two callers have materially different business or transactional semantics, define separate use cases and explain the distinction. + +Choose principal kinds from actual behavior. Do not accept every principal merely because the use case is shared. Workspace API keys have a write ceiling and cannot satisfy admin operations. The operation definition must fail fast when its role, workspace-key policy, and principal kinds disagree. + +Route declarations, tool adapters, and use cases must use the same literal operation. Runtime operation selection is permitted only from a trusted, code-defined registry. Never accept an operation ID or permission tag from an HTTP body, model argument, or other untrusted input. + +## Implement the application use case + +Use `defineAuthorizedWorkspaceUseCase` directly or a thin domain binding that supplies domain-specific authorization options: + +```ts +export const renameWidget = defineAuthorizedWorkspaceUseCase({ + operation: widgetOperations.rename, + resolveContext: ({ input }: { input: RenameWidgetInput }) => + loadCanonicalWidgetContext(input.id, input.assertedWorkspaceId), + authorizationOptions: { delegation: widgetDelegationPolicy }, + execute: async ({ input, context }) => renameWidgetRecord({ + workspaceId: context.workspaceId, + widgetId: context.resourceId, + name: input.name, + }), + projectAudit: ({ result }) => ({ + action: AuditAction.WIDGET_UPDATED, + resourceType: AuditResourceType.WIDGET, + resourceId: result.id, + resourceName: result.name, + }), + afterSuccess: ({ context }) => notifyWidgetsChanged(context.workspaceId), +}) +``` + +Adapt the example to the domain's real authorization options; do not copy invented field names. + +The wrapper must own this lifecycle: + +1. Reject disallowed principal kinds before protected loading. +2. Load canonical context and conceal asserted-scope mismatches as required. +3. Authorize the operation using current policy state. +4. Execute the manager or repository primitive. +5. Project audit from authoritative results. +6. Await shared post-success effects. + +Do not call shared authorization, principal audit attribution, or `recordAudit` manually from an ordinary migrated use-case body. Use `projectAudit` only when the operation has semantic audit. Return no audit entries for authoritative no-ops. Keep product analytics such as `captureServerEvent` surface-specific through the adapter's success hook. + +Inspect legacy orchestration before reusing it. If it already authorizes, audits, notifies, or captures analytics, call a lower-level primitive or remove duplicate responsibility for migrated callers. + +## Adapt internal APIs + +Use `defineInternalJsonRoute` for ordinary JSON routes. Explicitly declare the contract, session authentication policy, semantic operation, rate policy, error policy, input mapping, use case, and presenter when the wire result differs. + +The internal adapter owns session authentication and internal response envelopes. It must not implement workspace authorization. Preserve internal-only analytics through `onSuccess` after application success. + +Keep the route module declarative. If several internal routes repeat authentication, parsing, error rendering, or response construction, improve the shared internal route builder instead of adding a domain-specific route wrapper. + +## Adapt public or versioned APIs + +Use the appropriate public/versioned route builder, such as `defineV2JsonRoute`, with API-key authentication, explicit operation rate policy, rollout policy, external error projection, and an external presenter. + +Authentication and HTTP formatting may differ from internal APIs; authorization and business behavior must not. Rate-limit using the credential or principal subject, never a billed owner. Resolve billing attribution only for billing, quota, or legacy required-user fields. + +Keep surface contracts separate when their wire shapes differ. Reuse shared primitive schemas and domain validators for invariants such as IDs, names, bounds, and formats. Do not maintain duplicate internal and external schemas merely because the routes are separate; import the same schema when the wire shape is genuinely identical. Never cast one surface response into another. + +Keep v1 middleware and routes unchanged unless explicitly included. + +## Adapt Copilot + +Create one domain-level Copilot application adapter instead of constructing delegated principals in every tool: + +```ts +executeCopilotWidgetUseCase(context, renameWidget, input, { resourceId }) +``` + +That adapter must: + +- Require a trusted server-authored Copilot execution marker. +- Require the authenticated subject, canonical workspace, tool-call or execution identity, and required audience or lifecycle scope. +- Construct the shared delegated `Principal` in one place. +- Optionally bind the canonical resource scope after trusted resolution. +- Verify that the use case exposes a registered code-defined operation. +- Call the application use case directly. + +Never construct authoritative delegation from model-provided workspace IDs, user IDs, operation IDs, resource scope, or permission tags. Model arguments are requested targets only and must be checked against trusted execution context and canonical data. + +Tool handlers own argument aliases, resumable legacy names, abort checks, and tool-specific presentation. They must not query managers directly for protected operations or manually authorize. + +A Copilot reference helper may translate a path to a resource only by calling an authorized application resolver under the intended semantic operation. Passing a code-defined operation object is acceptable; passing a model-provided operation string is not. Reauthorizing during later execution is safe but redundant. When resolution and execution form one business operation, need a consistent snapshot, or appear repeatedly together, prefer a top-level application use case such as `renameWidgetByReference`. + +Special composition roots may resolve one principal and deliberately thread it through several application calls or lower-level admission stages. Keep this exceptional and explicit; ordinary tools should use the shared execution adapter. + +Map expected typed errors to safe tool results. Unknown errors must become generic system/retryable messages while retaining full causes in server logs. Never return raw database or storage errors to the model. + +## Adapt other internal or external tools + +Treat every tool runtime as a surface adapter: + +- Normalize its already-authenticated execution context into an existing `Principal` through one shared adapter for that runtime or domain. +- Call the same application use case used by HTTP and Copilot surfaces. +- Preserve the tool protocol's input, output, retry, and cancellation semantics. +- Keep authoritative workspace and subject scope server-authored. + +An internal caller is not automatically trusted to bypass authorization. It must supply an explicit principal or use a deliberately designed service/delegation principal. If the current principal model cannot express its authority, stop and extend the identity model intentionally; do not fall back to an owner, uploader, creator, or arbitrary user ID. + +External tool endpoints authenticate at their adapter exactly like public APIs. Do not authenticate again inside the application use case. + +## Preserve identity and attribution + +- Session and personal-key principals authorize through current human workspace permission. +- Personal API keys also respect the workspace's personal-key policy. +- Workspace keys authorize as the workspace under explicit operation policy and the write ceiling, independent of creator membership. +- Delegated principals re-check the current subject and their workspace, audience, expiry, execution, and resource scope. +- Billing owners are attribution for billing or legacy required columns only, never authorization, rate identity, delegated identity, audit actor, or human analytics identity. +- Preserve structured `PrincipalActor` metadata in semantic audit. + +If a required legacy user column cannot represent the real actor, label the compatibility attribution explicitly. Never pretend it is the acting human. + +## Handle special operations explicitly + +Do not force these through an ordinary JSON migration: + +- Upload or multipart lifecycles: bind immutable credential identity, reauthorize control legs and finalization, and make durable completion idempotent. +- Large bodies: authenticate and perform cheap admission before bounded buffering. +- Binary or streaming responses: use binary/stream builders and typed descriptors. +- Bulk or recursive operations: deduplicate and cap inputs and expansion, load all resources canonically, and define atomic versus best-effort behavior. +- Polymorphic tools: select the semantic operation only after trusted target-kind resolution; do not route unrelated branches through one domain registry. +- Multi-resource transactions: keep canonical scope predicates and derive audit from authoritative affected rows. + +Stop and report a missing design rather than weakening identity, authorization, limits, or errors. + +## Test the complete matrix + +Add focused tests for every migrated surface and principal kind allowed by the operation: + +- Application: allowed and disallowed roles, principal-kind rejection before canonical loading, workspace assertion mismatch, delegated scope, not found, conflict, no-op, and infrastructure propagation. +- Repository: canonical active lookup, workspace-predicated writes, archived resources, authoritative affected rows, and database error propagation. +- Internal API: authentication before parsing, exact contract, typed errors, and surface analytics only after success. +- Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers. +- Copilot or tools: trusted context, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes. +- Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op. + +Run at minimum: + +```bash +bunx vitest run +bunx biome check +bunx turbo run type-check --filter=sim --filter=@sim/auth +bun run check:api-validation:strict +git diff --check +``` + +Do not claim a check passed unless it completed successfully. + +## Work safely in parallel + +- Assign non-overlapping route modules and caller sets. Two methods in one route file are one ownership unit. +- Treat operation registries, contract families, route policies, and shared surface adapters as merge hotspots. +- Keep shared core foundations owned by one task; ordinary domain migrations should consume them without modifying them. +- Preserve unrelated working-tree changes. Never stage proposal docs, lockfile drift, or another agent's edits. +- Do not commit, push, or open a PR unless requested. + +## Hand off + +Report: + +1. Semantic operation, role, workspace-key policy, and principal kinds. +2. Migrated, deferred, and non-goal entry points. +3. Behavior preserved per internal, public, Copilot, and other tool surface. +4. Identity construction and authoritative scope source for each surface. +5. Files changed and shared merge hotspots. +6. Tests and checks run with results. +7. Remaining risks or blockers. Fail fast when an invariant could not be implemented. diff --git a/.cursor/commands/ship.md b/.cursor/commands/ship.md index 77ec67d04a8..c5d92d97ae4 100644 --- a/.cursor/commands/ship.md +++ b/.cursor/commands/ship.md @@ -60,11 +60,12 @@ When the user runs `/ship`: exit 1 } rm -f /tmp/ship-audit-results - for s in check:boundaries check:api-validation:strict check:desktop-bridge check:desktop-ipc \ + for s in check:boundaries check:api-validation:strict check:openapi \ + check:desktop-bridge check:desktop-ipc \ check:utils check:zustand-v5 \ check:react-query check:client-boundary check:bare-icons check:icon-paths \ check:realtime-prune check:tool-registry-boundary check:tool-request-boundary \ - tool-metadata:check \ + check:sql-date-binding tool-metadata:check \ integration-catalog:check skills:check agent-stream-docs:check; do ( bun run "$s" >"/tmp/ship-audit-${s//:/-}.log" 2>&1; echo "$? $s" >>/tmp/ship-audit-results ) & done diff --git a/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts index 6ed8d6d1e35..38cd89b45c8 100644 --- a/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts @@ -2,9 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { completeInternalFileUploadContract } from '@/lib/api/contracts/upload-sessions' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { completeUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' -import { finalizeUploadPurpose } from '@/app/api/files/uploads/finalizers' -import { reauthorizeUploadPurpose } from '@/app/api/files/uploads/purposes' +import { completeInternalUploadSession } from '@/lib/uploads/upload-session/application' import { requireUploadUser, toInternalUploadSession, @@ -22,16 +20,15 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Uploa if (!parsed.success) return parsed.response try { - const session = await getOwnedUploadSession({ - uploadId: parsed.data.params.uploadId, - uploadToken: parsed.data.headers['upload-token'], - userId: actor.id, - }) - await reauthorizeUploadPurpose(actor.id, session) - const completed = await completeUploadSession({ - session, - finalize: (claimed) => finalizeUploadPurpose({ session: claimed, actor, request }), - }) + const completed = await completeInternalUploadSession( + actor.principal, + { + uploadId: parsed.data.params.uploadId, + uploadToken: parsed.data.headers['upload-token'], + actor, + }, + request + ) return NextResponse.json({ data: toInternalUploadSession(completed.session, completed.value), }) diff --git a/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts index b707f196567..29491528450 100644 --- a/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts @@ -2,8 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { createInternalFileUploadPartUrlsContract } from '@/lib/api/contracts/upload-sessions' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createUploadPartUrls, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' -import { reauthorizeUploadPurpose } from '@/app/api/files/uploads/purposes' +import { issueInternalUploadPartUrls } from '@/lib/uploads/upload-session/application' import { requireUploadUser, uploadSessionErrorResponse } from '@/app/api/files/uploads/utils' interface UploadRouteParams { @@ -17,18 +16,16 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Uploa if (!parsed.success) return parsed.response try { - const session = await getOwnedUploadSession({ - uploadId: parsed.data.params.uploadId, - uploadToken: parsed.data.headers['upload-token'], - userId: actor.id, - }) - await reauthorizeUploadPurpose(actor.id, session) - const parts = await createUploadPartUrls({ - session, - partNumbers: parsed.data.body.partNumbers, - localOrigin: request.nextUrl.origin, - }) - return NextResponse.json({ data: { parts } }) + const parts = await issueInternalUploadPartUrls( + actor.principal, + { + uploadId: parsed.data.params.uploadId, + uploadToken: parsed.data.headers['upload-token'], + partNumbers: parsed.data.body.partNumbers, + }, + request + ) + return NextResponse.json({ data: parts }) } catch (error) { const classified = uploadSessionErrorResponse(error) if (classified) return classified diff --git a/apps/sim/app/api/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/route.ts index 8887bfbb593..162620470fe 100644 --- a/apps/sim/app/api/files/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/files/uploads/[uploadId]/route.ts @@ -2,8 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { abortInternalFileUploadContract } from '@/lib/api/contracts/upload-sessions' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { abortUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' -import { reauthorizeUploadPurpose } from '@/app/api/files/uploads/purposes' +import { abortInternalUploadSession } from '@/lib/uploads/upload-session/application' import { requireUploadUser, toInternalUploadSession, @@ -21,13 +20,10 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Upl if (!parsed.success) return parsed.response try { - const session = await getOwnedUploadSession({ + const aborted = await abortInternalUploadSession(actor.principal, { uploadId: parsed.data.params.uploadId, uploadToken: parsed.data.headers['upload-token'], - userId: actor.id, }) - await reauthorizeUploadPurpose(actor.id, session) - const aborted = await abortUploadSession(session) return NextResponse.json({ data: toInternalUploadSession(aborted, null) }) } catch (error) { const classified = uploadSessionErrorResponse(error) diff --git a/apps/sim/app/api/files/uploads/finalizers.test.ts b/apps/sim/app/api/files/uploads/finalizers.test.ts index a5ed437fe66..37434dbac84 100644 --- a/apps/sim/app/api/files/uploads/finalizers.test.ts +++ b/apps/sim/app/api/files/uploads/finalizers.test.ts @@ -73,6 +73,7 @@ import { finalizeUploadPurpose } from '@/app/api/files/uploads/finalizers' const now = new Date('2026-08-04T12:00:00.000Z') const actor = { id: 'user-1', name: 'Ada', email: 'ada@example.com' } +const principal = { kind: 'session' as const, userId: actor.id, sessionId: 'session-1' } const metadataRow = { id: 'file-1', key: 'workspace-logos/upload-1-logo.png', @@ -147,8 +148,8 @@ describe('upload purpose finalizers', () => { mockInsertReturning.mockResolvedValueOnce([metadataRow]) const request = new NextRequest('http://localhost/api/files/uploads/upload-1/complete') - const first = await finalizeUploadPurpose({ session: uploadSession, actor, request }) - const retry = await finalizeUploadPurpose({ session: uploadSession, actor, request }) + const first = await finalizeUploadPurpose({ session: uploadSession, actor, principal, request }) + const retry = await finalizeUploadPurpose({ session: uploadSession, actor, principal, request }) expect(first.value).toEqual({ path: `/api/files/serve/s3/${encodeURIComponent(metadataRow.key)}?context=workspace-logos`, @@ -169,6 +170,7 @@ describe('upload purpose finalizers', () => { finalizeUploadPurpose({ session: uploadSession, actor, + principal, request: new NextRequest('http://localhost/api/files/uploads/upload-1/complete'), }) ).rejects.toMatchObject({ code: 'conflict' }) @@ -185,6 +187,7 @@ describe('upload purpose finalizers', () => { finalizeUploadPurpose({ session: uploadSession, actor, + principal, request: new NextRequest('http://localhost/api/files/uploads/upload-1/complete'), }) ).rejects.toMatchObject({ code: 'conflict' }) @@ -208,10 +211,23 @@ describe('upload purpose finalizers', () => { mockGetWorkspaceFile.mockResolvedValue(workspaceFile) const request = new NextRequest('http://localhost/api/files/uploads/upload-1/complete') - const first = await finalizeUploadPurpose({ session: workspaceSession, actor, request }) - const retry = await finalizeUploadPurpose({ session: workspaceSession, actor, request }) + const first = await finalizeUploadPurpose({ + session: workspaceSession, + actor, + principal, + request, + }) + const retry = await finalizeUploadPurpose({ + session: workspaceSession, + actor, + principal, + request, + }) expect(retry.value).toEqual(first.value) + expect(mockRegisterUploadedWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ uploadSessionId: workspaceSession.id }) + ) expect(mockNotifyWorkspaceFilesChanged).toHaveBeenCalledTimes(1) expect(mockRecordAudit).toHaveBeenCalledTimes(1) expect(mockCaptureServerEvent).toHaveBeenCalledTimes(1) @@ -236,6 +252,7 @@ describe('upload purpose finalizers', () => { finalizeUploadPurpose({ session: workspaceSession, actor, + principal, request: new NextRequest('http://localhost/api/files/uploads/upload-1/complete'), }) ).rejects.toMatchObject({ code: 'conflict' }) @@ -243,4 +260,38 @@ describe('upload purpose finalizers', () => { expect(mockRecordAudit).not.toHaveBeenCalled() expect(mockCaptureServerEvent).not.toHaveBeenCalled() }) + + it('uses the current billing owner only for workspace-key legacy attribution', async () => { + const workspaceSession = { + ...uploadSession, + purpose: 'workspace_file' as const, + storageContext: 'workspace' as const, + storageKey: workspaceFile.key, + finalKey: workspaceFile.key, + fileName: workspaceFile.name, + contentType: workspaceFile.type, + } + mockRegisterUploadedWorkspaceFile.mockResolvedValueOnce({ + file: { id: workspaceFile.id }, + created: true, + }) + mockGetWorkspaceFile.mockResolvedValue(workspaceFile) + const request = new NextRequest('http://localhost/api/files/uploads/upload-1/complete') + + await finalizeUploadPurpose({ + session: workspaceSession, + actor: { id: 'current-owner' }, + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-1', + }, + request, + }) + + expect(mockRegisterUploadedWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'current-owner' }) + ) + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/files/uploads/finalizers.ts b/apps/sim/app/api/files/uploads/finalizers.ts index 3c998f1be30..a59eec3beb0 100644 --- a/apps/sim/app/api/files/uploads/finalizers.ts +++ b/apps/sim/app/api/files/uploads/finalizers.ts @@ -1,10 +1,11 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { type Principal, resolvePrincipalAuditAttribution } from '@sim/auth/principal' import { db } from '@sim/db' import { workspaceFiles } from '@sim/db/schema' import { generateId } from '@sim/utils/id' import { eq, sql } from 'drizzle-orm' -import type { NextRequest } from 'next/server' import type { V2File } from '@/lib/api/contracts/v2/files' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' import { captureServerEvent } from '@/lib/posthog/server' import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { getServeStoragePrefix } from '@/lib/uploads/config' @@ -51,7 +52,9 @@ interface FinalizedWorkspaceFile { interface FinalizeUploadPurposeParams { session: UploadSessionRecord actor: UploadActor - request: NextRequest + request: OrchestrationRequestContext + principal: Principal + authorizeBeforeRegistration?: () => Promise } interface FinalizedUploadPurpose { @@ -79,10 +82,18 @@ export async function finalizeUploadPurpose({ session, actor, request, + principal, + authorizeBeforeRegistration, }: FinalizeUploadPurposeParams): Promise { switch (session.purpose) { case 'workspace_file': - return finalizeInternalWorkspaceFile(session, actor, request) + return finalizeInternalWorkspaceFile( + session, + actor, + request, + principal, + authorizeBeforeRegistration + ) case 'profile_picture': return { value: storedAssetResult(session, 'profile-pictures') } case 'workspace_logo': @@ -100,12 +111,30 @@ export async function finalizeUploadPurpose({ } } +export async function loadCompletedUploadPurpose( + session: UploadSessionRecord +): Promise { + if (session.purpose !== 'workspace_file') { + throw new Error(`Upload purpose ${session.purpose} has no durable file result`) + } + return toV2File(await loadCompletedWorkspaceFileUpload(session)) +} + async function finalizeInternalWorkspaceFile( session: UploadSessionRecord, actor: UploadActor, - request: NextRequest + request: OrchestrationRequestContext, + principal: Principal, + authorizeBeforeRegistration?: () => Promise ): Promise { - const finalized = await finalizeWorkspaceFileUpload({ session, actor, request, source: 'ui' }) + const finalized = await finalizeWorkspaceFileUpload({ + session, + actor, + request, + source: 'ui', + principal, + authorizeBeforeRegistration, + }) return { value: await toV2File(finalized.file), completedFileId: finalized.file.id, @@ -119,15 +148,24 @@ async function finalizeInternalWorkspaceFile( export async function finalizeWorkspaceFileUpload(params: { session: UploadSessionRecord actor: UploadActor - request: NextRequest + request: OrchestrationRequestContext source: 'api' | 'ui' + principal: Principal + authorizeBeforeRegistration?: () => Promise }): Promise { - const { session, actor, request, source } = params + const { session, actor, request, source, principal, authorizeBeforeRegistration } = params const workspaceId = requireWorkspaceId(session) const metadata = session.metadata as { folderId?: string | null } + if (session.completedFileId) { + await authorizeBeforeRegistration?.() + return { file: await loadCompletedWorkspaceFileUpload(session), created: false } + } + await authorizeBeforeRegistration?.() + const legacyAttributionUserId = principal.kind === 'workspace_api_key' ? actor.id : session.userId const registered = await registerUploadedWorkspaceFile({ workspaceId, - userId: session.userId, + userId: legacyAttributionUserId, + uploadSessionId: session.id, key: session.storageKey, originalName: session.fileName, contentType: session.contentType, @@ -145,33 +183,56 @@ export async function finalizeWorkspaceFileUpload(params: { } if (registered.created) { await notifyWorkspaceFilesChanged(workspaceId) - captureServerEvent( - actor.id, - 'file_uploaded', - { workspace_id: workspaceId, file_type: session.contentType }, - { groups: { workspace: workspaceId } } - ) + if (principal.kind !== 'workspace_api_key') { + captureServerEvent( + actor.id, + 'file_uploaded', + { workspace_id: workspaceId, file_type: session.contentType }, + { groups: { workspace: workspaceId } } + ) + } + const auditAttribution = resolvePrincipalAuditAttribution(principal) recordAudit({ workspaceId, - actorId: actor.id, - actorName: actor.name, + actorId: auditAttribution.actorId, + actorName: auditAttribution.actorName ?? actor.name, actorEmail: actor.email, action: AuditAction.FILE_UPLOADED, resourceType: AuditResourceType.FILE, resourceId: file.id, resourceName: file.name, description: `Uploaded file "${file.name}"${source === 'api' ? ' via API' : ''}`, - metadata: { fileSize: file.size, fileType: file.type }, + metadata: { + fileSize: file.size, + fileType: file.type, + actor: auditAttribution.actor, + }, request, }) } return { file, created: registered.created } } +export async function loadCompletedWorkspaceFileUpload( + session: UploadSessionRecord +): Promise { + const workspaceId = requireWorkspaceId(session) + if (!session.completedFileId) { + throw new Error('Workspace upload session has no completed file marker') + } + const durable = await getWorkspaceFile(workspaceId, session.completedFileId, { + includeDeleted: true, + throwOnError: true, + }) + if (!durable) throw new UploadSessionError('conflict', 'Completed workspace file not found') + if (durable.deletedAt) throw new UploadSessionError('conflict', 'Upload result was deleted') + return durable +} + async function finalizeWorkspaceLogo( session: UploadSessionRecord, actor: UploadActor, - request: NextRequest + request: OrchestrationRequestContext ): Promise { const workspaceId = requireWorkspaceId(session) const finalized = await insertOrLoadFileMetadata({ diff --git a/apps/sim/app/api/files/uploads/purposes.ts b/apps/sim/app/api/files/uploads/purposes.ts index a67d2e3298c..9c8810563a4 100644 --- a/apps/sim/app/api/files/uploads/purposes.ts +++ b/apps/sim/app/api/files/uploads/purposes.ts @@ -1,13 +1,21 @@ +import type { Principal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { workspace } from '@sim/db/schema' import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' +import { and, eq, isNull } from 'drizzle-orm' import type { CreateInternalFileUploadBody } from '@/lib/api/contracts/upload-sessions' +import type { WorkspaceOperation } from '@/lib/core/application' import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace' import { + assertUploadSessionAuthBinding, createUploadSession, UploadSessionError, type UploadSessionRecord, } from '@/lib/uploads/upload-session/service' import { isImageFileType } from '@/lib/uploads/utils/file-utils' import { validateAttachmentFileType } from '@/lib/uploads/utils/validation' +import { authorizeWorkspaceFileAccess } from '@/lib/workspace-files/application/authorization' +import { fileOperations } from '@/lib/workspace-files/application/operations' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' export type InternalUploadPurpose = CreateInternalFileUploadBody['purpose'] @@ -21,15 +29,21 @@ const INTERNAL_UPLOAD_PURPOSES = new Set([ ]) export async function createPurposeUploadSession( - userId: string, + principal: Principal, body: CreateInternalFileUploadBody, localOrigin: string ) { + const userId = await principalUserId( + principal, + 'workspaceId' in body ? body.workspaceId : undefined + ) validatePurposeFile(body) switch (body.purpose) { case 'workspace_file': { - await requireWorkspacePermission(userId, body.workspaceId, 'write') + const context = await loadWorkspaceAuthorizationContext(body.workspaceId) + if (!context) throw new UploadSessionError('not_found', 'Workspace not found') + await authorizeWorkspaceFileAccess(principal, fileOperations.uploadCreate, context) const folderId = await assertWorkspaceFileFolderTarget(body.workspaceId, body.folderId) return createUploadSession({ purpose: body.purpose, @@ -39,6 +53,7 @@ export async function createPurposeUploadSession( contentType: body.contentType, fileSize: body.size, metadata: { folderId }, + principal, localOrigin, }) } @@ -120,10 +135,41 @@ export async function reauthorizeUploadPurpose( } } +/** + * Re-authorizes a workspace-file control leg against the current principal and + * current workspace policy. Session metadata is only accepted after the + * immutable, server-authored credential binding matches. + */ +export async function reauthorizeWorkspaceUploadPurpose( + principal: Principal, + session: UploadSessionRecord, + operation: WorkspaceOperation = fileOperations.uploadComplete +): Promise { + if (session.purpose !== 'workspace_file' || !session.workspaceId) { + throw new UploadSessionError('not_found', 'Upload session not found') + } + assertUploadSessionAuthBinding(session, principal) + const context = await loadWorkspaceAuthorizationContext(session.workspaceId) + if (!context) throw new UploadSessionError('not_found', 'Upload session not found') + await authorizeWorkspaceFileAccess(principal, operation, { + workspaceId: context.workspaceId, + workspaceOrganizationId: context.workspaceOrganizationId, + allowPersonalApiKeys: context.allowPersonalApiKeys, + }) +} + export function isInternalUploadPurpose(purpose: string): purpose is InternalUploadPurpose { return INTERNAL_UPLOAD_PURPOSES.has(purpose as InternalUploadPurpose) } +/** Resolves the current billing owner only for legacy upload attribution fields. */ +export async function resolveUploadAttributionUserId( + principal: Principal, + workspaceId: string +): Promise { + return principalUserId(principal, workspaceId) +} + function validatePurposeFile(body: CreateInternalFileUploadBody): void { if (body.purpose === 'profile_picture' || body.purpose === 'workspace_logo') { if (!isImageFileType(body.contentType)) { @@ -186,3 +232,43 @@ function requireSessionScope(value: string | null, label = 'scope'): string { } return value } + +async function principalUserId(principal: Principal, workspaceId?: string): Promise { + switch (principal.kind) { + case 'session': + case 'personal_api_key': + return principal.userId + case 'workspace_api_key': + if (!workspaceId || principal.workspaceId !== workspaceId) { + throw new UploadSessionError('forbidden', 'Workspace API key cannot access this workspace') + } + { + const context = await loadWorkspaceAuthorizationContext(workspaceId) + if (!context?.billedAccountUserId) { + throw new Error('Workspace upload attribution requires a billing owner') + } + return context.billedAccountUserId + } + case 'delegated': + throw new UploadSessionError('forbidden', 'Delegated principals cannot create uploads') + } +} + +async function loadWorkspaceAuthorizationContext(workspaceId: string): Promise<{ + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string +} | null> { + const [row] = await db + .select({ + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + billedAccountUserId: workspace.billedAccountUserId, + }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + return row ?? null +} diff --git a/apps/sim/app/api/files/uploads/route.test.ts b/apps/sim/app/api/files/uploads/route.test.ts index 4ea8c61428a..f36e29862d1 100644 --- a/apps/sim/app/api/files/uploads/route.test.ts +++ b/apps/sim/app/api/files/uploads/route.test.ts @@ -7,6 +7,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetSession, mockCreateUploadSession, + mockCreateInternalPurposeUploadSession, + mockCompleteInternalUploadSession, mockGetOwnedUploadSession, mockCompleteUploadSession, mockGetUserEntityPermissions, @@ -14,6 +16,8 @@ const { } = vi.hoisted(() => ({ mockGetSession: vi.fn(), mockCreateUploadSession: vi.fn(), + mockCreateInternalPurposeUploadSession: vi.fn(), + mockCompleteInternalUploadSession: vi.fn(), mockGetOwnedUploadSession: vi.fn(), mockCompleteUploadSession: vi.fn(), mockGetUserEntityPermissions: vi.fn(), @@ -38,6 +42,13 @@ vi.mock('@/lib/uploads/upload-session/service', () => ({ abortUploadSession: vi.fn(), })) +vi.mock('@/lib/uploads/upload-session/application', () => ({ + createInternalPurposeUploadSession: mockCreateInternalPurposeUploadSession, + completeInternalUploadSession: mockCompleteInternalUploadSession, + issueInternalUploadPartUrls: vi.fn(), + abortInternalUploadSession: vi.fn(), +})) + vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetUserEntityPermissions, })) @@ -98,12 +109,12 @@ function session(overrides: Record = {}) { describe('/api/files/uploads', () => { beforeEach(() => { vi.clearAllMocks() - mockGetSession.mockResolvedValue({ user: actor }) + mockGetSession.mockResolvedValue({ user: actor, session: { id: 'session-1' } }) mockGetUserEntityPermissions.mockResolvedValue('admin') }) it('creates a purpose-scoped PUT session without exposing write capability in the session', async () => { - mockCreateUploadSession.mockResolvedValue({ + mockCreateInternalPurposeUploadSession.mockResolvedValue({ ...session(), transfer: { method: 'put', @@ -126,12 +137,10 @@ describe('/api/files/uploads', () => { const body = await response.json() expect(response.status).toBe(201) - expect(mockCreateUploadSession).toHaveBeenCalledWith( - expect.objectContaining({ - purpose: 'profile_picture', - userId: actor.id, - localOrigin: 'http://localhost', - }) + expect(mockCreateInternalPurposeUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'session', userId: actor.id }), + expect.objectContaining({ purpose: 'profile_picture' }), + request ) expect(body.data).toMatchObject({ session: { @@ -148,7 +157,7 @@ describe('/api/files/uploads', () => { }) it('creates a PUT session for an empty workspace file', async () => { - mockCreateUploadSession.mockResolvedValue({ + mockCreateInternalPurposeUploadSession.mockResolvedValue({ ...session({ workspaceId: 'workspace-1', purpose: 'workspace_file', @@ -179,8 +188,10 @@ describe('/api/files/uploads', () => { const response = await createUpload(request) expect(response.status).toBe(201) - expect(mockCreateUploadSession).toHaveBeenCalledWith( - expect.objectContaining({ purpose: 'workspace_file', fileSize: 0 }) + expect(mockCreateInternalPurposeUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'session' }), + expect.objectContaining({ purpose: 'workspace_file', size: 0 }), + request ) await expect(response.json()).resolves.toMatchObject({ data: { session: { purpose: 'workspace_file', size: 0 } }, @@ -188,7 +199,7 @@ describe('/api/files/uploads', () => { }) it('preserves the 5 GiB direct-to-storage limit for mothership attachments', async () => { - mockCreateUploadSession.mockResolvedValue({ + mockCreateInternalPurposeUploadSession.mockResolvedValue({ ...session({ workspaceId: 'workspace-1', purpose: 'mothership_attachment', @@ -216,11 +227,10 @@ describe('/api/files/uploads', () => { const response = await createUpload(request) expect(response.status).toBe(201) - expect(mockCreateUploadSession).toHaveBeenCalledWith( - expect.objectContaining({ - purpose: 'mothership_attachment', - fileSize: MAX_WORKSPACE_FILE_SIZE, - }) + expect(mockCreateInternalPurposeUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'session' }), + expect.objectContaining({ purpose: 'mothership_attachment', size: MAX_WORKSPACE_FILE_SIZE }), + request ) }) @@ -240,7 +250,7 @@ describe('/api/files/uploads', () => { const response = await createUpload(request) expect(response.status).toBe(400) - expect(mockCreateUploadSession).not.toHaveBeenCalled() + expect(mockCreateInternalPurposeUploadSession).not.toHaveBeenCalled() }) it('reauthorizes a terminal request and returns only the terminal-safe session', async () => { @@ -259,7 +269,7 @@ describe('/api/files/uploads', () => { type: 'image/png', } mockGetOwnedUploadSession.mockReturnValue(logoSession) - mockCompleteUploadSession.mockResolvedValue({ + mockCompleteInternalUploadSession.mockResolvedValue({ session: { ...logoSession, status: 'completed', completedAt: now }, value: result, alreadyCompleted: false, @@ -275,9 +285,13 @@ describe('/api/files/uploads', () => { const body = await response.json() expect(response.status).toBe(200) - expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(actor.id, 'workspace', 'workspace-1') - expect(mockCompleteUploadSession).toHaveBeenCalledWith( - expect.objectContaining({ session: logoSession }) + expect(mockCompleteInternalUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'session' }), + expect.objectContaining({ + uploadId: 'upload-1', + actor: expect.objectContaining({ id: actor.id }), + }), + request ) expect(body).toEqual({ data: expect.objectContaining({ @@ -302,6 +316,6 @@ describe('/api/files/uploads', () => { const response = await createUpload(request) expect(response.status).toBe(401) - expect(mockCreateUploadSession).not.toHaveBeenCalled() + expect(mockCreateInternalPurposeUploadSession).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/files/uploads/route.ts b/apps/sim/app/api/files/uploads/route.ts index 85752ea6e82..c7e4e0a56d9 100644 --- a/apps/sim/app/api/files/uploads/route.ts +++ b/apps/sim/app/api/files/uploads/route.ts @@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { createInternalFileUploadContract } from '@/lib/api/contracts/upload-sessions' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createPurposeUploadSession } from '@/app/api/files/uploads/purposes' +import { createInternalPurposeUploadSession } from '@/lib/uploads/upload-session/application' import { requireUploadUser, toInternalUploadSession, @@ -16,10 +16,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response try { - const created = await createPurposeUploadSession( - actor.id, + const created = await createInternalPurposeUploadSession( + actor.principal, parsed.data.body, - request.nextUrl.origin + request ) return NextResponse.json( { diff --git a/apps/sim/app/api/files/uploads/utils.ts b/apps/sim/app/api/files/uploads/utils.ts index ca8e9356cb3..e19ea1a9210 100644 --- a/apps/sim/app/api/files/uploads/utils.ts +++ b/apps/sim/app/api/files/uploads/utils.ts @@ -1,3 +1,4 @@ +import type { SessionPrincipal } from '@sim/auth/principal' import { NextResponse } from 'next/server' import { type InternalFileUploadSession, @@ -8,15 +9,25 @@ import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/or import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service' import type { UploadActor, UploadPurposeResult } from '@/app/api/files/uploads/finalizers' -export async function requireUploadUser(): Promise { +export type AuthenticatedUploadActor = UploadActor & { principal: SessionPrincipal } + +export async function requireUploadUser(): Promise { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const sessionId = session.session?.id + if (!sessionId) throw new Error('Authenticated session is missing its session ID') + const principal: SessionPrincipal = { + kind: 'session', + userId: session.user.id, + sessionId, + } return { id: session.user.id, name: session.user.name, email: session.user.email, + principal, } } diff --git a/apps/sim/app/api/files/utils.ts b/apps/sim/app/api/files/utils.ts index a5f97e4b431..96e865cfaf6 100644 --- a/apps/sim/app/api/files/utils.ts +++ b/apps/sim/app/api/files/utils.ts @@ -167,7 +167,7 @@ const SAFE_INLINE_TYPES = new Set([ const FORCE_ATTACHMENT_EXTENSIONS = new Set(['html', 'htm', 'js', 'css', 'xml']) -function getSecureFileHeaders(filename: string, originalContentType: string) { +export function getSecureFileHeaders(filename: string, originalContentType: string) { const extension = filename.split('.').pop()?.toLowerCase() || '' if (FORCE_ATTACHMENT_EXTENSIONS.has(extension)) { diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 351d9f8d7d4..f22e484ee2c 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -125,6 +125,16 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ uploadWorkspaceFile: vi.fn(), })) +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ + resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, +})) + +vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ + readWorkspaceFileContent: { + execute: vi.fn(async () => ({ content: await mockFetchWorkspaceFileBuffer() })), + }, +})) + vi.mock('@/lib/uploads', () => ({ StorageService: { uploadFile: mockUploadFile, @@ -183,7 +193,13 @@ describe('Function Execute API Route', () => { url: '/api/files/view/existing', key: 'workspace/existing.png', }) - mockResolveWorkspaceFileReference.mockResolvedValue(null) + mockResolveWorkspaceFileReference.mockResolvedValue({ + id: 'wf_existing', + workspaceId: 'workspace-1', + name: 'existing.txt', + size: 0, + key: 'workspace/existing.txt', + }) mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.alloc(0)) mockValidateWorkspaceFileWriteTarget.mockImplementation(async ({ target }) => ({ mode: target.mode, @@ -1234,6 +1250,45 @@ describe('Function Execute API Route', () => { expect(data.output.result.message).toContain('/home/user/doc.md') }) + it('continues an overwrite when the advisory comparison fails', async () => { + envFlagsMock.isRemoteSandboxEnabled = true + const newContent = '# doc\nnew content\n' + mockExecuteInSandbox.mockResolvedValueOnce({ + result: 'done', + stdout: 'ok', + sandboxId: 'sandbox-123', + exportedFiles: { '/home/user/doc.md': newContent }, + }) + mockResolveWorkspaceFileReference.mockRejectedValueOnce( + new Error('comparison storage unavailable') + ) + + const response = await POST( + createMockRequest('POST', { + code: 'print("done")', + language: 'python', + workspaceId: 'workspace-1', + outputs: { + files: [ + { + path: 'files/doc.md', + mode: 'overwrite', + sandboxPath: '/home/user/doc.md', + mimeType: 'text/markdown', + }, + ], + }, + }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1) + expect(data.output.result).toMatchObject({ unchanged: false }) + expect(data.output.result).not.toHaveProperty('previousSize') + }) + it('reports size, previousSize, and sha256 receipts on a successful overwrite export', async () => { envFlagsMock.isRemoteSandboxEnabled = true const newContent = '# doc\nnew content\n' diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 67e80f7cd7d..c8adac49106 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -1,3 +1,4 @@ +import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' import { getErrorMessage } from '@sim/utils/errors' @@ -78,15 +79,15 @@ import { MAX_SANDBOX_OUTPUT_BYTES, } from '@/lib/execution/remote-sandbox/output-limits' import { isExecutionResourceLimitError } from '@/lib/execution/resource-errors' -import { - fetchWorkspaceFileBuffer, - resolveWorkspaceFileReference, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { mergeWorkspaceFileSecretProvenance, type WorkspaceFileSecretProvenance, } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { getWorkflowById } from '@/lib/workflows/utils' +import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants' import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference' import { @@ -1319,24 +1320,41 @@ async function appendPrivateResolvedSecretNames( * either a legitimately idempotent regeneration, or the incident signature of * code that never wrote to the declared sandboxPath (the file still holds the * mounted input). Only the model can tell those apart, so callers surface the - * fact loudly in the receipt instead of failing the write. Comparison failures - * never block the write; the current content is only downloaded when the sizes - * already match. + * fact loudly in the receipt instead of failing the write. Comparison is + * advisory and never blocks the authoritative write; the current content is + * only downloaded when the sizes already match. */ async function checkOverwriteTarget( + principal: Principal, workspaceId: string, targetPath: string, buffer: Buffer ): Promise<{ previousSize?: number; identical: boolean }> { try { - const existing = await resolveWorkspaceFileReference(workspaceId, targetPath) - if (!existing) return { identical: false } + const existing = await resolveWorkspaceFileReference({ + principal, + operation: fileOperations.updateContent, + workspaceId, + reference: targetPath, + }) if (existing.size !== buffer.length) { return { previousSize: existing.size, identical: false } } - const current = await fetchWorkspaceFileBuffer(existing) + const { content: current } = await readWorkspaceFileContent.execute({ + principal, + input: { + fileId: existing.id, + assertedWorkspaceId: workspaceId, + maxBytes: buffer.length, + }, + }) return { previousSize: existing.size, identical: current.equals(buffer) } - } catch { + } catch (error) { + logger.warn('Unable to compare workspace overwrite target before export', { + workspaceId, + targetPath, + error: getErrorMessage(error), + }) return { identical: false } } } @@ -1455,11 +1473,18 @@ async function maybeExportSandboxFileToWorkspace(args: { const mode = outputMode ?? (overwriteFileId ? 'overwrite' : 'create') const targetPath = mode === 'create' ? outputPath : overwriteFileId || outputPath + const principal = createWorkspaceFileDelegatedPrincipal({ + serviceId: 'executor', + subjectUserId: authUserId, + workspaceId: resolvedWorkspaceId, + delegationId: `function-execute:${routeContext.requestId}`, + executionId: routeContext.executionId, + }) let previousSize: number | undefined let unchanged = false if (mode === 'overwrite') { - const check = await checkOverwriteTarget(resolvedWorkspaceId, targetPath, fileBuffer) + const check = await checkOverwriteTarget(principal, resolvedWorkspaceId, targetPath, fileBuffer) previousSize = check.previousSize unchanged = check.identical } @@ -1468,7 +1493,7 @@ async function maybeExportSandboxFileToWorkspace(args: { const sha256 = sha256Hex(fileBuffer) const written = await writeWorkspaceFileByPath({ workspaceId: resolvedWorkspaceId, - userId: authUserId, + principal, target: { path: targetPath, mode, @@ -1656,13 +1681,20 @@ async function maybeExportSandboxFilesToWorkspace(args: { }) } + const principal = createWorkspaceFileDelegatedPrincipal({ + serviceId: 'executor', + subjectUserId: args.authUserId, + workspaceId: resolvedWorkspaceId, + delegationId: `function-execute:${args.routeContext.requestId}`, + executionId: args.routeContext.executionId, + }) let validationPaths: string[] try { const validations = await Promise.all( preparedFiles.map((prepared) => validateWorkspaceFileWriteTarget({ workspaceId: resolvedWorkspaceId, - userId: args.authUserId, + principal, target: prepared.target, }) ) @@ -1709,14 +1741,19 @@ async function maybeExportSandboxFilesToWorkspace(args: { let previousSize: number | undefined let unchanged = false if (prepared.target.mode === 'overwrite') { - const check = await checkOverwriteTarget(resolvedWorkspaceId, prepared.target.path, buffer) + const check = await checkOverwriteTarget( + principal, + resolvedWorkspaceId, + prepared.target.path, + buffer + ) previousSize = check.previousSize unchanged = check.identical } const sha256 = sha256Hex(buffer) const written = await writeWorkspaceFileByPath({ workspaceId: resolvedWorkspaceId, - userId: args.authUserId, + principal, target: prepared.target, buffer, inferredMimeType: prepared.resolvedMimeType, diff --git a/apps/sim/app/api/tools/file/manage/route.test.ts b/apps/sim/app/api/tools/file/manage/route.test.ts index 27fbdb18858..e7a4625a089 100644 --- a/apps/sim/app/api/tools/file/manage/route.test.ts +++ b/apps/sim/app/api/tools/file/manage/route.test.ts @@ -13,6 +13,9 @@ const { mockEnsureWorkspaceFileFolderPath, mockFetchWorkspaceFileBuffer, mockGetBoundWorkspaceFileSecretProvenance, + mockLoadActiveWorkspaceContext, + mockLoadActiveWorkspaceFileContext, + mockResolveEffectiveWorkspacePermission, mockGetFileMetadataByKey, mockGetWorkspaceFile, mockResolveWorkspaceFileReference, @@ -26,6 +29,9 @@ const { mockEnsureWorkspaceFileFolderPath: vi.fn(), mockFetchWorkspaceFileBuffer: vi.fn(), mockGetBoundWorkspaceFileSecretProvenance: vi.fn(), + mockLoadActiveWorkspaceContext: vi.fn(), + mockLoadActiveWorkspaceFileContext: vi.fn(), + mockResolveEffectiveWorkspacePermission: vi.fn(), mockGetFileMetadataByKey: vi.fn(), mockGetWorkspaceFile: vi.fn(), mockResolveWorkspaceFileReference: vi.fn(), @@ -38,16 +44,50 @@ vi.mock('@/lib/file-parsers', () => ({ parseBuffer: vi.fn(), })) +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_UPLOADED: 'file_uploaded', FILE_UPDATED: 'file_updated' }, + AuditResourceType: { FILE: 'file' }, + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/realtime/notify', () => ({ + notifyWorkspaceFilesChanged: vi.fn(async () => undefined), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || + permission === required || + (permission === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: (...args: unknown[]) => + mockResolveEffectiveWorkspacePermission(...args), +})) + vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ fetchWorkspaceFileBuffer: (...args: unknown[]) => mockFetchWorkspaceFileBuffer(...args), getWorkspaceFile: (...args: unknown[]) => mockGetWorkspaceFile(...args), + loadActiveWorkspaceContext: (...args: unknown[]) => mockLoadActiveWorkspaceContext(...args), + loadActiveWorkspaceFileContext: (...args: unknown[]) => + mockLoadActiveWorkspaceFileContext(...args), resolveWorkspaceFileReference: (...args: unknown[]) => mockResolveWorkspaceFileReference(...args), updateWorkspaceFileContent: (...args: unknown[]) => mockUpdateWorkspaceFileContent(...args), uploadWorkspaceFile: (...args: unknown[]) => mockUploadWorkspaceFile(...args), })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ - ensureWorkspaceFileFolderPath: (...args: unknown[]) => mockEnsureWorkspaceFileFolderPath(...args), +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + FileConflictError: class FileConflictError extends Error {}, + ContentVersionConflictError: class ContentVersionConflictError extends Error {}, + fetchWorkspaceFileBuffer: (...args: unknown[]) => mockFetchWorkspaceFileBuffer(...args), + getWorkspaceFile: (...args: unknown[]) => mockGetWorkspaceFile(...args), + loadActiveWorkspaceContext: (...args: unknown[]) => mockLoadActiveWorkspaceContext(...args), + updateWorkspaceFileContent: (...args: unknown[]) => mockUpdateWorkspaceFileContent(...args), + uploadWorkspaceFile: (...args: unknown[]) => mockUploadWorkspaceFile(...args), +})) + +vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ + createWorkspaceFileFolderOperation: { + execute: (...args: unknown[]) => mockEnsureWorkspaceFileFolderPath(...args), + }, })) vi.mock('@/lib/core/config/redis', () => ({ @@ -56,6 +96,7 @@ vi.mock('@/lib/core/config/redis', () => ({ })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ + EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE: { status: 'exact', entries: [] }, getBoundWorkspaceFileSecretProvenance: (...args: unknown[]) => mockGetBoundWorkspaceFileSecretProvenance(...args), mergeWorkspaceFileSecretProvenance: ( @@ -131,12 +172,30 @@ describe('POST /api/tools/file/manage content provenance', () => { authType: 'internal_jwt', }) mockAssertActiveWorkspaceAccess.mockResolvedValue(undefined) + mockResolveEffectiveWorkspacePermission.mockResolvedValue('write') + mockGetWorkspaceFile.mockImplementation(async (_workspaceId: string, fileId: string) => + workspaceFile(fileId) + ) + mockLoadActiveWorkspaceContext.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'user-1', + }) + mockLoadActiveWorkspaceFileContext.mockImplementation(async (fileId: string) => ({ + fileId, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'user-1', + })) mockAssertToolFileAccess.mockResolvedValue(undefined) - mockEnsureWorkspaceFileFolderPath.mockResolvedValue(null) + mockEnsureWorkspaceFileFolderPath.mockResolvedValue({ folder: { id: 'folder-1' } }) mockDownloadServableFileFromStorage.mockImplementation(async (file: { name: string }) => ({ buffer: Buffer.from(`content:${file.name}`), })) mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('before')) + mockUpdateWorkspaceFileContent.mockResolvedValue({ file: workspaceFile('file-1') }) mockUploadWorkspaceFile.mockResolvedValue({ id: 'new-file', name: 'new.txt', @@ -146,9 +205,6 @@ describe('POST /api/tools/file/manage content provenance', () => { }) it('returns a scoped, deduplicated union of exact canonical file provenance', async () => { - mockGetWorkspaceFile.mockImplementation(async (_workspaceId: string, fileId: string) => - workspaceFile(fileId) - ) mockGetBoundWorkspaceFileSecretProvenance.mockImplementation( async (_workspaceId: string, identity: { fileId: string }) => identity.fileId === 'file-1' @@ -229,7 +285,9 @@ describe('POST /api/tools/file/manage content provenance', () => { 'new.txt', 'text/plain', { + exactName: false, folderId: null, + folderPath: undefined, secretProvenance: { status: 'exact', entries: [ @@ -308,11 +366,12 @@ describe('POST /api/tools/file/manage content provenance', () => { ) expect(response.status).toBe(200) - expect(mockEnsureWorkspaceFileFolderPath).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - userId: 'user-1', - pathSegments: ['Reports'], - }) + expect(mockEnsureWorkspaceFileFolderPath).toHaveBeenCalledWith( + expect.objectContaining({ + principal: expect.objectContaining({ kind: 'delegated', subjectUserId: 'user-1' }), + input: { workspaceId: 'workspace-1', path: 'Reports' }, + }) + ) expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( 'workspace-1', 'user-1', @@ -320,7 +379,9 @@ describe('POST /api/tools/file/manage content provenance', () => { 'secret-value.txt', 'text/plain', { - folderId: null, + exactName: false, + folderId: 'folder-1', + folderPath: undefined, secretProvenance: { status: 'exact', entries: [] }, } ) @@ -335,7 +396,6 @@ describe('POST /api/tools/file/manage content provenance', () => { content: 'ordinary text', }) ) - expect(response.status).toBe(200) expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( 'workspace-1', @@ -343,7 +403,12 @@ describe('POST /api/tools/file/manage content provenance', () => { Buffer.from('ordinary text'), 'new.txt', 'text/plain', - { folderId: null } + { + exactName: false, + folderId: null, + folderPath: undefined, + secretProvenance: { status: 'exact', entries: [] }, + } ) }) @@ -471,19 +536,20 @@ describe('POST /api/tools/file/manage content provenance', () => { ) expect(response.status).toBe(200) + expect(Buffer.isBuffer(mockUploadWorkspaceFile.mock.calls[0]?.[2])).toBe(true) expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( 'workspace-1', 'user-1', - expect.any(Buffer), + expect.anything(), 'bundle.zip', 'application/zip', - { + expect.objectContaining({ folderId: null, secretProvenance: { status: 'exact', entries: [{ name: 'TOKEN', encryptedValue: 'encrypted-token' }], }, - } + }) ) }) @@ -607,4 +673,26 @@ describe('POST /api/tools/file/manage content provenance', () => { expect(body).not.toHaveProperty('__resolvedSecretTraceProvenance') expect(mockGetBoundWorkspaceFileSecretProvenance).not.toHaveBeenCalled() }) + + it('never uses query.userId as the authorization identity', async () => { + mockGetWorkspaceFile.mockResolvedValue(workspaceFile('file-1')) + + const response = await POST( + createMockRequest( + 'POST', + { operation: 'get', workspaceId: 'workspace-1', fileId: 'file-1' }, + {}, + 'http://localhost:3000/api/tools/file/manage?userId=attacker' + ) + ) + + expect(response.status).toBe(200) + expect(mockResolveEffectiveWorkspacePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + null, + undefined, + { forUpdate: undefined } + ) + }) }) diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 1af9a5f4c70..e3595aa6fec 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -1,5 +1,4 @@ import { Buffer, isUtf8 } from 'buffer' -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' @@ -10,6 +9,7 @@ import { parseRequest } from '@/lib/api/server' import { AuthType, type AuthTypeValue, checkInternalAuth } from '@/lib/auth/hybrid' import { splitWorkspaceFilePath } from '@/lib/copilot/tools/server/files/workspace-file' import { acquireLock, releaseLock } from '@/lib/core/config/redis' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { ensureAbsoluteUrl } from '@/lib/core/utils/urls' @@ -26,26 +26,14 @@ import { requestsPrivateToolMetadata, } from '@/lib/execution/private-tool-metadata' import { isSupportedFileType, parseBuffer } from '@/lib/file-parsers' -import { - getShareForResource, - getSharesForResources, - ShareValidationError, - upsertFileShare, -} from '@/lib/public-shares/share-manager' +import { getSharesForResources, ShareValidationError } from '@/lib/public-shares/share-manager' import { ArchiveError, type DecompressResult, decompressArchiveBufferToWorkspaceFiles, MAX_ARCHIVE_BYTES, } from '@/lib/uploads/archive' -import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { - fetchWorkspaceFileBuffer, - getWorkspaceFile, - resolveWorkspaceFileReference, - updateWorkspaceFileContent, - uploadWorkspaceFile, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import type { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getBoundWorkspaceFileSecretProvenance, mergeWorkspaceFileSecretProvenance, @@ -60,17 +48,24 @@ import { } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path' -import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' import { - assertActiveWorkspaceAccess, - getUserEntityPermissions, - isWorkspaceAccessDeniedError, -} from '@/lib/workspaces/permissions/utils' + admitCreateWorkspaceFile, + createWorkspaceFile, + createWorkspaceFileFromBuffer, +} from '@/lib/workspace-files/application/create-workspace-file' +import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' +import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' +import { downloadWorkspaceFileRecord } from '@/lib/workspace-files/application/read-workspace-file-record' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' +import { updateWorkspaceFileShare } from '@/lib/workspace-files/application/share-workspace-file' +import { updateWorkspaceFileContent } from '@/lib/workspace-files/application/update-workspace-file-content' +import { createWorkspaceFileFolderOperation } from '@/lib/workspace-files/application/workspace-file-folders' +import { MAX_WORKSPACE_FILE_CONTENT_BYTES } from '@/lib/workspace-files/orchestration' +import { isWorkspaceAccessDeniedError } from '@/lib/workspaces/permissions/utils' import { assertToolFileAccess } from '@/app/api/files/authorization' -import { - PublicFileSharingNotAllowedError, - validatePublicFileSharing, -} from '@/ee/access-control/utils/permission-check' import type { UserFile } from '@/executor/types' import { ResolvedSecretTraceProvenanceAccumulator, @@ -82,6 +77,16 @@ export const dynamic = 'force-dynamic' const logger = createLogger('FileManageAPI') +function requireInternalPrincipal(auth: { userId?: string }, workspaceId: string) { + if (!auth.userId) throw new Error('Authenticated internal file operation is missing its user ID') + return createWorkspaceFileDelegatedPrincipal({ + serviceId: 'executor', + subjectUserId: auth.userId, + workspaceId, + delegationId: `internal-file-tool:${auth.userId}`, + }) +} + const workspaceFileToUserFile = (file: Awaited>) => { if (!file) return null @@ -440,15 +445,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const { query, body } = parsed.data - const userId = auth.userId || query.userId - if (!userId) { - return NextResponse.json({ success: false, error: 'userId is required' }, { status: 400 }) - } + if (!auth.userId) throw new Error('Authenticated internal file operation is missing its user ID') + const userId = auth.userId const workspaceId = body.workspaceId || query.workspaceId if (!workspaceId) { return NextResponse.json({ success: false, error: 'workspaceId is required' }, { status: 400 }) } + const principal = requireInternalPrincipal(auth, workspaceId) const includePrivateContentProvenance = body.operation === 'content' && requestsPrivateToolMetadata(request.headers, RESOLVED_SECRET_PROVENANCE_METADATA_V1) @@ -459,8 +463,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) => fileContentJsonResponse(responseBody, includePrivateContentProvenance, init, provenance) try { - await assertActiveWorkspaceAccess(workspaceId, userId) - switch (body.operation) { case 'get': { const { fileId, fileInput } = body @@ -481,12 +483,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) } - const file = await getWorkspaceFile(workspaceId, selectedFileId) - if (!file) { - return NextResponse.json( - { success: false, error: `File not found: "${selectedFileId}"` }, - { status: 404 } - ) + let file: Awaited> + try { + file = ( + await readWorkspaceFileMetadata.execute({ + principal, + input: { fileId: selectedFileId, assertedWorkspaceId: workspaceId }, + request, + }) + ).file + } catch (error) { + if (error instanceof OrchestrationError && error.code === 'not_found') { + return NextResponse.json( + { success: false, error: `File not found: "${selectedFileId}"` }, + { status: 404 } + ) + } + throw error } logger.info('File retrieved', { @@ -515,15 +528,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) } - const files = await Promise.all( - selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id)) - ) - const missingFileId = selectedFileIds.find((_, index) => !files[index]) - if (missingFileId) { - return NextResponse.json( - { success: false, error: `File not found: "${missingFileId}"` }, - { status: 404 } - ) + const files = [] as Array>>> + for (const id of selectedFileIds) { + try { + files.push( + ( + await readWorkspaceFileMetadata.execute({ + principal, + input: { fileId: id, assertedWorkspaceId: workspaceId }, + request, + }) + ).file + ) + } catch (error) { + if (error instanceof OrchestrationError && error.code === 'not_found') { + return NextResponse.json( + { success: false, error: `File not found: "${id}"` }, + { status: 404 } + ) + } + throw error + } } const shares = await getSharesForResources('file', selectedFileIds) @@ -580,15 +605,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return contentResponse({ success: false, error: 'File is required' }, { status: 400 }) } - const workspaceFiles = await Promise.all( - selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id)) - ) - const missingFileId = selectedFileIds.find((_, index) => !workspaceFiles[index]) - if (missingFileId) { - return contentResponse( - { success: false, error: `File not found: "${missingFileId}"` }, - { status: 404 } - ) + const workspaceFiles = [] as Array< + NonNullable>> + > + for (const id of selectedFileIds) { + try { + workspaceFiles.push( + ( + await readWorkspaceFileMetadata.execute({ + principal, + input: { fileId: id, assertedWorkspaceId: workspaceId }, + request, + }) + ).file + ) + } catch (error) { + if (error instanceof OrchestrationError && error.code === 'not_found') { + return contentResponse( + { success: false, error: `File not found: "${id}"` }, + { status: 404 } + ) + } + throw error + } } const canonicalSources: FileContentSource[] = workspaceFiles.flatMap((file) => { @@ -660,29 +699,38 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } const { folderSegments, leafName } = splitWorkspaceFilePath(fileName) - const folderId = await ensureWorkspaceFileFolderPath({ - workspaceId, - userId, - pathSegments: folderSegments, - }) + await admitCreateWorkspaceFile(principal, workspaceId) + const folderId = + folderSegments.length === 0 + ? null + : ( + await createWorkspaceFileFolderOperation.execute({ + principal, + input: { workspaceId, path: folderSegments.join('/') }, + request, + }) + ).folder.id const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(leafName)) - const fileBuffer = Buffer.from(content ?? '', 'utf-8') - const result = await uploadWorkspaceFile( - workspaceId, - userId, - fileBuffer, - leafName, - mimeType, - { + const result = await createWorkspaceFile.execute({ + principal, + input: { + workspaceId, + name: leafName, + contentType: mimeType, + content: content ?? '', + encoding: 'utf-8', folderId, + exactName: false, ...(provenanceResolution.contentProvenance ? { secretProvenance: provenanceResolution.contentProvenance } : {}), - } - ) + }, + request, + }) + const fileBuffer = Buffer.from(content ?? '', 'utf-8') logger.info('File created', { - fileId: result.id, + fileId: result.file.id, name: fileName, size: fileBuffer.length, }) @@ -690,10 +738,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, data: { - id: result.id, - name: result.name, + id: result.file.id, + name: result.file.name, size: fileBuffer.length, - url: ensureAbsoluteUrl(result.url), + url: ensureAbsoluteUrl(result.file.url ?? result.file.path), }, }) } @@ -707,30 +755,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { .map((s) => s.trim()) .filter(Boolean) : [] - const targetFolderId = await ensureWorkspaceFileFolderPath({ - workspaceId, - userId, - pathSegments, - }) - const moveResult = await performMoveWorkspaceFileItems({ - workspaceId, - userId, - fileIds: [fileId], - targetFolderId, + await moveWorkspaceFileItemsOperation.execute({ + principal, + input: { + workspaceId, + fileIds: [fileId], + targetFolderPath: pathSegments.join('/'), + }, + request, }) - if (!moveResult.success) { - return NextResponse.json( - { success: false, error: moveResult.error }, - { - status: - moveResult.errorCode === 'conflict' - ? 409 - : moveResult.errorCode === 'not_found' - ? 404 - : 400, - } - ) - } logger.info('File moved', { fileId, targetFolder: targetFolder || '(root)' }) return NextResponse.json({ success: true, @@ -741,18 +774,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { case 'manage_sharing': { const { fileId, fileInput, isActive, authType, password, allowedEmails } = body - // Check permission before probing file existence so a read-only caller - // can't distinguish 404 from 403 as a file-existence side channel. - // Publishing is more sensitive than the other mutating ops, so it - // requires write/admin (not just workspace access) like the share route. - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json( - { success: false, error: 'Insufficient permissions' }, - { status: 403 } - ) - } - // Resolve the canonical file id. The basic file picker provides an object // with a storage `key` but no id, so map the key to the workspace file row. let resolvedFileId = typeof fileId === 'string' ? fileId : undefined @@ -776,52 +797,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const file = await getWorkspaceFile(workspaceId, resolvedFileId) - if (!file) { - return NextResponse.json( - { success: false, error: `File not found: "${resolvedFileId}"` }, - { status: 404 } - ) - } - - // Enabling a share is gated by the org's access-control policy; disabling - // is always allowed so users can un-share after the policy is turned on. - if (isActive) { - // Resolve the auth type the same way upsertFileShare will (falling back - // to the existing share's type) so the policy gate can't be bypassed by - // re-enabling a pre-existing restricted share without an explicit authType. - const existingShare = await getShareForResource('file', resolvedFileId) - const resolvedAuthType = authType ?? existingShare?.authType ?? 'public' - try { - await validatePublicFileSharing(userId, workspaceId, resolvedAuthType) - } catch (error) { - if (error instanceof PublicFileSharingNotAllowedError) { - return NextResponse.json({ success: false, error: error.message }, { status: 403 }) - } - throw error - } - } - - const share = await upsertFileShare({ - workspaceId, - fileId: resolvedFileId, - userId, - isActive, - authType, - password, - allowedEmails, - }) - - recordAudit({ - workspaceId, - actorId: userId, - action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED, - resourceType: AuditResourceType.FILE, - resourceId: resolvedFileId, - resourceName: file.name, - description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${file.name}"`, - request, - }) + const share = ( + await updateWorkspaceFileShare.execute({ + principal, + input: { + fileId: resolvedFileId, + assertedWorkspaceId: workspaceId, + isActive, + authType, + password, + allowedEmails, + }, + request, + }) + ).share logger.info('File sharing updated', { fileId: resolvedFileId, @@ -837,13 +826,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { case 'append': { const { fileName, content } = body - const existing = await resolveWorkspaceFileReference(workspaceId, fileName) - if (!existing) { - return NextResponse.json( - { success: false, error: `File not found: "${fileName}"` }, - { status: 404 } - ) - } + const existing = await resolveWorkspaceFileReference({ + principal, + operation: fileOperations.updateContent, + workspaceId, + reference: fileName, + }) const lockKey = `file-append:${workspaceId}:${existing.id}` const lockValue = `${Date.now()}-${generateShortId()}` @@ -887,22 +875,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => { : appendedProvenance ? mergeWorkspaceFileSecretProvenance(existingProvenance, appendedProvenance) : undefined - const existingBuffer = await fetchWorkspaceFileBuffer(existing) + const { content: existingBuffer } = await readWorkspaceFileContent.execute({ + principal, + input: { + fileId: existing.id, + assertedWorkspaceId: workspaceId, + maxBytes: MAX_WORKSPACE_FILE_CONTENT_BYTES, + }, + }) const finalContent = existingBuffer.toString('utf-8') + content const fileBuffer = Buffer.from(finalContent, 'utf-8') - await updateWorkspaceFileContent( - workspaceId, - existing.id, - userId, - fileBuffer, - undefined, - { - expectedUpdatedAt: existing.contentUpdatedAt, - secretProvenancePolicy: secretProvenance - ? { mode: 'replace', provenance: secretProvenance } - : { mode: 'preserve' }, - } - ) + await updateWorkspaceFileContent.execute({ + principal, + input: { + fileId: existing.id, + assertedWorkspaceId: workspaceId, + content: finalContent, + encoding: 'utf-8', + expectedUpdatedAt: existing.contentUpdatedAt ?? undefined, + provenanceMode: secretProvenance ? undefined : 'preserve', + ...(secretProvenance ? { secretProvenance } : {}), + }, + request, + }) logger.info('File appended', { fileId: existing.id, @@ -938,16 +933,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (selectedFileIds.length === 0 && selectedInputFiles.length === 0) { return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) } + await admitCreateWorkspaceFile(principal, workspaceId) - const workspaceFiles = await Promise.all( - selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id)) - ) - const missingFileId = selectedFileIds.find((_, index) => !workspaceFiles[index]) - if (missingFileId) { - return NextResponse.json( - { success: false, error: `File not found: "${missingFileId}"` }, - { status: 404 } - ) + const workspaceFiles = [] as Array< + NonNullable>> + > + for (const id of selectedFileIds) { + try { + workspaceFiles.push( + ( + await downloadWorkspaceFileRecord.execute({ + principal, + input: { fileId: id, assertedWorkspaceId: workspaceId }, + request, + }) + ).file + ) + } catch (error) { + if (error instanceof OrchestrationError && error.code === 'not_found') { + return NextResponse.json( + { success: false, error: `File not found: "${id}"` }, + { status: 404 } + ) + } + throw error + } } const workspaceEntries: ArchiveEntry[] = workspaceFiles.flatMap((file) => { @@ -1028,29 +1038,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ? stripExtension(toFlatFileName(userFiles[0].name, 'archive')) : 'archive' const leafName = ensureZipExtension(baseName) - const folderId = await ensureWorkspaceFileFolderPath({ - workspaceId, - userId, - pathSegments: [], + const result = await createWorkspaceFileFromBuffer.execute({ + principal, + input: { + workspaceId, + name: leafName, + contentType: 'application/zip', + content: zipBuffer, + folderId: null, + exactName: false, + secretProvenance: archiveProvenance, + }, + request, }) - const result = await uploadWorkspaceFile( - workspaceId, - userId, - zipBuffer, - leafName, - 'application/zip', - { folderId, secretProvenance: archiveProvenance } - ) const compressedFile: UserFile = { - ...result, - url: ensureAbsoluteUrl(result.url), + ...result.file, + url: ensureAbsoluteUrl(result.file.url ?? result.file.path), size: zipBuffer.length, } logger.info('Files compressed', { - fileId: result.id, - name: result.name, + fileId: result.file.id, + name: result.file.name, fileCount: userFiles.length, size: zipBuffer.length, }) @@ -1083,16 +1093,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 400 } ) } + await admitCreateWorkspaceFile(principal, workspaceId) - const workspaceFiles = await Promise.all( - selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id)) - ) - const missingFileId = selectedFileIds.find((_, index) => !workspaceFiles[index]) - if (missingFileId) { - return NextResponse.json( - { success: false, error: `File not found: "${missingFileId}"` }, - { status: 404 } - ) + const workspaceFiles = [] as Array< + NonNullable>> + > + for (const id of selectedFileIds) { + try { + workspaceFiles.push( + ( + await downloadWorkspaceFileRecord.execute({ + principal, + input: { fileId: id, assertedWorkspaceId: workspaceId }, + request, + }) + ).file + ) + } catch (error) { + if (error instanceof OrchestrationError && error.code === 'not_found') { + return NextResponse.json( + { success: false, error: `File not found: "${id}"` }, + { status: 404 } + ) + } + throw error + } } const archive = workspaceFiles @@ -1147,7 +1172,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { result = await decompressArchiveBufferToWorkspaceFiles(archiveBuffer, { workspaceId, - userId, + principal, secretProvenance: archiveProvenance, }) } catch (archiveError) { @@ -1202,6 +1227,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (isWorkspaceAccessDeniedError(error)) { return contentResponse({ success: false, error: 'Workspace access denied' }, { status: 403 }) } + if (error instanceof OrchestrationError) { + const status = + error.code === 'forbidden' + ? 403 + : error.code === 'not_found' + ? 404 + : error.code === 'conflict' + ? 409 + : error.code === 'payload_too_large' + ? 413 + : error.code === 'validation' + ? 400 + : 500 + return contentResponse({ success: false, error: error.message }, { status }) + } const notReady = docNotReadyResponse(error) if (notReady) { if (!includePrivateContentProvenance) return notReady diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts index 5ed44af26fb..989a7ef434b 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts @@ -4,62 +4,68 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockPerformUpdateContent, - mockGetUserEmailsByIds, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockPerformUpdateContent: vi.fn(), - mockGetUserEmailsByIds: vi.fn(), +const mocks = vi.hoisted(() => ({ + admit: vi.fn(), + updateContent: vi.fn(), + authenticateV2ApiKey: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + getUserEmailsByIds: vi.fn(), })) -vi.mock('@/lib/users/queries', () => ({ - getUserEmailsByIds: mockGetUserEmailsByIds, - requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, +vi.mock('@/lib/workspace-files/orchestration', () => ({ + MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, +})) + +vi.mock('@/lib/workspace-files/application/update-workspace-file-content', () => ({ + admitUpdateWorkspaceFileContent: mocks.admit, + updateWorkspaceFileContent: { + operation: { id: 'files.update_content', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.updateContent, + }, })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkRateLimitDirect + checkRateLimitDirectOrThrow = mocks.checkRateLimitDirectOrThrow + }, })) vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null), })) -vi.mock('@/lib/workspace-files/orchestration', () => ({ - MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, - performUpdateWorkspaceFileContent: mockPerformUpdateContent, +vi.mock('@/lib/users/queries', () => ({ + getUserEmailsByIds: mocks.getUserEmailsByIds, + requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { PUT } from '@/app/api/v2/files/[fileId]/content/route' -const WS = 'workspace-1' +const WORKSPACE_ID = 'workspace-1' const FILE_ID = 'wf_1' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } - -const RECORD = { +const record = { id: FILE_ID, - workspaceId: WS, + workspaceId: WORKSPACE_ID, name: 'data.csv', key: 'workspace/ws/1-x-data.csv', path: '/api/files/serve/x', @@ -67,7 +73,6 @@ const RECORD = { type: 'text/csv', uploadedBy: 'user-1', folderId: null, - folderPath: null, uploadedAt: new Date('2024-01-01T00:00:00Z'), updatedAt: new Date('2024-01-03T00:00:00Z'), } @@ -88,151 +93,99 @@ const callPut = (body: unknown, contentLength?: number) => describe('PUT /api/v2/files/[fileId]/content', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformUpdateContent.mockResolvedValue({ success: true, file: RECORD }) - mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) - - expect(res.status).toBe(404) - expect(mockPerformUpdateContent).not.toHaveBeenCalled() - }) - - it('400s when content is missing', async () => { - const res = await callPut({ workspaceId: WS }) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformUpdateContent).not.toHaveBeenCalled() - }) - - it('400s on an encoding outside the enum', async () => { - const res = await callPut({ workspaceId: WS, content: 'x', encoding: 'latin1' }) - expect(res.status).toBe(400) - expect(mockPerformUpdateContent).not.toHaveBeenCalled() + mocks.authenticateV2ApiKey.mockResolvedValue(auth) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2024-01-01T01:00:00Z'), + }) + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), + }) + mocks.admit.mockResolvedValue(undefined) + mocks.updateContent.mockResolvedValue({ file: record }) + mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) }) - it('400s malformed base64 in the v2 error envelope', async () => { - const res = await callPut({ workspaceId: WS, content: 'not-base64!', encoding: 'base64' }) - const body = await res.json() - - expect(res.status).toBe(400) - expect(body.error.code).toBe('BAD_REQUEST') - expect(body.error.message).toBe('content must be valid base64') - expect(mockPerformUpdateContent).not.toHaveBeenCalled() - }) + it('performs authenticated admission before parsing a large or malformed body', async () => { + mocks.admit.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) - it('accepts empty base64 as a zero-byte replacement', async () => { - const res = await callPut({ workspaceId: WS, content: '', encoding: 'base64' }) + const response = await callPut('{not-json') - expect(res.status).toBe(200) - expect(mockPerformUpdateContent).toHaveBeenCalledWith( - expect.objectContaining({ content: '', encoding: 'base64' }) - ) + expect(response.status).toBe(404) + expect(mocks.admit).toHaveBeenCalledWith(auth.principal, FILE_ID) + expect(mocks.updateContent).not.toHaveBeenCalled() }) - it('allows JSON bodies above the default 50 MiB cap for base64 expansion', async () => { - const res = await callPut( - { workspaceId: WS, content: 'TQ==', encoding: 'base64' }, - 60 * 1024 * 1024 - ) + it('validates body fields after admission', async () => { + const response = await callPut({ workspaceId: WORKSPACE_ID }) - expect(res.status).toBe(200) - expect(mockPerformUpdateContent).toHaveBeenCalled() + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.updateContent).not.toHaveBeenCalled() }) - it('returns an oversized JSON body in the canonical v2 413 envelope', async () => { - const res = await callPut({ workspaceId: WS, content: '' }, 70 * 1024 * 1024 + 1) + it('returns an oversized body in the canonical v2 envelope', async () => { + const response = await callPut({ workspaceId: WORKSPACE_ID, content: '' }, 70 * 1024 * 1024 + 1) - expect(res.status).toBe(413) - await expect(res.json()).resolves.toEqual({ + expect(response.status).toBe(413) + await expect(response.json()).resolves.toEqual({ error: { code: 'PAYLOAD_TOO_LARGE', message: 'Request body is too large' }, }) - expect(mockPerformUpdateContent).not.toHaveBeenCalled() + expect(mocks.admit).toHaveBeenCalled() + expect(mocks.updateContent).not.toHaveBeenCalled() }) - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', + it('replaces content through the shared use case and returns the v2 projection', async () => { + const request = new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/content`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, content: 'id,name\n' }), }) - const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) - expect(res.status).toBe(403) - expect(mockPerformUpdateContent).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('replaces the content and returns the updated file', async () => { - const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data).toEqual({ - id: FILE_ID, - name: 'data.csv', - size: 8, - type: 'text/csv', - key: 'workspace/ws/1-x-data.csv', - folderPath: '/', - uploadedByEmail: 'ada@example.com', - uploadedAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-03T00:00:00.000Z', + const response = await PUT(request, { params: Promise.resolve({ fileId: FILE_ID }) }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + id: FILE_ID, + name: 'data.csv', + size: 8, + type: 'text/csv', + key: 'workspace/ws/1-x-data.csv', + folderPath: '/', + uploadedByEmail: 'ada@example.com', + uploadedAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-03T00:00:00.000Z', + }, }) - expect(mockPerformUpdateContent).toHaveBeenCalledWith({ - workspaceId: WS, - fileId: FILE_ID, - userId: 'user-1', - content: 'id,name\n', - encoding: 'utf-8', - request: expect.anything(), + expect(mocks.updateContent).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + fileId: FILE_ID, + assertedWorkspaceId: WORKSPACE_ID, + content: 'id,name\n', + encoding: 'utf-8', + }, + request, }) - }) - - it('forwards base64 encoding through to the orchestration', async () => { - await callPut({ workspaceId: WS, content: 'aWQsbmFtZQo=', encoding: 'base64' }) - expect(mockPerformUpdateContent).toHaveBeenCalledWith( - expect.objectContaining({ encoding: 'base64' }) + expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledWith( + 'v2:files.update_content:api-key:key-1', + expect.anything() ) }) - it('maps a payload_too_large errorCode to 413 rather than string-sniffing', async () => { - mockPerformUpdateContent.mockResolvedValue({ - success: false, - error: 'Storage limit exceeded. Used: 5.10GB, Limit: 5GB', - errorCode: 'payload_too_large', - }) - - const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) - const body = await res.json() - - expect(res.status).toBe(413) - expect(body.error.code).toBe('PAYLOAD_TOO_LARGE') - expect(body.error.message).toContain('Storage limit exceeded') - }) - - it('maps a not_found errorCode to 404', async () => { - mockPerformUpdateContent.mockResolvedValue({ - success: false, - error: 'File not found', - errorCode: 'not_found', - }) + it('maps typed quota failures to 413', async () => { + mocks.updateContent.mockRejectedValue( + new OrchestrationError('payload_too_large', 'Storage limit exceeded') + ) - const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) + const response = await callPut({ workspaceId: WORKSPACE_ID, content: 'id,name\n' }) - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(response.status).toBe(413) + expect((await response.json()).error.code).toBe('PAYLOAD_TOO_LARGE') }) }) diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.ts index 3c295bd1306..c85c5251366 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.ts @@ -1,61 +1,41 @@ import { v2UpdateFileContentContract } from '@/lib/api/contracts/v2/files' -import { messageForOrchestrationError } from '@/lib/core/orchestration/types' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' import { - MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, - performUpdateWorkspaceFileContent, -} from '@/lib/workspace-files/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' + admitUpdateWorkspaceFileContent, + updateWorkspaceFileContent, +} from '@/lib/workspace-files/application/update-workspace-file-content' +import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' import { toV2File } from '@/app/api/v2/files/utils' -import { - v2Data, - v2Error, - v2ErrorForOrchestration, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { v2Error } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * PUT /api/v2/files/[fileId]/content — Replace a file's bytes. - * - * A full replace, not an append: `content` becomes the entire body of the file. - * `encoding: 'base64'` carries non-UTF-8 bytes. The decoded body is capped at - * 50 MB and still debits the workspace storage quota, so a write that would push - * the payer past its limit fails with 413. - */ -export const PUT = withPublicApiRouteHandler({ +/** PUT /api/v2/files/[fileId]/content — Replace a file's bytes. */ +export const PUT = defineV2JsonRoute({ contract: v2UpdateFileContentContract, - rateLimitEndpoint: 'file-content', + auth: v2ApiKeyAuth, + operation: fileOperations.updateContent, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, parseOptions: { invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, payloadTooLargeResponse: () => v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large'), }, - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - const { fileId } = input.params - const { workspaceId, content, encoding } = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performUpdateWorkspaceFileContent({ - workspaceId, - fileId, - userId, - content, - encoding, - request, - }) - - if (!result.success || !result.file) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to update file content') - ) + beforeParse: async ({ principal, params }) => { + if (typeof params.fileId === 'string') { + await admitUpdateWorkspaceFileContent(principal, params.fileId) } - - return v2Data(await toV2File(result.file), { rateLimit }) }, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: body.workspaceId, + content: body.content, + encoding: body.encoding, + }), + useCase: updateWorkspaceFileContent, + present: async ({ file }) => ({ data: await toV2File(file) }), }) diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts index 69dd8f2dc7c..e838e936fd0 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts @@ -4,52 +4,57 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockGetWorkspaceFile, - mockGetUserEmailsByIds, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockGetWorkspaceFile: vi.fn(), - mockGetUserEmailsByIds: vi.fn(), +const mocks = vi.hoisted(() => ({ + readMetadata: vi.fn(), + authenticateV2ApiKey: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + getUserEmailsByIds: vi.fn(), })) -vi.mock('@/lib/users/queries', () => ({ - getUserEmailsByIds: mockGetUserEmailsByIds, - requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, +vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => ({ + readWorkspaceFileMetadata: { + operation: { id: 'files.read_metadata', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.readMetadata, + }, })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkRateLimitDirect + checkRateLimitDirectOrThrow = mocks.checkRateLimitDirectOrThrow + }, })) vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null), })) -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - getWorkspaceFile: mockGetWorkspaceFile, +vi.mock('@/lib/users/queries', () => ({ + getUserEmailsByIds: mocks.getUserEmailsByIds, + requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET } from '@/app/api/v2/files/[fileId]/metadata/route' const WORKSPACE_ID = 'workspace-1' const FILE_ID = 'wf_1' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), +const context = { params: Promise.resolve({ fileId: FILE_ID }) } +const auth = { + principal: { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } -const ctx = { params: Promise.resolve({ fileId: FILE_ID }) } - function buildRecord() { return { id: FILE_ID, @@ -68,39 +73,39 @@ function buildRecord() { } const callGet = (query: string) => - GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/metadata?${query}`), ctx) + GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/metadata?${query}`), context) describe('GET /api/v2/files/[fileId]/metadata', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetWorkspaceFile.mockResolvedValue(buildRecord()) - mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) + mocks.authenticateV2ApiKey.mockResolvedValue(auth) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2024-01-01T01:00:00Z'), + }) + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), + }) + mocks.readMetadata.mockResolvedValue({ file: buildRecord() }) + mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) }) - it('400s when workspaceId is missing', async () => { + it('authenticates and charges before rejecting a missing workspaceId', async () => { const response = await callGet('') expect(response.status).toBe(400) - expect(mockGetWorkspaceFile).not.toHaveBeenCalled() + expect(mocks.authenticateV2ApiKey).toHaveBeenCalled() + expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledTimes(2) + expect(mocks.readMetadata).not.toHaveBeenCalled() }) - it('surfaces an access-denied failure', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - - const response = await callGet(`workspaceId=${WORKSPACE_ID}`) - - expect(response.status).toBe(403) - expect(mockGetWorkspaceFile).not.toHaveBeenCalled() - }) - - it('404s when the workspace-scoped file does not exist', async () => { - mockGetWorkspaceFile.mockResolvedValue(null) + it('conceals an authorization failure as not found', async () => { + mocks.readMetadata.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) const response = await callGet(`workspaceId=${WORKSPACE_ID}`) @@ -108,7 +113,7 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { expect((await response.json()).error.code).toBe('NOT_FOUND') }) - it('returns the public metadata projection without loading content', async () => { + it('returns the v2 metadata projection through the shared use case', async () => { const response = await callGet(`workspaceId=${WORKSPACE_ID}`) expect(response.status).toBe(200) @@ -125,14 +130,10 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { updatedAt: '2024-01-02T00:00:00.000Z', }, }) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( - expect.anything(), - 'user-1', - WORKSPACE_ID, - 'read' - ) - expect(mockGetWorkspaceFile).toHaveBeenCalledWith(WORKSPACE_ID, FILE_ID, { - throwOnError: true, + expect(mocks.readMetadata).toHaveBeenCalledWith({ + principal: auth.principal, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, + request: expect.anything(), }) }) }) diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts index f74c7fe55be..a76a4862229 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts @@ -1,27 +1,24 @@ import { v2GetFileContract } from '@/lib/api/contracts/v2/files' -import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { toV2File } from '@/app/api/v2/files/utils' -import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 /** GET /api/v2/files/[fileId]/metadata — Return file metadata without downloading its bytes. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetFileContract, - rateLimitEndpoint: 'file-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { fileId } = input.params - const { workspaceId } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) - if (!file) return v2Error('NOT_FOUND', 'File not found') - - return v2Data(await toV2File(file), { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.readMetadata, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: readWorkspaceFileMetadata, + present: async ({ file }) => ({ data: await toV2File(file) }), }) diff --git a/apps/sim/app/api/v2/files/[fileId]/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/route.test.ts index 9707809e2af..4168341cf70 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.test.ts @@ -1,340 +1,210 @@ /** * @vitest-environment node - * - * Public v2 file detail: download, rename, archive. Covers the orchestration - * error mapping that replaced the route-local status switch. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockGetWorkspaceFile, - mockFetchWorkspaceFileBuffer, - mockPerformRename, - mockPerformDelete, - mockGetUserEmailsByIds, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockGetWorkspaceFile: vi.fn(), - mockFetchWorkspaceFileBuffer: vi.fn(), - mockPerformRename: vi.fn(), - mockPerformDelete: vi.fn(), - mockGetUserEmailsByIds: vi.fn(), +const mocks = vi.hoisted(() => ({ + download: vi.fn(), + rename: vi.fn(), + deleteFile: vi.fn(), + authenticateV2ApiKey: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + getUserEmailsByIds: vi.fn(), })) -vi.mock('@/lib/users/queries', () => ({ - getUserEmailsByIds: mockGetUserEmailsByIds, - requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, +vi.mock('@/lib/workspace-files/application/download-workspace-file', () => ({ + downloadWorkspaceFileStream: { + operation: { id: 'files.download', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.download, + }, })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/workspace-files/application/rename-workspace-file', () => ({ + renameWorkspaceFile: { + operation: { id: 'files.rename', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.rename, + }, })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/workspace-files/application/delete-workspace-file', () => ({ + deleteWorkspaceFileOperation: { + operation: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.deleteFile, + }, })) -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - getWorkspaceFile: mockGetWorkspaceFile, - fetchWorkspaceFileBuffer: mockFetchWorkspaceFileBuffer, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/lib/workspace-files/orchestration', () => ({ - performRenameWorkspaceFile: mockPerformRename, - performDeleteWorkspaceFileItems: mockPerformDelete, +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkRateLimitDirect + checkRateLimitDirectOrThrow = mocks.checkRateLimitDirectOrThrow + }, })) -import { DELETE, GET, PATCH } from '@/app/api/v2/files/[fileId]/route' +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null) })) -const WS = 'workspace-1' -const FILE_ID = 'wf_1' +vi.mock('@/lib/users/queries', () => ({ + getUserEmailsByIds: mocks.getUserEmailsByIds, + requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, +})) -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { DELETE, GET, PATCH } from '@/app/api/v2/files/[fileId]/route' -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, +const WORKSPACE_ID = 'workspace-1' +const FILE_ID = 'wf_1' +const context = { params: Promise.resolve({ fileId: FILE_ID }) } +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } -function buildRecord(overrides: Record = {}) { +function fileRecord(overrides: Record = {}) { return { id: FILE_ID, - workspaceId: WS, + workspaceId: WORKSPACE_ID, name: 'data.csv', key: 'workspace/ws/1-x-data.csv', path: '/api/files/serve/x', - size: 1024, + size: 8, type: 'text/csv', uploadedBy: 'user-1', folderId: null, - folderPath: null, uploadedAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), + updatedAt: new Date('2024-01-03T00:00:00Z'), ...overrides, } } -const ctx = { params: Promise.resolve({ fileId: FILE_ID }) } - -const callDownload = (query: string) => - GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?${query}`), ctx) - -const callRename = (body: unknown) => - PATCH( - new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }), - ctx - ) - -const callDelete = (query: string) => - DELETE(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?${query}`), ctx) - -describe('GET /api/v2/files/[fileId]', () => { +describe('v2 single-file routes', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetWorkspaceFile.mockResolvedValue(buildRecord()) - mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('id,name\n')) - mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callDownload(`workspaceId=${WS}`) - - expect(res.status).toBe(404) - expect(mockGetWorkspaceFile).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callDownload('') - expect(res.status).toBe(400) - expect(mockGetWorkspaceFile).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', + mocks.authenticateV2ApiKey.mockResolvedValue(auth) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2024-01-01T01:00:00Z'), }) - const res = await callDownload(`workspaceId=${WS}`) - expect(res.status).toBe(403) - expect(mockGetWorkspaceFile).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callDownload(`workspaceId=${WS}`) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('streams the bytes with rate-limit headers', async () => { - const res = await callDownload(`workspaceId=${WS}`) - expect(res.status).toBe(200) - expect(res.headers.get('Content-Type')).toBe('text/csv') - expect(res.headers.get('X-RateLimit-Remaining')).toBe('99') - expect(await res.text()).toBe('id,name\n') - }) -}) - -describe('PATCH /api/v2/files/[fileId]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformRename.mockResolvedValue({ - success: true, - file: buildRecord({ name: 'renamed.csv' }), + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) - - expect(res.status).toBe(404) - expect(mockPerformRename).not.toHaveBeenCalled() - }) - - it('400s on a name containing a path separator', async () => { - const res = await callRename({ workspaceId: WS, name: 'nested/renamed.csv' }) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformRename).not.toHaveBeenCalled() - }) - - it('400s on an unknown body field', async () => { - const res = await callRename({ workspaceId: WS, name: 'renamed.csv', folderId: 'fold_1' }) - expect(res.status).toBe(400) - expect(mockPerformRename).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', + mocks.download.mockResolvedValue({ + file: fileRecord(), + stream: new Blob(['id,name\n']).stream(), }) - const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) - expect(res.status).toBe(403) - expect(mockPerformRename).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('renames and returns the public file shape', async () => { - const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data).toEqual({ + mocks.rename.mockResolvedValue({ file: fileRecord({ name: 'renamed.csv' }) }) + mocks.deleteFile.mockResolvedValue({ id: FILE_ID, - name: 'renamed.csv', - size: 1024, - type: 'text/csv', - key: 'workspace/ws/1-x-data.csv', - folderPath: '/', - uploadedByEmail: 'ada@example.com', - uploadedAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', + workspaceId: WORKSPACE_ID, + deleted: true, }) - expect(mockPerformRename).toHaveBeenCalledWith({ - workspaceId: WS, - fileId: FILE_ID, - name: 'renamed.csv', - userId: 'user-1', - }) - }) - - it('maps a conflict errorCode to 409 through the shared mapper', async () => { - mockPerformRename.mockResolvedValue({ - success: false, - error: 'A file named "renamed.csv" already exists in this workspace', - errorCode: 'conflict', - }) - - const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) - const body = await res.json() - - expect(res.status).toBe(409) - expect(body.error.code).toBe('CONFLICT') - expect(body.error.message).toContain('already exists') - }) - - it('hides an unclassified failure behind a generic 500', async () => { - mockPerformRename.mockResolvedValue({ - success: false, - error: 'update "workspace_files" set ... failed', - errorCode: 'internal', + mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) + }) + + it('downloads bytes through the binary adapter with operation rate headers', async () => { + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?workspaceId=${WORKSPACE_ID}`), + context + ) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('text/csv') + expect(response.headers.get('Content-Disposition')).toContain('data.csv') + expect(response.headers.get('X-RateLimit-Remaining')).toBe('99') + expect(await response.text()).toBe('id,name\n') + expect(mocks.download).toHaveBeenCalledWith({ + principal: auth.principal, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, + request: expect.anything(), }) - - const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) - const body = await res.json() - - expect(res.status).toBe(500) - expect(body.error.message).toBe('Internal server error') - }) -}) - -describe('DELETE /api/v2/files/[fileId]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformDelete.mockResolvedValue({ success: true, deletedItems: { files: 1, folders: 0 } }) }) - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + it('conceals download authorization failures', async () => { + mocks.download.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) - const res = await callDelete(`workspaceId=${WS}`) + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?workspaceId=${WORKSPACE_ID}`), + context + ) - expect(res.status).toBe(404) - expect(mockPerformDelete).not.toHaveBeenCalled() + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') }) - it('400s when workspaceId is missing', async () => { - const res = await callDelete('') - expect(res.status).toBe(400) - expect(mockPerformDelete).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', + it('renames through the shared use case and v2 presenter', async () => { + const request = new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, name: 'renamed.csv' }), }) - const res = await callDelete(`workspaceId=${WS}`) - expect(res.status).toBe(403) - expect(mockPerformDelete).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callDelete(`workspaceId=${WS}`) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('archives the file and acknowledges', async () => { - const res = await callDelete(`workspaceId=${WS}`) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data).toEqual({ id: FILE_ID, deleted: true }) - expect(mockPerformDelete).toHaveBeenCalledWith({ - workspaceId: WS, - userId: 'user-1', - fileIds: [FILE_ID], - request: expect.anything(), + const response = await PATCH(request, context) + + expect(response.status).toBe(200) + expect((await response.json()).data.name).toBe('renamed.csv') + expect(mocks.rename).toHaveBeenCalledWith({ + principal: auth.principal, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, name: 'renamed.csv' }, + request, }) }) - it('maps a not_found errorCode to 404', async () => { - mockPerformDelete.mockResolvedValue({ - success: false, - error: 'File not found', - errorCode: 'not_found', + it('maps rename conflicts and conceals authorization failures', async () => { + mocks.rename.mockRejectedValueOnce(new OrchestrationError('conflict', 'Name exists')) + const conflict = await PATCH( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, name: 'renamed.csv' }), + }), + context + ) + expect(conflict.status).toBe(409) + + mocks.rename.mockRejectedValueOnce( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) + const concealed = await PATCH( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, name: 'renamed.csv' }), + }), + context + ) + expect(concealed.status).toBe(404) + }) + + it('archives through the same principal and operation pipeline', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/files/${FILE_ID}?workspaceId=${WORKSPACE_ID}`, + { method: 'DELETE' } + ) + const response = await DELETE(request, context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { id: FILE_ID, deleted: true } }) + expect(mocks.deleteFile).toHaveBeenCalledWith({ + principal: auth.principal, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, + request, }) - - const res = await callDelete(`workspaceId=${WS}`) - - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') }) }) diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts index 53dec1d5470..5db1ff967de 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -1,27 +1,20 @@ -import { createLogger } from '@sim/logger' import { v2DeleteFileContract, v2DownloadFileContract, v2RenameFileContract, } from '@/lib/api/contracts/v2/files' -import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { - performDeleteWorkspaceFileItems, - performRenameWorkspaceFile, -} from '@/lib/workspace-files/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' + defineV2BinaryRoute, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' +import { downloadWorkspaceFileStream } from '@/lib/workspace-files/application/download-workspace-file' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' import { toV2File } from '@/app/api/v2/files/utils' -import { - rateLimitHeaders, - v2Data, - v2Error, - v2ErrorForOrchestration, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2FileDetailAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -33,31 +26,23 @@ export const revalidate = 0 * `X-RateLimit-*` headers. Errors still render the canonical v2 JSON error body. * Lookups are workspace-scoped (IDOR-safe): a file in another workspace 404s. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2BinaryRoute({ contract: v2DownloadFileContract, - rateLimitEndpoint: 'file-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { fileId } = input.params - const { workspaceId } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const fileRecord = await getWorkspaceFile(workspaceId, fileId) - if (!fileRecord) return v2Error('NOT_FOUND', 'File not found') - - const buffer = await fetchWorkspaceFileBuffer(fileRecord) - - return new Response(new Uint8Array(buffer), { - status: 200, - headers: { - 'Content-Type': fileRecord.type || 'application/octet-stream', - 'Content-Disposition': `attachment; filename="${fileRecord.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(fileRecord.name)}`, - 'Content-Length': String(buffer.length), - ...rateLimitHeaders(rateLimit), - }, - }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.download, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: downloadWorkspaceFileStream, + present: ({ file, stream }) => ({ + body: stream, + contentType: file.type || 'application/octet-stream', + contentDisposition: `attachment; filename="${file.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(file.name)}`, + contentLength: file.size, + }), }) /** @@ -67,63 +52,38 @@ export const GET = withPublicApiRouteHandler({ * Names that collide within the destination folder are rejected as `CONFLICT` — * unlike upload, which auto-suffixes on the internal surface. */ -export const PATCH = withPublicApiRouteHandler({ +export const PATCH = defineV2JsonRoute({ contract: v2RenameFileContract, - rateLimitEndpoint: 'file-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { fileId } = input.params - const { workspaceId, name } = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performRenameWorkspaceFile({ workspaceId, fileId, name, userId }) - - if (!result.success || !result.file) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to rename file') - ) - } - - return v2Data(await toV2File(result.file), { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.rename, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: body.workspaceId, + name: body.name, + }), + useCase: renameWorkspaceFile, + present: async ({ file }) => ({ data: await toV2File(file) }), }) /** * DELETE /api/v2/files/[fileId] — Delete a file. * - * Delegates to the shared orchestration, which is workspace-scoped and records - * its own audit entry (the request is forwarded so that entry captures client - * IP / user agent). Orchestration `errorCode`s map to specific v2 codes rather - * than v1's blanket 500. + * Uses the shared workspace-file application operation, which canonicalizes the + * resource, authorizes the API-key principal, archives it, and records the + * semantic audit/notification side effects once. */ -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteFileContract, - rateLimitEndpoint: 'file-detail', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - const { fileId } = input.params - const { workspaceId } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performDeleteWorkspaceFileItems({ - workspaceId, - userId, - fileIds: [fileId], - request, - }) - - if (!result.success) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to delete file') - ) - } - - logger.info(`Deleted file ${fileId} from workspace ${workspaceId}`) - - return v2Data({ id: fileId, deleted: true as const }, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.delete, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: deleteWorkspaceFileOperation, + present: ({ id, deleted }) => ({ data: { id, deleted } }), }) diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts index 25b15d2f47b..78123770c87 100644 --- a/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts @@ -1,57 +1,90 @@ /** * @vitest-environment node - * - * Public v2 file share. The two decisions that separate it from the internal - * route are pinned here: the caller-supplied `token` is rejected, and a bare - * re-enable keeps the token the orchestration already stored. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformGetShare, mockPerformUpsert } = - vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockPerformGetShare: vi.fn(), - mockPerformUpsert: vi.fn(), - })) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { + class MockV2ApiKeyUnauthenticatedError extends Error { + constructor(message = 'Invalid API key') { + super(message) + this.name = 'V2ApiKeyUnauthenticatedError' + } + } + + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + getShare: vi.fn(), + updateShare: vi.fn(), + }, + MockV2ApiKeyUnauthenticatedError, + } +}) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: vi.fn().mockReturnValue({ + maxTokens: 100, + refillRate: 100, + refillIntervalMs: 60_000, + }), })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: vi.fn().mockReturnValue(null), })) -vi.mock('@/lib/workspace-files/orchestration', () => ({ - performGetWorkspaceFileShare: mockPerformGetShare, - performUpsertWorkspaceFileShare: mockPerformUpsert, +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('request-1'), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) + +vi.mock('@/lib/workspace-files/application/share-workspace-file', () => ({ + getWorkspaceFileShare: { + operation: { id: 'files.share.read', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.getShare, + }, + updateWorkspaceFileShare: { + operation: { id: 'files.share.update', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.updateShare, + }, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET, PUT } from '@/app/api/v2/files/[fileId]/share/route' -const WS = 'workspace-1' +const WORKSPACE_ID = 'workspace-1' const FILE_ID = 'wf_1' - +const PRINCIPAL = { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' } +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['workspace:workspace-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} const RATE_LIMIT_OK = { allowed: true, - userId: 'user-1', - keyType: 'workspace', limit: 100, remaining: 99, resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 0, } - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, -} - const SHARE = { id: 'shr_1', token: 'existing-token-abcd', @@ -63,209 +96,177 @@ const SHARE = { hasPassword: false, allowedEmails: [] as string[], } +const context = { params: Promise.resolve({ fileId: FILE_ID }) } -const ctx = { params: Promise.resolve({ fileId: FILE_ID }) } - -const callGet = (query: string) => - GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/share?${query}`), ctx) +function callGet(query = `workspaceId=${WORKSPACE_ID}`) { + return GET( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/share?${query}`, { + headers: { 'x-api-key': 'key' }, + }), + context + ) +} -const callPut = (body: unknown) => - PUT( +function callPut(body: unknown) { + return PUT( new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/share`, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'x-api-key': 'key' }, body: JSON.stringify(body), }), - ctx + context ) +} describe('GET /api/v2/files/[fileId]/share', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformGetShare.mockResolvedValue({ success: true, share: SHARE }) + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.gate.mockResolvedValue(null) + mocks.getShare.mockResolvedValue({ share: SHARE }) + }) + + it('authenticates and rate-limits before parsing or executing', async () => { + mocks.authenticate.mockRejectedValueOnce( + new MockV2ApiKeyUnauthenticatedError('API key required') + ) + + const response = await callGet() + + expect(response.status).toBe(401) + expect(mocks.getShare).not.toHaveBeenCalled() + expect(mocks.operationRate).not.toHaveBeenCalled() }) it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + mocks.gate.mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - const res = await callGet(`workspaceId=${WS}`) + const response = await callGet() - expect(res.status).toBe(404) - expect(mockPerformGetShare).not.toHaveBeenCalled() + expect(response.status).toBe(404) + expect(mocks.getShare).not.toHaveBeenCalled() }) - it('400s when workspaceId is missing', async () => { - const res = await callGet('') - expect(res.status).toBe(400) - expect(mockPerformGetShare).not.toHaveBeenCalled() + it('validates the asserted workspace before executing the use case', async () => { + const response = await callGet('') + + expect(response.status).toBe(400) + expect(mocks.getShare).not.toHaveBeenCalled() }) - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', + it('conceals authorization failures as not found', async () => { + mocks.getShare.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Access denied')) + + const response = await callGet() + const body = await response.json() + + expect(response.status).toBe(404) + expect(body.error.code).toBe('NOT_FOUND') + expect(mocks.getShare).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, + request: expect.anything(), }) - const res = await callGet(`workspaceId=${WS}`) - expect(res.status).toBe(403) - expect(mockPerformGetShare).not.toHaveBeenCalled() }) - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callGet(`workspaceId=${WS}`) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') + it('returns the share through the v2 envelope', async () => { + const response = await callGet() + + expect(response.status).toBe(200) + expect((await response.json()).data).toEqual({ share: SHARE }) }) - it('reads at workspace read level and returns the share', async () => { - const res = await callGet(`workspaceId=${WS}`) - const body = await res.json() + it('returns the rate-limit response when denied', async () => { + mocks.operationRate.mockResolvedValueOnce({ ...RATE_LIMIT_OK, allowed: false, remaining: 0 }) - expect(res.status).toBe(200) - expect(body.data).toEqual({ share: SHARE }) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(expect.anything(), 'user-1', WS, 'read') - expect(mockPerformGetShare).toHaveBeenCalledWith({ workspaceId: WS, fileId: FILE_ID }) - }) + const response = await callGet() - it('returns a null share for a file that was never shared', async () => { - mockPerformGetShare.mockResolvedValue({ success: true, share: null }) - const res = await callGet(`workspaceId=${WS}`) - expect((await res.json()).data).toEqual({ share: null }) + expect(response.status).toBe(429) + expect((await response.json()).error.code).toBe('RATE_LIMITED') + expect(mocks.getShare).not.toHaveBeenCalled() }) }) describe('PUT /api/v2/files/[fileId]/share', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformUpsert.mockResolvedValue({ success: true, share: SHARE }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callPut({ workspaceId: WS, isActive: true }) - - expect(res.status).toBe(404) - expect(mockPerformUpsert).not.toHaveBeenCalled() - }) - - it('400s when isActive is missing', async () => { - const res = await callPut({ workspaceId: WS }) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformUpsert).not.toHaveBeenCalled() + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.gate.mockResolvedValue(null) + mocks.updateShare.mockResolvedValue({ share: SHARE }) }) - it('rejects a caller-supplied token instead of minting a predictable URL', async () => { - const res = await callPut({ - workspaceId: WS, + it('rejects a caller-supplied token at the v2 boundary', async () => { + const response = await callPut({ + workspaceId: WORKSPACE_ID, isActive: true, token: 'attacker-chosen-token', }) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformUpsert).not.toHaveBeenCalled() + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.updateShare).not.toHaveBeenCalled() }) - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - const res = await callPut({ workspaceId: WS, isActive: true }) - expect(res.status).toBe(403) - expect(mockPerformUpsert).not.toHaveBeenCalled() - }) + it('renders typed validation failures in the v2 envelope', async () => { + mocks.updateShare.mockRejectedValueOnce( + new OrchestrationError('validation', 'Password is required for password-protected shares') + ) - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callPut({ workspaceId: WS, isActive: true }) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') + const response = await callPut({ workspaceId: WORKSPACE_ID, isActive: true }) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body.error).toEqual({ + code: 'BAD_REQUEST', + message: 'Password is required for password-protected shares', + }) }) - it('enables the share at workspace write level and never forwards a token', async () => { - const res = await callPut({ - workspaceId: WS, + it('passes the shared principal and canonical workspace assertion to the use case', async () => { + const response = await callPut({ + workspaceId: WORKSPACE_ID, isActive: true, authType: 'password', password: 'hunter2hunter2', }) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data).toEqual({ share: SHARE }) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( - expect.anything(), - 'user-1', - WS, - 'write' - ) - expect(mockPerformUpsert).toHaveBeenCalledWith({ - workspaceId: WS, - fileId: FILE_ID, - userId: 'user-1', - isActive: true, - authType: 'password', - password: 'hunter2hunter2', - allowedEmails: undefined, + + expect(response.status).toBe(200) + expect((await response.json()).data).toEqual({ share: SHARE }) + expect(mocks.updateShare).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + fileId: FILE_ID, + assertedWorkspaceId: WORKSPACE_ID, + isActive: true, + authType: 'password', + password: 'hunter2hunter2', + allowedEmails: undefined, + }, request: expect.anything(), }) - expect(mockPerformUpsert.mock.calls[0][0]).not.toHaveProperty('token') }) - it('preserves the existing token on a bare re-enable', async () => { - const res = await callPut({ workspaceId: WS, isActive: true }) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data.share.token).toBe('existing-token-abcd') - expect(body.data.share.url).toBe('https://www.sim.ai/f/existing-token-abcd') - // No authType either: the orchestration resolves the stored one, so the - // access-control gate is evaluated against the real mode, not 'public'. - expect(mockPerformUpsert).toHaveBeenCalledWith( - expect.objectContaining({ isActive: true, authType: undefined }) - ) - }) - - it('maps a forbidden errorCode from the access-control policy to 403', async () => { - mockPerformUpsert.mockResolvedValue({ - success: false, - error: 'Public file sharing is not allowed based on your permission group settings', - errorCode: 'forbidden', - }) + it('conceals forbidden updates as not found', async () => { + mocks.updateShare.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Access denied')) - const res = await callPut({ workspaceId: WS, isActive: true }) - const body = await res.json() + const response = await callPut({ workspaceId: WORKSPACE_ID, isActive: true }) - expect(res.status).toBe(403) - expect(body.error.code).toBe('FORBIDDEN') - expect(body.error.message).toContain('not allowed') + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') }) - it('maps a validation errorCode to 400', async () => { - mockPerformUpsert.mockResolvedValue({ - success: false, - error: 'Password is required for password-protected shares', - errorCode: 'validation', - }) + it('returns the rate-limit response when denied', async () => { + mocks.operationRate.mockResolvedValueOnce({ ...RATE_LIMIT_OK, allowed: false, remaining: 0 }) - const res = await callPut({ workspaceId: WS, isActive: true, authType: 'password' }) + const response = await callPut({ workspaceId: WORKSPACE_ID, isActive: true }) - expect(res.status).toBe(400) - expect((await res.json()).error.message).toBe( - 'Password is required for password-protected shares' - ) + expect(response.status).toBe(429) + expect((await response.json()).error.code).toBe('RATE_LIMITED') + expect(mocks.updateShare).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.ts index 22ff6225615..5b6e5a6a015 100644 --- a/apps/sim/app/api/v2/files/[fileId]/share/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.ts @@ -1,84 +1,43 @@ import { v2GetFileShareContract, v2UpsertFileShareContract } from '@/lib/api/contracts/v2/files' -import { messageForOrchestrationError } from '@/lib/core/orchestration/types' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' import { - performGetWorkspaceFileShare, - performUpsertWorkspaceFileShare, -} from '@/lib/workspace-files/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2Data, v2ErrorForOrchestration, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' + getWorkspaceFileShare, + updateWorkspaceFileShare, +} from '@/lib/workspace-files/application/share-workspace-file' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * GET /api/v2/files/[fileId]/share — Read a file's public share state. - * - * `null` means the file has never been shared. `hasPassword` is the only signal - * carried for a password-gated share; the ciphertext is never exposed. - */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetFileShareContract, - rateLimitEndpoint: 'file-share', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { fileId } = input.params - const { workspaceId } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const result = await performGetWorkspaceFileShare({ workspaceId, fileId }) - - if (!result.success) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to fetch share') - ) - } - - return v2Data({ share: result.share ?? null }, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.readShare, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: getWorkspaceFileShare, + present: ({ share }) => ({ data: { share } }), }) -/** - * PUT /api/v2/files/[fileId]/share — Enable or disable a file's public share. - * - * Requires workspace `write`, matching the UI. The share token is always - * server-generated: the internal surface accepts a caller-supplied one so the UI - * can render a link before saving, but over an API key that would mint - * predictable public URLs and collide with the token unique index. - * - * `isActive: false` disables, it does not revoke — the token and the stored - * password / allow-list survive, so re-enabling resurrects the same URL. - */ -export const PUT = withPublicApiRouteHandler({ +export const PUT = defineV2JsonRoute({ contract: v2UpsertFileShareContract, - rateLimitEndpoint: 'file-share', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - const { fileId } = input.params - const { workspaceId, isActive, authType, password, allowedEmails } = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performUpsertWorkspaceFileShare({ - workspaceId, - fileId, - userId, - isActive, - authType, - password, - allowedEmails, - request, - }) - - if (!result.success || !result.share) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to update share') - ) - } - - return v2Data({ share: result.share }, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.updateShare, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: body.workspaceId, + isActive: body.isActive, + authType: body.authType, + password: body.password, + allowedEmails: body.allowedEmails, + }), + useCase: updateWorkspaceFileShare, + present: ({ share }) => ({ data: { share } }), }) diff --git a/apps/sim/app/api/v2/files/bulk-delete/route.test.ts b/apps/sim/app/api/v2/files/bulk-delete/route.test.ts index 31795501de6..a66f490f6c2 100644 --- a/apps/sim/app/api/v2/files/bulk-delete/route.test.ts +++ b/apps/sim/app/api/v2/files/bulk-delete/route.test.ts @@ -4,51 +4,64 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformDelete } = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockPerformDelete: vi.fn(), +const { mockPreauth, mockOperationRate, mockGate, mockExecute } = vi.hoisted(() => ({ + mockPreauth: vi.fn(), + mockOperationRate: vi.fn(), + mockGate: vi.fn(), + mockExecute: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: vi.fn().mockResolvedValue({ + principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['workspace:workspace-1'], + rateLimitSubscription: null, + keyType: 'workspace', + }), + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mockPreauth + checkRateLimitDirectOrThrow = mockOperationRate + }, + getRateLimit: vi + .fn() + .mockReturnValue({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/lib/workspace-files/orchestration', () => ({ - performDeleteWorkspaceFileItems: mockPerformDelete, +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: vi.fn().mockReturnValue(null), +})) +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('request-1'), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGate })) +vi.mock('@/lib/workspace-files/application/archive-workspace-file-items', () => ({ + archiveWorkspaceFileItemsOperation: { + operation: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mockExecute, + }, })) import { POST } from '@/app/api/v2/files/bulk-delete/route' const WS = 'workspace-1' - const RATE_LIMIT_OK = { allowed: true, - userId: 'user-1', - keyType: 'workspace', limit: 100, remaining: 99, resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, + retryAfterMs: 0, } const callDelete = (body: unknown) => POST( new NextRequest('http://localhost:3000/api/v2/files/bulk-delete', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'x-api-key': 'key' }, body: JSON.stringify(body), }) ) @@ -56,42 +69,36 @@ const callDelete = (body: unknown) => describe('POST /api/v2/files/bulk-delete', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformDelete.mockResolvedValue({ success: true, deletedItems: { files: 3, folders: 1 } }) + mockPreauth.mockResolvedValue(RATE_LIMIT_OK) + mockOperationRate.mockResolvedValue(RATE_LIMIT_OK) + mockGate.mockResolvedValue(null) + mockExecute.mockResolvedValue({ deletedItems: { files: 3, folders: 0 } }) }) it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - + mockGate.mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) - expect(res.status).toBe(404) - expect(mockPerformDelete).not.toHaveBeenCalled() + expect(mockExecute).not.toHaveBeenCalled() }) it('400s when the selection is empty', async () => { const res = await callDelete({ workspaceId: WS, fileIds: [] }) expect(res.status).toBe(400) expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformDelete).not.toHaveBeenCalled() + expect(mockExecute).not.toHaveBeenCalled() }) - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) + it('surfaces a forbidden collection operation', async () => { + const { OrchestrationError } = await import('@/lib/core/orchestration/types') + mockExecute.mockRejectedValue(new OrchestrationError('forbidden', 'Access denied')) const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(403) - expect(mockPerformDelete).not.toHaveBeenCalled() }) it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + mockPreauth.mockResolvedValue({ ...RATE_LIMIT_OK, allowed: false, remaining: 0 }) const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(429) expect((await res.json()).error.code).toBe('RATE_LIMITED') @@ -99,27 +106,17 @@ describe('POST /api/v2/files/bulk-delete', () => { it('deletes the selection and reports the file count', async () => { const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) - const body = await res.json() - expect(res.status).toBe(200) - expect(body.data).toEqual({ deletedItems: { files: 3 } }) - expect(mockPerformDelete).toHaveBeenCalledWith({ - workspaceId: WS, - userId: 'user-1', - fileIds: ['wf_1'], - request: expect.anything(), - }) + expect((await res.json()).data).toEqual({ deletedItems: { files: 3 } }) + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ input: { workspaceId: WS, fileIds: ['wf_1'] } }) + ) }) - it('maps a not_found errorCode to 404', async () => { - mockPerformDelete.mockResolvedValue({ - success: false, - error: 'File not found', - errorCode: 'not_found', - }) - + it('maps a not-found failure to 404', async () => { + const { OrchestrationError } = await import('@/lib/core/orchestration/types') + mockExecute.mockRejectedValue(new OrchestrationError('not_found', 'File not found')) const res = await callDelete({ workspaceId: WS, fileIds: ['wf_missing'] }) - expect(res.status).toBe(404) expect((await res.json()).error.code).toBe('NOT_FOUND') }) diff --git a/apps/sim/app/api/v2/files/bulk-delete/route.ts b/apps/sim/app/api/v2/files/bulk-delete/route.ts index accd962d9dc..35ae13cfe31 100644 --- a/apps/sim/app/api/v2/files/bulk-delete/route.ts +++ b/apps/sim/app/api/v2/files/bulk-delete/route.ts @@ -1,40 +1,19 @@ import { v2BulkDeleteFilesContract } from '@/lib/api/contracts/v2/files' -import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2Data, v2ErrorForOrchestration, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { archiveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/archive-workspace-file-items' +import { fileOperations } from '@/lib/workspace-files/application/operations' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * POST /api/v2/files/bulk-delete — Delete files. Folder deletion is owned by - * `/api/v2/files/folders` so this resource operation never accepts folder ids. - */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2BulkDeleteFilesContract, - rateLimitEndpoint: 'file-bulk-delete', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - const { workspaceId, fileIds } = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performDeleteWorkspaceFileItems({ - workspaceId, - userId, - fileIds, - request, - }) - - if (!result.success || !result.deletedItems) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to delete files') - ) - } - - return v2Data({ deletedItems: { files: result.deletedItems.files } }, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.delete, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ body }) => ({ workspaceId: body.workspaceId, fileIds: body.fileIds }), + useCase: archiveWorkspaceFileItemsOperation, + present: ({ deletedItems }) => ({ data: { deletedItems: { files: deletedItems.files } } }), }) diff --git a/apps/sim/app/api/v2/files/folders/route.test.ts b/apps/sim/app/api/v2/files/folders/route.test.ts new file mode 100644 index 00000000000..f255960ce04 --- /dev/null +++ b/apps/sim/app/api/v2/files/folders/route.test.ts @@ -0,0 +1,223 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { + class MockV2ApiKeyUnauthenticatedError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + listFolders: vi.fn(), + createFolder: vi.fn(), + updateFolder: vi.fn(), + deleteFolder: vi.fn(), + }, + MockV2ApiKeyUnauthenticatedError, + } +}) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, +})) +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: vi.fn().mockReturnValue({ + maxTokens: 100, + refillRate: 100, + refillIntervalMs: 60_000, + }), +})) +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: vi.fn().mockReturnValue(null), +})) +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('request-1'), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ + listWorkspaceFileFoldersOperation: { + operation: { id: 'files.folders.list', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.listFolders, + }, + createWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.create', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.createFolder, + }, + updateWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.update', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.updateFolder, + }, + deleteWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.deleteFolder, + }, +})) + +import { DELETE, GET, PATCH, POST } from '@/app/api/v2/files/folders/route' + +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' } +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['workspace:workspace-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const RATE_LIMIT_OK = { + allowed: true, + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 0, +} +const folder = { + id: 'folder-1', + workspaceId: WORKSPACE_ID, + userId: 'owner-1', + name: 'Reports', + parentId: null, + path: '/Reports', + sortOrder: 0, + deletedAt: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), +} +const context = undefined + +function request(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', url: string, body?: unknown) { + return new NextRequest(`http://localhost:3000${url}`, { + method, + headers: { + 'x-api-key': 'key', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) +} + +describe('/api/v2/files/folders', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.gate.mockResolvedValue(null) + mocks.listFolders.mockResolvedValue({ folders: [folder] }) + mocks.createFolder.mockResolvedValue({ folder }) + mocks.updateFolder.mockResolvedValue({ folder }) + mocks.deleteFolder.mockResolvedValue({ + deletedItems: { folders: 1, files: 2 }, + path: '/Reports', + }) + }) + + it('lists folders through the shared operation and v2 presenter', async () => { + const response = await GET( + request('GET', `/api/v2/files/folders?workspaceId=${WORKSPACE_ID}`), + context + ) + + expect(response.status).toBe(200) + expect((await response.json()).data).toEqual([ + { + name: 'Reports', + path: '/Reports', + parentPath: '/', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ]) + expect(mocks.listFolders).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + parentPath: undefined, + search: undefined, + sortBy: 'name', + sortOrder: 'asc', + }, + request: expect.anything(), + }) + }) + + it('creates a folder from its canonical path', async () => { + const response = await POST( + request('POST', '/api/v2/files/folders', { workspaceId: WORKSPACE_ID, path: '/Reports' }), + context + ) + + expect(response.status).toBe(200) + expect((await response.json()).data.folder).toEqual({ + name: 'Reports', + path: '/Reports', + parentPath: '/', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }) + expect(mocks.createFolder).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, path: '/Reports' }, + request: expect.anything(), + }) + }) + + it('relocates a folder through the shared operation', async () => { + const response = await PATCH( + request('PATCH', '/api/v2/files/folders', { + workspaceId: WORKSPACE_ID, + path: '/Reports', + destinationPath: '/Archive/Reports', + }), + context + ) + + expect(response.status).toBe(200) + expect(mocks.updateFolder).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + path: '/Reports', + destinationPath: '/Archive/Reports', + }, + request: expect.anything(), + }) + }) + + it('deletes a folder and returns the v2 deletion result', async () => { + const response = await DELETE( + request( + 'DELETE', + `/api/v2/files/folders?workspaceId=${WORKSPACE_ID}&path=%2FReports&recursive=true` + ), + context + ) + + expect(response.status).toBe(200) + expect((await response.json()).data).toEqual({ + path: '/Reports', + deleted: true, + deletedItems: { folders: 1, files: 2 }, + }) + }) + + it('authenticates before parsing folder input', async () => { + mocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await POST(request('POST', '/api/v2/files/folders', {}), context) + + expect(response.status).toBe(401) + expect(mocks.createFolder).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/files/folders/route.ts b/apps/sim/app/api/v2/files/folders/route.ts index 7ed1d0b4aee..1bbcbe91fd4 100644 --- a/apps/sim/app/api/v2/files/folders/route.ts +++ b/apps/sim/app/api/v2/files/folders/route.ts @@ -4,109 +4,91 @@ import { v2ListFileFoldersContract, v2RelocateFileFolderContract, } from '@/lib/api/contracts/v2/files' -import { toFolderPathView } from '@/lib/folders/paths' -import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' import { - performCreateWorkspaceFileFolderAtPath, - performDeleteWorkspaceFileFolderByPath, - performRelocateWorkspaceFileFolderByPath, -} from '@/lib/workspace-files/orchestration/file-folder-lifecycle' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { - resolveFolderPathId, - toV2PathFolder, - v2FolderPathMutationError, -} from '@/app/api/v2/lib/folders' -import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' + createWorkspaceFileFolderOperation, + deleteWorkspaceFileFolderOperation, + listWorkspaceFileFoldersOperation, + updateWorkspaceFileFolderOperation, +} from '@/lib/workspace-files/application/workspace-file-folders' export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = withPublicApiRouteHandler({ - contract: v2ListFileFoldersContract, - rateLimitEndpoint: 'files', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, parentPath, search, sortBy, sortOrder } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) +function toV2Folder(folder: { name: string; path: string; createdAt: Date; updatedAt: Date }) { + const path = folder.path.startsWith('/') ? folder.path : `/${folder.path}` + const parentPath = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) || '/' : '/' + return { + name: folder.name, + path, + parentPath, + createdAt: folder.createdAt.toISOString(), + updatedAt: folder.updatedAt.toISOString(), + } +} - const index = await loadActiveFolderPathIndex(workspaceId, 'file') - const parentId = parentPath === undefined ? undefined : resolveFolderPathId(index, parentPath) - if (parentPath !== undefined && parentId === undefined) { - return v2Error('NOT_FOUND', 'Folder not found') - } - const rows = await listActiveFolderRows(workspaceId, 'file', { - parentId, - search, - sortBy, - sortOrder, - }) - return v2CursorList( - rows.map((row) => toV2PathFolder(row, index, false)), - null, - { rateLimit } - ) - }, +export const GET = defineV2JsonRoute({ + contract: v2ListFileFoldersContract, + auth: v2ApiKeyAuth, + operation: fileOperations.listFolders, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + parentPath: query.parentPath, + search: query.search, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + }), + useCase: listWorkspaceFileFoldersOperation, + present: ({ folders }) => ({ data: folders.map(toV2Folder), nextCursor: null }), }) -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateFileFolderContract, - rateLimitEndpoint: 'files', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, path } = input.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await performCreateWorkspaceFileFolderAtPath({ workspaceId, userId, path }) - if (!result.success || !result.folder || !result.path) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') - } - return v2Data( - { folder: toFolderPathView(result.folder, result.path) }, - { rateLimit, status: 201 } - ) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.createFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ body }) => ({ workspaceId: body.workspaceId, path: body.path }), + useCase: createWorkspaceFileFolderOperation, + present: ({ folder }) => ({ data: { folder: toV2Folder(folder) } }), }) -export const PATCH = withPublicApiRouteHandler({ +export const PATCH = defineV2JsonRoute({ contract: v2RelocateFileFolderContract, - rateLimitEndpoint: 'files', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, path, destinationPath } = input.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await performRelocateWorkspaceFileFolderByPath({ - workspaceId, - userId, - path, - destinationPath, - }) - if (!result.success || !result.folder || !result.path) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') - } - return v2Data({ folder: toFolderPathView(result.folder, result.path) }, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.updateFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + path: body.path, + destinationPath: body.destinationPath, + }), + useCase: updateWorkspaceFileFolderOperation, + present: ({ folder }) => ({ data: { folder: toV2Folder(folder) } }), }) -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteFileFolderContract, - rateLimitEndpoint: 'files', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, path, recursive } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await performDeleteWorkspaceFileFolderByPath({ - workspaceId, - userId, - path, - recursive, - }) - if (!result.success || !result.deletedItems) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') - } - return v2Data( - { path, deleted: true as const, deletedItems: result.deletedItems }, - { rateLimit } - ) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.deleteFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + path: query.path, + recursive: query.recursive, + }), + useCase: deleteWorkspaceFileFolderOperation, + present: ({ deletedItems, path }) => ({ + data: { + path: path ?? '/', + deleted: true as const, + deletedItems, + }, + }), }) diff --git a/apps/sim/app/api/v2/files/move/route.test.ts b/apps/sim/app/api/v2/files/move/route.test.ts index b6651476b8f..d192bf356b1 100644 --- a/apps/sim/app/api/v2/files/move/route.test.ts +++ b/apps/sim/app/api/v2/files/move/route.test.ts @@ -4,51 +4,65 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformMove } = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockPerformMove: vi.fn(), +const { mockPreauth, mockOperationRate, mockGate, mockExecute } = vi.hoisted(() => ({ + mockPreauth: vi.fn(), + mockOperationRate: vi.fn(), + mockGate: vi.fn(), + mockExecute: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: vi.fn().mockResolvedValue({ + principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['workspace:workspace-1'], + rateLimitSubscription: null, + keyType: 'workspace', + }), + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mockPreauth + checkRateLimitDirectOrThrow = mockOperationRate + }, + getRateLimit: vi + .fn() + .mockReturnValue({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/lib/workspace-files/orchestration', () => ({ - performMoveWorkspaceFileItems: mockPerformMove, +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: vi.fn().mockReturnValue(null), +})) +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('request-1'), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGate })) +vi.mock('@/lib/workspace-files/application/move-workspace-file-items', () => ({ + moveWorkspaceFileItemsOperation: { + operation: { id: 'files.move', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mockExecute, + }, })) import { POST } from '@/app/api/v2/files/move/route' const WS = 'workspace-1' - const RATE_LIMIT_OK = { allowed: true, - userId: 'user-1', - keyType: 'workspace', limit: 100, remaining: 99, resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 0, } - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, -} +const RATE_LIMIT_DENIED = { ...RATE_LIMIT_OK, allowed: false, remaining: 0, retryAfterMs: 1000 } const callMove = (body: unknown) => POST( new NextRequest('http://localhost:3000/api/v2/files/move', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'x-api-key': 'key' }, body: JSON.stringify(body), }) ) @@ -56,42 +70,37 @@ const callMove = (body: unknown) => describe('POST /api/v2/files/move', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformMove.mockResolvedValue({ success: true, movedItems: { files: 2, folders: 0 } }) + mockPreauth.mockResolvedValue(RATE_LIMIT_OK) + mockOperationRate.mockResolvedValue(RATE_LIMIT_OK) + mockGate.mockResolvedValue(null) + mockExecute.mockResolvedValue({ movedItems: { files: 2, folders: 0 } }) }) it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - + mockGate.mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) - expect(res.status).toBe(404) - expect(mockPerformMove).not.toHaveBeenCalled() + expect(mockExecute).not.toHaveBeenCalled() }) it('400s when the selection is empty', async () => { const res = await callMove({ workspaceId: WS }) expect(res.status).toBe(400) expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformMove).not.toHaveBeenCalled() + expect(mockExecute).not.toHaveBeenCalled() }) - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) + it('surfaces a forbidden collection operation', async () => { + const { OrchestrationError } = await import('@/lib/core/orchestration/types') + mockExecute.mockRejectedValue(new OrchestrationError('forbidden', 'Access denied')) const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(403) - expect(mockPerformMove).not.toHaveBeenCalled() + expect(mockExecute).toHaveBeenCalledOnce() }) it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + mockPreauth.mockResolvedValue(RATE_LIMIT_DENIED) const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(429) expect((await res.json()).error.code).toBe('RATE_LIMITED') @@ -103,40 +112,27 @@ describe('POST /api/v2/files/move', () => { fileIds: ['wf_1', 'wf_2'], targetFolderPath: '/Reports', }) - const body = await res.json() - expect(res.status).toBe(200) - expect(body.data).toEqual({ movedItems: { files: 2 } }) - expect(mockPerformMove).toHaveBeenCalledWith({ - workspaceId: WS, - userId: 'user-1', - fileIds: ['wf_1', 'wf_2'], - targetFolderPath: '/Reports', - }) + expect((await res.json()).data).toEqual({ movedItems: { files: 2 } }) + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ + input: { workspaceId: WS, fileIds: ['wf_1', 'wf_2'], targetFolderPath: '/Reports' }, + }) + ) }) it('treats an omitted targetFolderPath as the workspace root', async () => { await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) - expect(mockPerformMove).toHaveBeenCalledWith( - expect.objectContaining({ fileIds: ['wf_1'], targetFolderPath: '/' }) + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ targetFolderPath: '/' }) }) ) }) - it('maps a conflict errorCode to 409 without partially applying', async () => { - mockPerformMove.mockResolvedValue({ - success: false, - error: 'A file named "data.csv" already exists in the destination folder', - errorCode: 'conflict', - }) - - const res = await callMove({ - workspaceId: WS, - fileIds: ['wf_1'], - targetFolderPath: '/Reports', - }) - const body = await res.json() - + it('maps a conflict error to 409', async () => { + const { OrchestrationError } = await import('@/lib/core/orchestration/types') + mockExecute.mockRejectedValue(new OrchestrationError('conflict', 'Name collision')) + const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(409) - expect(body.error.code).toBe('CONFLICT') + expect((await res.json()).error.code).toBe('CONFLICT') }) }) diff --git a/apps/sim/app/api/v2/files/move/route.ts b/apps/sim/app/api/v2/files/move/route.ts index 9d306d1269b..2efa115b366 100644 --- a/apps/sim/app/api/v2/files/move/route.ts +++ b/apps/sim/app/api/v2/files/move/route.ts @@ -1,44 +1,23 @@ import { v2MoveFileItemsContract } from '@/lib/api/contracts/v2/files' -import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2Data, v2ErrorForOrchestration, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' +import { fileOperations } from '@/lib/workspace-files/application/operations' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * POST /api/v2/files/move — Move files into a folder. - * - * An omitted `targetFolderPath` moves the selection to the - * workspace root. The whole selection moves under one advisory lock, so a name - * collision at the destination fails the request as `CONFLICT` rather than - * partially applying. - */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2MoveFileItemsContract, - rateLimitEndpoint: 'file-move', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, fileIds, targetFolderPath } = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performMoveWorkspaceFileItems({ - workspaceId, - userId, - fileIds, - targetFolderPath: targetFolderPath ?? '/', - }) - - if (!result.success || !result.movedItems) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to move file items') - ) - } - - return v2Data({ movedItems: { files: result.movedItems.files } }, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.move, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + fileIds: body.fileIds, + targetFolderPath: body.targetFolderPath ?? '/', + }), + useCase: moveWorkspaceFileItemsOperation, + present: ({ movedItems }) => ({ data: { movedItems: { files: movedItems.files } } }), }) diff --git a/apps/sim/app/api/v2/files/route.test.ts b/apps/sim/app/api/v2/files/route.test.ts index 7bf7aa9f384..4b6508d5c20 100644 --- a/apps/sim/app/api/v2/files/route.test.ts +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -4,521 +4,220 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockPerformCreateWorkspaceFile, - mockQueryWorkspaceFiles, - mockResolveWorkspaceAccess, - mockV2ApiGateError, - mockLoadActiveFolderPathIndex, - mockGetUserEmailsByIds, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockPerformCreateWorkspaceFile: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockQueryWorkspaceFiles: vi.fn(), - mockV2ApiGateError: vi.fn().mockResolvedValue(null), - mockLoadActiveFolderPathIndex: vi.fn(), - mockGetUserEmailsByIds: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticateV2ApiKey: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + createFile: vi.fn(), + queryFiles: vi.fn(), + getUserEmailsByIds: vi.fn(), + gate: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/workspace-files/application/create-workspace-file', () => ({ + createWorkspaceFile: { + operation: { id: 'files.create', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.createFile, + }, })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: mockV2ApiGateError, +vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ + queryWorkspaceFilePage: { + operation: { id: 'files.list', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.queryFiles, + }, })) -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - queryWorkspaceFiles: mockQueryWorkspaceFiles, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/lib/folders/queries', () => ({ - loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkRateLimitDirect + checkRateLimitDirectOrThrow = mocks.checkRateLimitDirectOrThrow + }, })) -vi.mock('@/lib/workspace-files/orchestration', () => ({ - MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, - performCreateWorkspaceFile: mockPerformCreateWorkspaceFile, -})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) vi.mock('@/lib/users/queries', () => ({ - getUserEmailsByIds: mockGetUserEmailsByIds, + getUserEmailsByIds: mocks.getUserEmailsByIds, requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, })) import { OrchestrationError } from '@/lib/core/orchestration/types' -import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' import { GET, POST } from '@/app/api/v2/files/route' -const WS = 'workspace-1' -const FOLDER_ID = 'fold_1' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, +const WORKSPACE_ID = 'workspace-1' +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } - -function buildRecord(overrides: Record = {}) { - return { - id: 'wf_1', - workspaceId: WS, - name: 'data.csv', - key: 'workspace/ws/1-x-data.csv', - path: '/api/files/serve/x', - size: 1024, - type: 'text/csv', - uploadedBy: 'user-1', - folderId: null, - folderPath: null, - uploadedAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - ...overrides, - } -} - -/** What the route forwards for a bare `?workspaceId=` list. */ -const DEFAULT_LIST_ARGS = { - folderId: undefined, - search: undefined, - sortBy: 'uploadedAt', - sortOrder: 'asc', - limit: 100, - after: undefined, +const FILE = { + id: 'wf_1', + workspaceId: WORKSPACE_ID, + name: 'notes.md', + key: `workspace/${WORKSPACE_ID}/notes.md`, + path: '/api/files/serve/notes.md?context=workspace', + size: 0, + type: 'text/markdown', + uploadedBy: 'user-1', + folderId: null, + folderPath: null, + uploadedAt: new Date('2026-08-04T00:00:00.000Z'), + updatedAt: new Date('2026-08-05T00:00:00.000Z'), } -const callList = (query: string) => - GET(new NextRequest(`http://localhost:3000/api/v2/files?${query}`)) - -function createRequest(body: Record) { +function createRequest(body: unknown): NextRequest { return new NextRequest('http://localhost:3000/api/v2/files', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: typeof body === 'string' ? body : JSON.stringify(body), }) } -describe('GET /api/v2/files', () => { +describe('/api/v2/files', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockQueryWorkspaceFiles.mockResolvedValue({ files: [buildRecord()], nextKeys: null }) - mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) - mockLoadActiveFolderPathIndex.mockResolvedValue({ - rowById: new Map([['fold_1', { id: 'fold_1', name: 'Reports', parentId: null }]]), - pathById: new Map([['fold_1', '/Reports']]), - idByPath: new Map([ - ['/Reports', 'fold_1'], - ['/Fixtures', 'fold_1'], - ]), + mocks.authenticateV2ApiKey.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-04T01:00:00.000Z'), }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callList(`workspaceId=${WS}`) - - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callList('limit=10') - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() - }) - - it('400s on a scope outside the enum', async () => { - const res = await callList(`workspaceId=${WS}&scope=everything`) - expect(res.status).toBe(400) - expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-04T01:00:00.000Z'), }) - const res = await callList(`workspaceId=${WS}`) - expect(res.status).toBe(403) - expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callList(`workspaceId=${WS}`) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('returns the public file shape including folder and updatedAt', async () => { - mockQueryWorkspaceFiles.mockResolvedValue({ - files: [buildRecord({ folderId: FOLDER_ID, folderPath: 'Reports/Q1' })], - nextKeys: null, + mocks.queryFiles.mockResolvedValue({ + files: [FILE], + nextKeys: undefined, + cursorSort: 'name:asc', }) - - const res = await callList(`workspaceId=${WS}`) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.nextCursor).toBeNull() - expect(body.data).toEqual([ - { - id: 'wf_1', - name: 'data.csv', - size: 1024, - type: 'text/csv', - key: 'workspace/ws/1-x-data.csv', - folderPath: '/Reports/Q1', - uploadedByEmail: 'ada@example.com', - uploadedAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - }, - ]) - expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, DEFAULT_LIST_ARGS) + mocks.createFile.mockResolvedValue({ file: FILE }) + mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) }) - it('lists active files only and rejects the removed archived scope', async () => { - await callList(`workspaceId=${WS}`) - expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, DEFAULT_LIST_ARGS) + it('authenticates and charges before validating list input', async () => { + const response = await GET(new NextRequest('http://localhost:3000/api/v2/files')) - const res = await callList(`workspaceId=${WS}&scope=archived`) - expect(res.status).toBe(400) + expect(response.status).toBe(400) + expect(mocks.authenticateV2ApiKey).toHaveBeenCalled() + expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledTimes(2) + expect(mocks.queryFiles).not.toHaveBeenCalled() }) - it('forwards search, folder, and sort into the query rather than filtering the result', async () => { - await callList( - `workspaceId=${WS}&search=report&folderPath=${encodeURIComponent('/Reports')}&sortBy=name&sortOrder=desc` + it('lists through the shared use case and v2 presenter', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}&sortBy=name` ) - - expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, { - ...DEFAULT_LIST_ARGS, - folderId: FOLDER_ID, - search: 'report', - sortBy: 'name', - sortOrder: 'desc', - }) - }) - - it('treats folderPath=/ as root-only while omission lists every folder', async () => { - await callList(`workspaceId=${WS}&folderPath=%2F`) - - expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, { - ...DEFAULT_LIST_ARGS, - folderId: null, - }) - }) - - it('400s on a sort field outside the enum instead of passing it toward the query', async () => { - const res = await callList(`workspaceId=${WS}&sortBy=name;DROP TABLE workspace_files`) - - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() - }) - - it('400s on an empty search rather than treating it as unsearched', async () => { - const res = await callList(`workspaceId=${WS}&search=`) - - expect(res.status).toBe(400) - expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() - }) - - it('emits a cursor stamped with the sort and resumes from its keys', async () => { - mockQueryWorkspaceFiles.mockResolvedValue({ - files: [buildRecord()], - nextKeys: ['data.csv', 'wf_1'], - }) - - const first = await callList(`workspaceId=${WS}&sortBy=name`) - const { nextCursor } = await first.json() - expect(nextCursor).not.toBeNull() - - await callList(`workspaceId=${WS}&sortBy=name&cursor=${encodeURIComponent(nextCursor)}`) - - expect(mockQueryWorkspaceFiles).toHaveBeenLastCalledWith(WS, { - ...DEFAULT_LIST_ARGS, - sortBy: 'name', - after: ['data.csv', 'wf_1'], - }) - }) - - it('400s when a cursor is replayed under a different sort', async () => { - mockQueryWorkspaceFiles.mockResolvedValue({ - files: [buildRecord()], - nextKeys: ['data.csv', 'wf_1'], + const response = await GET(request) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: [ + { + id: FILE.id, + name: 'notes.md', + size: 0, + type: 'text/markdown', + key: FILE.key, + folderPath: '/', + uploadedByEmail: 'ada@example.com', + uploadedAt: '2026-08-04T00:00:00.000Z', + updatedAt: '2026-08-05T00:00:00.000Z', + }, + ], + nextCursor: null, + }) + expect(mocks.queryFiles).toHaveBeenCalledWith({ + principal: auth.principal, + input: expect.objectContaining({ + workspaceId: WORKSPACE_ID, + sortBy: 'name', + sortOrder: 'asc', + limit: 100, + }), + request, }) - - const first = await callList(`workspaceId=${WS}&sortBy=name`) - const { nextCursor } = await first.json() - mockQueryWorkspaceFiles.mockClear() - - const res = await callList( - `workspaceId=${WS}&sortBy=size&cursor=${encodeURIComponent(nextCursor)}` - ) - - expect(res.status).toBe(400) - expect((await res.json()).error.message).toMatch(/cursor does not match/i) - expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() - }) - - it('400s on a malformed cursor instead of silently restarting from page one', async () => { - const res = await callList(`workspaceId=${WS}&cursor=not-a-cursor`) - - expect(res.status).toBe(400) - expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() }) - it('400s when the cursor carries values the sort cannot hold', async () => { - mockQueryWorkspaceFiles.mockRejectedValue( - new OrchestrationError('validation', 'cursor does not match the requested sortBy/sortOrder.') + it('rejects malformed cursors before the application service', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}&cursor=not-a-cursor` + ) ) - const cursor = Buffer.from( - JSON.stringify({ sort: 'uploadedAt:asc', keys: ['not-a-date', 'wf_1'] }) - ).toString('base64') - - const res = await callList(`workspaceId=${WS}&cursor=${encodeURIComponent(cursor)}`) - - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - }) - - it('terminates pagination when the query reports no further keys', async () => { - mockQueryWorkspaceFiles.mockResolvedValue({ files: [buildRecord()], nextKeys: null }) - - const res = await callList(`workspaceId=${WS}&search=data`) - - expect((await res.json()).nextCursor).toBeNull() - }) -}) - -describe('POST /api/v2/files', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockV2ApiGateError.mockResolvedValue(null) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformCreateWorkspaceFile.mockResolvedValue({ - success: true, - file: buildRecord({ name: 'untitled.md', size: 0, type: 'text/markdown' }), - }) - mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) - }) - - it('creates an empty exact-name file with an inferred MIME type', async () => { - const request = createRequest({ workspaceId: WS, name: 'untitled.md' }) - - const response = await POST(request) - expect(response.status).toBe(201) - await expect(response.json()).resolves.toMatchObject({ - data: { id: 'wf_1', name: 'untitled.md', size: 0, type: 'text/markdown' }, - }) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(RATE_LIMIT_OK, 'user-1', WS, 'write') - expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith({ - workspaceId: WS, - userId: 'user-1', - name: 'untitled.md', - contentType: 'text/markdown', - folderPath: '/', - content: Buffer.alloc(0), - exactName: true, - request, - }) + expect(response.status).toBe(400) + expect(mocks.queryFiles).not.toHaveBeenCalled() }) - it('decodes initialized base64 content before orchestration', async () => { - mockPerformCreateWorkspaceFile.mockResolvedValue({ - success: true, - file: buildRecord({ - name: 'seed.bin', - size: 3, - type: 'application/octet-stream', - folderId: FOLDER_ID, - folderPath: 'Fixtures', - }), - }) + it('creates through the workspace-key principal without human analytics', async () => { const request = createRequest({ - workspaceId: WS, - name: 'seed.bin', - contentType: 'application/octet-stream', - folderPath: '/Fixtures', - content: Buffer.from([1, 2, 3]).toString('base64'), + workspaceId: WORKSPACE_ID, + name: 'notes.md', + content: 'TQ==', encoding: 'base64', }) - const response = await POST(request) expect(response.status).toBe(201) - expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: WS, - name: 'seed.bin', - contentType: 'application/octet-stream', - folderPath: '/Fixtures', - content: Buffer.from([1, 2, 3]), + expect((await response.json()).data.name).toBe('notes.md') + expect(mocks.createFile).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + workspaceId: WORKSPACE_ID, + name: 'notes.md', + contentType: 'text/markdown', + content: 'TQ==', + encoding: 'base64', + folderPath: '/', exactName: true, - }) - ) + }, + request, + }) }) - it('rejects malformed base64 before workspace access or orchestration', async () => { + it('rejects malformed base64 after authentication and rate limiting', async () => { const response = await POST( createRequest({ - workspaceId: WS, - name: 'seed.bin', + workspaceId: WORKSPACE_ID, + name: 'notes.md', content: 'not-base64!', encoding: 'base64', }) ) expect(response.status).toBe(400) - await expect(response.json()).resolves.toMatchObject({ - error: { code: 'BAD_REQUEST' }, - }) - expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + expect(mocks.authenticateV2ApiKey).toHaveBeenCalled() + expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledTimes(2) + expect(mocks.createFile).not.toHaveBeenCalled() }) - it('accepts empty base64 as a zero-byte file', async () => { - const response = await POST( - createRequest({ workspaceId: WS, name: 'empty.bin', content: '', encoding: 'base64' }) - ) - - expect(response.status).toBe(201) - expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith( - expect.objectContaining({ content: Buffer.alloc(0) }) - ) - }) + it('renders typed conflicts and hides unknown errors', async () => { + mocks.createFile.mockRejectedValueOnce(new OrchestrationError('conflict', 'Name exists')) + const conflict = await POST(createRequest({ workspaceId: WORKSPACE_ID, name: 'notes.md' })) + expect(conflict.status).toBe(409) + expect((await conflict.json()).error.code).toBe('CONFLICT') - it('returns the canonical v2 envelope when the JSON body exceeds the inline limit', async () => { - const request = new NextRequest('http://localhost:3000/api/v2/files', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': String(MAX_WORKSPACE_FILE_INLINE_BODY_BYTES + 1), - }, - body: '{}', + mocks.createFile.mockRejectedValueOnce(new Error('database details')) + const unexpected = await POST(createRequest({ workspaceId: WORKSPACE_ID, name: 'notes.md' })) + expect(unexpected.status).toBe(500) + expect(await unexpected.json()).toMatchObject({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, }) - - const response = await POST(request) - - expect(response.status).toBe(413) - await expect(response.json()).resolves.toMatchObject({ - error: { code: 'PAYLOAD_TOO_LARGE', message: 'Request body is too large' }, - }) - expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() - }) - - it('returns the canonical v2 envelope for malformed JSON', async () => { - const request = new NextRequest('http://localhost:3000/api/v2/files', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: '{not-json', - }) - - const response = await POST(request) - - expect(response.status).toBe(400) - await expect(response.json()).resolves.toMatchObject({ - error: { code: 'BAD_REQUEST', message: 'Request body must be valid JSON' }, - }) - expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() - }) - - it.each([ - { - label: 'name conflict', - result: { - success: false, - error: 'A file with this name already exists', - errorCode: 'conflict', - }, - status: 409, - code: 'CONFLICT', - message: 'A file with this name already exists', - }, - { - label: 'internal orchestration failure', - result: { success: false, error: 'database connection details', errorCode: 'internal' }, - status: 500, - code: 'INTERNAL_ERROR', - message: 'Internal server error', - }, - ])('maps a $label into the v2 error envelope', async ({ result, status, code, message }) => { - mockPerformCreateWorkspaceFile.mockResolvedValue(result) - - const response = await POST(createRequest({ workspaceId: WS, name: 'untitled.md' })) - - expect(response.status).toBe(status) - await expect(response.json()).resolves.toMatchObject({ error: { code, message } }) - }) - - it('returns the auth failure before gating, access checks, or orchestration', async () => { - mockCheckRateLimit.mockResolvedValue({ - allowed: false, - error: 'Invalid API key', - limit: 100, - remaining: 0, - resetAt: RATE_LIMIT_OK.resetAt, - }) - - const response = await POST(createRequest({ workspaceId: WS, name: 'untitled.md' })) - - expect(response.status).toBe(401) - await expect(response.json()).resolves.toMatchObject({ - error: { code: 'UNAUTHORIZED', message: 'Invalid API key' }, - }) - expect(mockV2ApiGateError).not.toHaveBeenCalled() - expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() - }) - - it('returns the v2 gate failure before access checks or orchestration', async () => { - const { v2Error } = await import('@/app/api/v2/lib/response') - mockV2ApiGateError.mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const response = await POST(createRequest({ workspaceId: WS, name: 'untitled.md' })) - - expect(response.status).toBe(404) - expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() - }) - - it('requires workspace write access before orchestration', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - - const response = await POST(createRequest({ workspaceId: WS, name: 'untitled.md' })) - - expect(response.status).toBe(403) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(RATE_LIMIT_OK, 'user-1', WS, 'write') - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index bd8fa79e89a..bba97991356 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -3,117 +3,78 @@ import { v2CreateFileContract, v2ListFilesContract, } from '@/lib/api/contracts/v2/files' -import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { queryWorkspaceFiles } from '@/lib/uploads/contexts/workspace' +import { INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' -import { - MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, - performCreateWorkspaceFile, -} from '@/lib/workspace-files/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { createWorkspaceFile } from '@/lib/workspace-files/application/create-workspace-file' +import { queryWorkspaceFilePage } from '@/lib/workspace-files/application/list-workspace-files' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' import { toV2File, toV2Files } from '@/app/api/v2/files/utils' -import { resolveFolderPathId } from '@/app/api/v2/lib/folders' import { cursorSortKey, decodeSortedCursor, encodeSortedCursor, - v2CaughtOrchestrationError, - v2CursorList, - v2CursorSortError, - v2Data, v2Error, - v2ErrorForOrchestration, - v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * GET /api/v2/files — List files in a workspace with search, sort, and cursor - * pagination. - * - * Filtering, ordering, and the page slice all run inside - * {@link queryWorkspaceFiles}' query. The route only translates the validated - * params and the opaque cursor, so a `search` never costs a full-workspace read. - */ -export const GET = withPublicApiRouteHandler({ +/** GET /api/v2/files — List files with search, sort, and cursor pagination. */ +export const GET = defineV2JsonRoute({ contract: v2ListFilesContract, - rateLimitEndpoint: 'files', - handler: async ({ input, auth: { userId, rateLimit } }) => { - try { - const { workspaceId, folderPath, search, sortBy, sortOrder, limit, cursor } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'file') - const folderId = - folderPath === undefined ? undefined : resolveFolderPathId(folderIndex, folderPath) - if (folderPath !== undefined && folderId === undefined) { - return v2Error('NOT_FOUND', 'Folder not found') - } - - const sort = cursorSortKey(sortBy, sortOrder) - const decoded = decodeSortedCursor(cursor, sort) - if (decoded.status === 'invalid') return v2CursorSortError() - - const { files, nextKeys } = await queryWorkspaceFiles(workspaceId, { - folderId, - search, - sortBy, - sortOrder, - limit, - after: decoded.status === 'ok' ? decoded.keys : undefined, - }) - - const items: V2File[] = await toV2Files(files) - const nextCursor = nextKeys ? encodeSortedCursor(sort, nextKeys) : null - - return v2CursorList(items, nextCursor, { rateLimit }) - } catch (error) { - // A cursor that doesn't fit the requested sort arrives classified as `validation` → 400. - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - throw error + auth: v2ApiKeyAuth, + operation: fileOperations.list, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ query }) => { + const cursorSort = cursorSortKey(query.sortBy, query.sortOrder) + const decoded = decodeSortedCursor(query.cursor, cursorSort) + if (decoded.status === 'invalid') { + throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) + } + return { + workspaceId: query.workspaceId, + folderPath: query.folderPath, + search: query.search, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + limit: query.limit, + after: decoded.status === 'ok' ? decoded.keys : undefined, + cursorSort, } }, + useCase: queryWorkspaceFilePage, + present: async ({ files, nextKeys, cursorSort }) => { + const items: V2File[] = await toV2Files(files) + return { data: items, nextCursor: nextKeys ? encodeSortedCursor(cursorSort, nextKeys) : null } + }, }) -/** POST /api/v2/files — Create an authored workspace file, optionally with initial content. */ -export const POST = withPublicApiRouteHandler({ +/** POST /api/v2/files — Create an authored workspace file. */ +export const POST = defineV2JsonRoute({ contract: v2CreateFileContract, - rateLimitEndpoint: 'files', + auth: v2ApiKeyAuth, + operation: fileOperations.create, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, parseOptions: { invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, payloadTooLargeResponse: () => v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large'), }, - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - const { workspaceId, name, contentType, folderPath, content, encoding } = input.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performCreateWorkspaceFile({ - workspaceId, - userId, - name, - contentType: contentType ?? getMimeTypeFromExtension(getFileExtension(name)), - folderPath: folderPath ?? '/', - content: Buffer.from(content, encoding), - exactName: true, - request, - }) - if (!result.success || !result.file) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to create file') - ) - } - - return v2Data(await toV2File(result.file), { rateLimit, status: 201 }) - }, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + name: body.name, + contentType: body.contentType ?? getMimeTypeFromExtension(getFileExtension(body.name)), + content: body.content, + encoding: body.encoding, + folderPath: body.folderPath ?? '/', + exactName: true, + }), + useCase: createWorkspaceFile, + present: async ({ file }) => ({ data: await toV2File(file) }), }) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts index 87d477083ab..42cee5756b3 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts @@ -1,48 +1,22 @@ import { v2CompleteFileUploadContract } from '@/lib/api/contracts/v2/files' -import { completeUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' -import { finalizeWorkspaceFileUpload } from '@/app/api/files/uploads/finalizers' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' -import { - v2CaughtOrchestrationError, - v2Data, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { completeWorkspaceFileUploadOperation } from '@/lib/uploads/upload-session/application' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { toV2FileUpload, v2UploadControlError } from '@/app/api/v2/files/uploads/utils' -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CompleteFileUploadContract, - rateLimitEndpoint: 'files', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - try { - const { uploadId } = input.params - const { workspaceId } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const session = await getOwnedUploadSession({ - uploadId, - workspaceId, - userId, - purpose: 'workspace_file', - uploadToken: input.headers['upload-token'], - }) - const result = await completeUploadSession({ - session, - finalize: async (claimed) => { - const finalized = await finalizeWorkspaceFileUpload({ - session: claimed, - actor: { id: userId }, - request, - source: 'api', - }) - return { value: finalized.file, completedFileId: finalized.file.id } - }, - }) - return v2Data(await toV2FileUpload(result.session, result.value), { rateLimit }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + auth: v2ApiKeyAuth, + operation: fileOperations.uploadComplete, + rateLimit: v2RateLimits.publicApi, + errorPolicy: { render: v2UploadControlError }, + mapInput: ({ params, query, headers }) => ({ + uploadId: params.uploadId, + workspaceId: query.workspaceId, + uploadToken: headers['upload-token'], + }), + useCase: completeWorkspaceFileUploadOperation, + present: async (result) => ({ + data: await toV2FileUpload(result.session, result.value), + }), }) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts index 926feab0e97..9261a7fd471 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts @@ -1,39 +1,21 @@ import { v2CreateFileUploadPartUrlsContract } from '@/lib/api/contracts/v2/files' -import { createUploadPartUrls, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { - v2CaughtOrchestrationError, - v2Data, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { issueWorkspaceFileUploadPartsOperation } from '@/lib/uploads/upload-session/application' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { v2UploadControlError } from '@/app/api/v2/files/uploads/utils' -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateFileUploadPartUrlsContract, - rateLimitEndpoint: 'files', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - try { - const { uploadId } = input.params - const { workspaceId } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const session = await getOwnedUploadSession({ - uploadId, - workspaceId, - userId, - purpose: 'workspace_file', - uploadToken: input.headers['upload-token'], - }) - const parts = await createUploadPartUrls({ - session, - partNumbers: input.body.partNumbers, - localOrigin: request.nextUrl.origin, - }) - return v2Data({ parts }, { rateLimit }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + auth: v2ApiKeyAuth, + operation: fileOperations.uploadParts, + rateLimit: v2RateLimits.publicApi, + errorPolicy: { render: v2UploadControlError }, + mapInput: ({ params, query, headers, body }) => ({ + uploadId: params.uploadId, + workspaceId: query.workspaceId, + uploadToken: headers['upload-token'], + partNumbers: body.partNumbers, + }), + useCase: issueWorkspaceFileUploadPartsOperation, + present: ({ parts }) => ({ data: { parts } }), }) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts index 49d085ffe68..f55e531f199 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts @@ -1,36 +1,20 @@ import { v2AbortFileUploadContract } from '@/lib/api/contracts/v2/files' -import { abortUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' -import { - v2CaughtOrchestrationError, - v2Data, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { abortWorkspaceFileUploadOperation } from '@/lib/uploads/upload-session/application' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { toV2FileUpload, v2UploadControlError } from '@/app/api/v2/files/uploads/utils' -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2AbortFileUploadContract, - rateLimitEndpoint: 'files', - handler: async ({ input, auth: { userId, rateLimit } }) => { - try { - const { uploadId } = input.params - const { workspaceId } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const session = await getOwnedUploadSession({ - uploadId, - workspaceId, - userId, - purpose: 'workspace_file', - uploadToken: input.headers['upload-token'], - }) - const aborted = await abortUploadSession(session) - return v2Data(await toV2FileUpload(aborted, null), { rateLimit }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + auth: v2ApiKeyAuth, + operation: fileOperations.uploadCancel, + rateLimit: v2RateLimits.publicApi, + errorPolicy: { render: v2UploadControlError }, + mapInput: ({ params, query, headers }) => ({ + uploadId: params.uploadId, + workspaceId: query.workspaceId, + uploadToken: headers['upload-token'], + }), + useCase: abortWorkspaceFileUploadOperation, + present: async (session) => ({ data: await toV2FileUpload(session, null) }), }) diff --git a/apps/sim/app/api/v2/files/uploads/route.test.ts b/apps/sim/app/api/v2/files/uploads/route.test.ts index 884dfbe87ce..27fdc5fca91 100644 --- a/apps/sim/app/api/v2/files/uploads/route.test.ts +++ b/apps/sim/app/api/v2/files/uploads/route.test.ts @@ -4,209 +4,151 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockCreateUploadSession, - mockLoadActiveFolderPathIndex, - mockWithFolderTreeLock, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockCreateUploadSession: vi.fn(), - mockLoadActiveFolderPathIndex: vi.fn(), - mockWithFolderTreeLock: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticateV2ApiKey: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + createUpload: vi.fn(), + gate: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/uploads/upload-session/application', () => ({ + createWorkspaceFileUploadOperation: { + operation: { id: 'files.upload.create', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.createUpload, + }, })) -vi.mock('@/lib/folders/queries', () => ({ - loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/lib/folders/locks', () => ({ - withFolderTreeLock: mockWithFolderTreeLock, +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkRateLimitDirect + checkRateLimitDirectOrThrow = mocks.checkRateLimitDirectOrThrow + }, })) -vi.mock('@/lib/uploads/upload-session/service', () => ({ - createUploadSession: mockCreateUploadSession, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) + +vi.mock('@/app/api/v2/files/uploads/utils', () => ({ + toV2FileUpload: vi.fn(async () => ({ + id: 'upload-1', + status: 'uploading', + name: 'file.csv', + contentType: 'text/csv', + size: 10, + expiresAt: '2026-08-04T21:00:00.000Z', + error: null, + file: null, + })), })) import { POST } from '@/app/api/v2/files/uploads/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' -const RATE_LIMIT = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2026-08-03T22:00:00.000Z'), +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } const UPLOAD_SESSION = { id: 'upload-1', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - knowledgeBaseId: null, - workflowId: null, - executionId: null, - purpose: 'workspace_file', - method: 'put', - storageContext: 'workspace', - storageKey: `${WORKSPACE_ID}/file.csv`, - finalKey: `${WORKSPACE_ID}/file.csv`, - storageProvider: 's3', - providerUploadId: null, - providerObjectVersion: null, - fileName: 'file.csv', - contentType: 'text/csv', - fileSize: 10, - partSize: null, - partCount: null, - status: 'uploading', uploadToken: 'signed-upload-token', - metadata: {}, - completedFileId: null, - error: null, - expiresAt: new Date('2026-08-04T21:00:00.000Z'), - createdAt: new Date('2026-08-03T21:00:00.000Z'), - updatedAt: new Date('2026-08-03T21:00:00.000Z'), - completedAt: null, transfer: { - method: 'put', + method: 'put' as const, url: 'https://storage.example/upload', headers: { 'content-type': 'text/csv' }, }, } function request(body: Record) { - return POST( - new NextRequest('http://localhost:3000/api/v2/files/uploads', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - ) + const request = new NextRequest('http://localhost:3000/api/v2/files/uploads', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify(body), + }) + return { request, response: POST(request) } } describe('POST /api/v2/files/uploads', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockWithFolderTreeLock.mockImplementation(async (_workspaceId, _resourceType, operation) => - operation({}) - ) - mockLoadActiveFolderPathIndex.mockResolvedValue({ - rowById: new Map(), - pathById: new Map(), - idByPath: new Map([['/Reports', 'folder-reports']]), + mocks.authenticateV2ApiKey.mockResolvedValue(AUTH) + mocks.gate.mockResolvedValue(null) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-04T21:00:00.000Z'), }) - mockCreateUploadSession.mockResolvedValue(UPLOAD_SESSION) + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-04T21:00:00.000Z'), + }) + mocks.createUpload.mockResolvedValue(UPLOAD_SESSION) }) - it('creates one signed PUT session for a small file', async () => { - const response = await request({ + it('creates a signed upload through the workspace principal pipeline', async () => { + const call = request({ workspaceId: WORKSPACE_ID, name: 'file.csv', contentType: 'text/csv', size: 10, }) + const response = await call.response expect(response.status).toBe(201) - const { data } = await response.json() - expect(data).toMatchObject({ - session: { id: 'upload-1', status: 'uploading', file: null }, - uploadToken: 'signed-upload-token', - transfer: { method: 'put', url: 'https://storage.example/upload' }, + expect(await response.json()).toMatchObject({ + data: { + session: { id: 'upload-1', status: 'uploading', file: null }, + uploadToken: 'signed-upload-token', + transfer: { method: 'put', url: 'https://storage.example/upload' }, + }, }) - expect(data.session).not.toHaveProperty('uploadToken') - expect(data.session).not.toHaveProperty('transfer') - expect(data.session).not.toHaveProperty('partSize') - expect(data.session).not.toHaveProperty('partCount') - expect(mockCreateUploadSession).toHaveBeenCalledWith({ - workspaceId: WORKSPACE_ID, - userId: 'user-1', - purpose: 'workspace_file', - fileName: 'file.csv', - contentType: 'text/csv', - fileSize: 10, - metadata: { folderId: null }, - localOrigin: 'http://localhost:3000', + expect(mocks.createUpload).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + name: 'file.csv', + contentType: 'text/csv', + size: 10, + folderPath: '/', + }, + request: call.request, }) }) - it('authorizes workspace write access before creating provider state', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - - const response = await request({ - workspaceId: WORKSPACE_ID, - name: 'file.csv', - contentType: 'text/csv', - size: 10, - }) + it('authenticates and rate limits before request validation', async () => { + const response = await request({ workspaceId: WORKSPACE_ID }).response - expect(response.status).toBe(403) - expect(mockLoadActiveFolderPathIndex).not.toHaveBeenCalled() - expect(mockCreateUploadSession).not.toHaveBeenCalled() + expect(response.status).toBe(400) + expect(mocks.authenticateV2ApiKey).toHaveBeenCalledTimes(1) + expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledTimes(2) + expect(mocks.createUpload).not.toHaveBeenCalled() }) - it('creates an upload session for an empty workspace file', async () => { - const response = await request({ + it('does not run a second creator-based authentication path', async () => { + await request({ workspaceId: WORKSPACE_ID, - name: 'file.csv', - contentType: 'text/csv', + name: 'empty.txt', + contentType: 'text/plain', size: 0, - }) - - expect(response.status).toBe(201) - expect(mockCreateUploadSession).toHaveBeenCalledWith( - expect.objectContaining({ purpose: 'workspace_file', fileSize: 0 }) - ) - }) + }).response - it('releases the folder tree lock before creating an upload session', async () => { - let lockHeld = false - mockWithFolderTreeLock.mockImplementation(async (_workspaceId, _resourceType, operation) => { - lockHeld = true - try { - return await operation({}) - } finally { - lockHeld = false - } - }) - mockCreateUploadSession.mockImplementationOnce(async () => { - expect(lockHeld).toBe(false) - return UPLOAD_SESSION - }) - - const response = await request({ - workspaceId: WORKSPACE_ID, - name: 'file.csv', - contentType: 'text/csv', - size: 10, - folderPath: '/Reports', - }) - - expect(response.status).toBe(201) - expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith( - WORKSPACE_ID, - 'file', - expect.any(Object) - ) - expect(mockCreateUploadSession).toHaveBeenCalledWith( - expect.objectContaining({ metadata: { folderId: 'folder-reports' } }) + expect(mocks.authenticateV2ApiKey).toHaveBeenCalledTimes(1) + expect(mocks.createUpload).toHaveBeenCalledWith( + expect.objectContaining({ principal: PRINCIPAL }) ) }) }) diff --git a/apps/sim/app/api/v2/files/uploads/route.ts b/apps/sim/app/api/v2/files/uploads/route.ts index 60ef13d4bd0..61194e27633 100644 --- a/apps/sim/app/api/v2/files/uploads/route.ts +++ b/apps/sim/app/api/v2/files/uploads/route.ts @@ -1,52 +1,30 @@ import { v2CreateFileUploadContract } from '@/lib/api/contracts/v2/files' -import { createUploadSession } from '@/lib/uploads/upload-session/service' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' +import { createWorkspaceFileUploadOperation } from '@/lib/uploads/upload-session/application' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' -import { resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateFileUploadContract, - rateLimitEndpoint: 'files', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - try { - const { workspaceId, name, contentType, size, folderPath } = input.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const resolution = await resolveFolderPathIdentity({ - workspaceId, - resourceType: 'file', - path: folderPath ?? '/', - }) - if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') - const session = await createUploadSession({ - workspaceId, - userId, - purpose: 'workspace_file', - fileName: name, - contentType, - fileSize: size, - metadata: { folderId: resolution.folderId }, - localOrigin: request.nextUrl.origin, - }) - return v2Data( - { - session: await toV2FileUpload(session, null), - uploadToken: session.uploadToken, - transfer: session.transfer, - }, - { rateLimit, status: 201 } - ) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + auth: v2ApiKeyAuth, + operation: fileOperations.uploadCreate, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + name: body.name, + contentType: body.contentType, + size: body.size, + folderPath: body.folderPath ?? ROOT_FOLDER_PATH, + }), + useCase: createWorkspaceFileUploadOperation, + present: async (session) => ({ + data: { + session: await toV2FileUpload(session, null), + uploadToken: session.uploadToken, + transfer: session.transfer, + }, + }), }) diff --git a/apps/sim/app/api/v2/files/uploads/utils.ts b/apps/sim/app/api/v2/files/uploads/utils.ts index dad280cfca8..45d60afd0c5 100644 --- a/apps/sim/app/api/v2/files/uploads/utils.ts +++ b/apps/sim/app/api/v2/files/uploads/utils.ts @@ -35,3 +35,21 @@ function uploadStatus(status: string): V2UploadStatus { } return status } + +import type { Principal } from '@sim/auth/principal' +import type { NextRequest, NextResponse } from 'next/server' +import { authenticateV2ApiKey } from '@/lib/api/server/routes/v2-api-key-auth' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' + +/** Re-authenticates the API key for each upload control leg. */ +export async function authenticateUploadPrincipal(request: NextRequest): Promise { + const auth = await authenticateV2ApiKey(request.headers.get('x-api-key')) + return auth.principal +} + +/** Resource-ID upload controls conceal authorization failures as absence. */ +export function v2UploadControlError(error: unknown): NextResponse | null { + const response = v2CaughtOrchestrationError(error) + if (!response) return null + return response.status === 403 ? v2Error('NOT_FOUND', 'Upload session not found') : response +} diff --git a/apps/sim/app/api/v2/lib/gate.ts b/apps/sim/app/api/v2/lib/gate.ts index d9bf214eece..8f14be2f6c1 100644 --- a/apps/sim/app/api/v2/lib/gate.ts +++ b/apps/sim/app/api/v2/lib/gate.ts @@ -10,12 +10,10 @@ import { v2Error } from '@/app/api/v2/lib/response' * answers 404 as if it did not exist, so an ungated caller cannot distinguish * "not in the rollout cohort" from "no such endpoint". * - * Deliberately keyed on `userId` only. A workspace- or org-keyed gate would - * have to read membership for a caller-supplied id before authorization has - * run, and its 404-vs-403 split would then leak whether that workspace's org - * is in the cohort — the trap the per-domain table gate has to work around by - * running late. Keyed on the authenticated user, the check is safe to run - * first and is uniform across every v2 route. + * Deliberately keyed on a server-resolved user rollout subject, never a + * caller-supplied workspace. Personal keys use their authenticated user; + * workspace keys use the canonical workspace billing owner as rollout-only + * context. That billing owner is not an authorization principal. */ export async function v2ApiGateError(userId: string): Promise { if (await isFeatureEnabled('v2-api', { userId })) return null diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts index da54edf8957..79860777852 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts @@ -1,124 +1,23 @@ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' import { workspaceFileCompiledCheckContract } from '@/lib/api/contracts/workspace-files' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { getE2BDocFormat } from '@/lib/copilot/tools/server/files/doc-compile' -import { runE2BCompiledCheck } from '@/lib/copilot/tools/server/files/doc-recalc' -import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' -import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' -import { validateMermaidSource } from '@/lib/mermaid/validate' -import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' -import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { compiledCheckWorkspaceFile } from '@/lib/workspace-files/application/compiled-check-workspace-file' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -const logger = createLogger('WorkspaceFileCompiledCheckAPI') - -/** - * GET /api/workspaces/[id]/files/[fileId]/compiled-check - * - * Compiles or validates the saved source for generated document-like files and - * returns whether it succeeds. Used by the file agent to self-verify generated - * code or diagram syntax before finalising an edit. - * - * Returns: - * 200 { ok: true } - * 200 { ok: false, error: string, errorName: string } — user code error - * 4xx on auth / missing file / unsupported extension - * 500 on system (sandbox infra) failure - */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const parsed = await parseRequest(workspaceFileCompiledCheckContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const membership = await verifyWorkspaceMembership(session.user.id, workspaceId) - if (!membership) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const fileRecord = await getWorkspaceFile(workspaceId, fileId) - if (!fileRecord) { - return NextResponse.json({ error: 'File not found' }, { status: 404 }) - } - - const ext = fileRecord.name.split('.').pop()?.toLowerCase() ?? '' - // In the E2B regime ALL four formats compile in the doc sandbox (Node for - // pptx/docx, Python for pdf/xlsx). Gate on the flag (not the stored MIME) so - // a stale file can't trigger an E2B compile when the sandbox is disabled. - const e2bFmt = isDocSandboxEnabled ? await getE2BDocFormat(fileRecord.name) : null - const taskId = BINARY_DOC_TASKS[ext] - const isMermaidFile = ext === 'mmd' || ext === 'mermaid' - if (!e2bFmt && !taskId && !isMermaidFile) { - return NextResponse.json( - { error: `Compiled check only supports .docx, .pptx, .pdf, .xlsx, and .mmd files` }, - { status: 422 } - ) - } - - let buffer: Buffer - try { - buffer = await fetchWorkspaceFileBuffer(fileRecord) - } catch (err) { - logger.error('Failed to download file for compiled check', { - fileId, - error: toError(err).message, - }) - return NextResponse.json({ error: 'Failed to read file' }, { status: 500 }) - } - - const code = buffer.toString('utf-8') - - if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { - return NextResponse.json({ error: 'File source exceeds maximum size' }, { status: 413 }) - } - - if (isMermaidFile) { - return NextResponse.json(await validateMermaidSource(code)) - } - - if (e2bFmt) { - // Loads the compile-once artifact if present, else compiles via E2B once - // (and recalc-scans xlsx formulas). Only a script error is { ok: false }; - // infra failures rethrow → 500, so the agent isn't told to "fix its script" - // during an E2B/S3 outage. - const result = await runE2BCompiledCheck({ - source: code, - fileName: fileRecord.name, - workspaceId, - ext, - }) - return NextResponse.json(result) - } - - try { - if (!taskId) { - return NextResponse.json({ error: 'Unsupported compiled check target' }, { status: 422 }) - } - await runSandboxTask(taskId, { code, workspaceId }, { ownerKey: `user:${session.user.id}` }) - return NextResponse.json({ ok: true }) - } catch (err) { - if (err instanceof SandboxUserCodeError) { - logger.info('Compiled check failed with user code error', { - fileId, - taskId, - error: toError(err).message, - errorName: err.name, - }) - return NextResponse.json({ ok: false, error: toError(err).message, errorName: err.name }) - } - throw err - } - } -) +export const GET = defineInternalJsonRoute({ + contract: workspaceFileCompiledCheckContract, + auth: internalSessionAuth, + operation: compiledCheckWorkspaceFile.operation, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal compiled-check behavior', + }), + errorPolicy: internalFileErrorPolicies.compiledCheck, + mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }), + useCase: compiledCheckWorkspaceFile, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts index 78f5cdb124f..14104079f62 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts @@ -5,25 +5,27 @@ import { authMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetUserEntityPermissions, mockPerformUpdateContent } = vi.hoisted(() => ({ - mockGetUserEntityPermissions: vi.fn(), - mockPerformUpdateContent: vi.fn(), -})) +const mocks = vi.hoisted(() => ({ admit: vi.fn(), updateContent: vi.fn() })) vi.mock('@/lib/workspace-files/orchestration', () => ({ MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, - performUpdateWorkspaceFileContent: mockPerformUpdateContent, })) -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: mockGetUserEntityPermissions, +vi.mock('@/lib/workspace-files/application/update-workspace-file-content', () => ({ + admitUpdateWorkspaceFileContent: mocks.admit, + updateWorkspaceFileContent: { + operation: { id: 'files.update_content', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.updateContent, + }, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { PUT } from '@/app/api/workspaces/[id]/files/[fileId]/content/route' const WORKSPACE_ID = 'workspace-1' const FILE_ID = 'wf_1' const USER = { id: 'user-1', name: 'Test User', email: 'test@sim.ai' } +const PRINCIPAL = { kind: 'session' as const, userId: USER.id, sessionId: 'session-1' } const RECORD = { id: FILE_ID, workspaceId: WORKSPACE_ID, @@ -34,7 +36,6 @@ const RECORD = { type: 'text/markdown', uploadedBy: USER.id, folderId: null, - folderPath: null, uploadedAt: new Date('2026-08-04T00:00:00.000Z'), updatedAt: new Date('2026-08-04T00:00:00.000Z'), } @@ -58,9 +59,9 @@ function createRequest(body: unknown, contentLength?: number): NextRequest { describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => { beforeEach(() => { vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: USER }) - mockGetUserEntityPermissions.mockResolvedValue('write') - mockPerformUpdateContent.mockResolvedValue({ success: true, file: RECORD }) + authMockFns.mockGetSession.mockResolvedValue({ user: USER, session: { id: 'session-1' } }) + mocks.admit.mockResolvedValue(undefined) + mocks.updateContent.mockResolvedValue({ file: RECORD }) }) it('authenticates before parsing an invalid request body', async () => { @@ -70,22 +71,23 @@ describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => { expect(response.status).toBe(401) await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }) - expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() - expect(mockPerformUpdateContent).not.toHaveBeenCalled() + expect(mocks.admit).not.toHaveBeenCalled() + expect(mocks.updateContent).not.toHaveBeenCalled() }) - it('authorizes the workspace before parsing the request body', async () => { - mockGetUserEntityPermissions.mockResolvedValue('read') + it('performs cheap file admission before buffering the request body', async () => { + mocks.admit.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) const response = await PUT(createRequest('{not-json'), routeContext) expect(response.status).toBe(403) - await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' }) - expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER.id, 'workspace', WORKSPACE_ID) - expect(mockPerformUpdateContent).not.toHaveBeenCalled() + expect(mocks.admit).toHaveBeenCalledWith(PRINCIPAL, FILE_ID) + expect(mocks.updateContent).not.toHaveBeenCalled() }) - it('rejects malformed base64 after authorization', async () => { + it('rejects malformed base64 after admission', async () => { const response = await PUT( createRequest({ content: 'not-base64!', encoding: 'base64' }), routeContext @@ -93,7 +95,7 @@ describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => { expect(response.status).toBe(400) await expect(response.json()).resolves.toMatchObject({ error: 'Validation error' }) - expect(mockPerformUpdateContent).not.toHaveBeenCalled() + expect(mocks.updateContent).not.toHaveBeenCalled() }) it('accepts empty base64 as a zero-byte replacement', async () => { @@ -101,14 +103,14 @@ describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => { const response = await PUT(request, routeContext) expect(response.status).toBe(200) - expect(mockPerformUpdateContent).toHaveBeenCalledWith({ - workspaceId: WORKSPACE_ID, - fileId: FILE_ID, - userId: USER.id, - content: '', - encoding: 'base64', - actorName: USER.name, - actorEmail: USER.email, + expect(mocks.updateContent).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + fileId: FILE_ID, + assertedWorkspaceId: WORKSPACE_ID, + content: '', + encoding: 'base64', + }, request, }) }) @@ -120,16 +122,14 @@ describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => { ) expect(response.status).toBe(200) - expect(mockPerformUpdateContent).toHaveBeenCalled() + expect(mocks.updateContent).toHaveBeenCalled() }) - it('rejects a JSON body above the inline-content cap', async () => { + it('rejects a JSON body above the inline-content cap after admission', async () => { const response = await PUT(createRequest({ content: '' }, 70 * 1024 * 1024 + 1), routeContext) expect(response.status).toBe(413) - await expect(response.json()).resolves.toEqual({ - error: `Request body exceeds the maximum allowed size of ${70 * 1024 * 1024} bytes`, - }) - expect(mockPerformUpdateContent).not.toHaveBeenCalled() + expect(mocks.admit).toHaveBeenCalled() + expect(mocks.updateContent).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts index a7d4934c783..c588f4e04f5 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts @@ -1,79 +1,40 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' +import { updateWorkspaceFileContentContract } from '@/lib/api/contracts/workspace-files' import { - updateWorkspaceFileContentContract, - workspaceFileParamsSchema, -} from '@/lib/api/contracts/workspace-files' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileErrorPolicies, internalFilePresenters } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' import { - messageForOrchestrationError, - statusForOrchestrationError, -} from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, - performUpdateWorkspaceFileContent, -} from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + admitUpdateWorkspaceFileContent, + updateWorkspaceFileContent, +} from '@/lib/workspace-files/application/update-workspace-file-content' +import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' export const dynamic = 'force-dynamic' -const logger = createLogger('WorkspaceFileContentAPI') - -/** - * PUT /api/workspaces/[id]/files/[fileId]/content - * Update a workspace file's text content (requires write permission) - */ -export const PUT = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const paramsResult = workspaceFileParamsSchema.safeParse(await context.params) - if (!paramsResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, - { status: 400 } - ) - } - const { id: workspaceId, fileId } = paramsResult.data - - const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (userPermission !== 'admin' && userPermission !== 'write') { - logger.warn(`User ${session.user.id} lacks write permission for workspace ${workspaceId}`) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) +/** PUT /api/workspaces/[id]/files/[fileId]/content — Replace a file's bytes. */ +export const PUT = defineInternalJsonRoute({ + contract: updateWorkspaceFileContentContract, + auth: internalSessionAuth, + operation: fileOperations.updateContent, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal content-update behavior', + }), + errorPolicy: internalFileErrorPolicies.default, + parseOptions: { maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES }, + beforeParse: async ({ principal, params }) => { + if (typeof params.fileId === 'string') { + await admitUpdateWorkspaceFileContent(principal, params.fileId) } - - const parsed = await parseRequest(updateWorkspaceFileContentContract, request, context, { - maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, - }) - if (!parsed.success) return parsed.response - const { content, encoding } = parsed.data.body - - const result = await performUpdateWorkspaceFileContent({ - workspaceId, - fileId, - userId: session.user.id, - content, - encoding: encoding === 'base64' ? 'base64' : 'utf-8', - actorName: session.user.name, - actorEmail: session.user.email, - request, - }) - - if (!result.success || !result.file) { - return NextResponse.json( - { - success: false, - error: messageForOrchestrationError(result, 'Failed to update file content'), - }, - { status: statusForOrchestrationError(result.errorCode) } - ) - } - - return NextResponse.json({ success: true, file: result.file }) - } -) + }, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: params.id, + content: body.content, + encoding: body.encoding === 'base64' ? ('base64' as const) : ('utf-8' as const), + }), + useCase: updateWorkspaceFileContent, + present: internalFilePresenters.successFile, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts index 0fd9553a4c4..100a7fadca0 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts @@ -1,56 +1,30 @@ import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { getWorkspaceCsvPreviewContract } from '@/lib/api/contracts/workspace-file-table' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCsvPreviewSlice } from '@/lib/file-parsers/csv-preview-slice' -import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { internalFileErrorPolicies, internalSessionOrServiceAuth } from '@/lib/workspace-files/api' +import { csvPreviewWorkspaceFile } from '@/lib/workspace-files/application/csv-preview-workspace-file' const logger = createLogger('WorkspaceCsvPreviewAPI') export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const userId = authResult.userId - - const parsed = await parseRequest(getWorkspaceCsvPreviewContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - const { key } = parsed.data.query - - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (!permission) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } - - // Resolve the file record (active, in this workspace) and read from its authoritative key — - // never the client-supplied one. This rejects archived/deleted files and keys with no live - // row, matching the access guarantees of /api/files/serve. - const record = await getWorkspaceFile(workspaceId, fileId) - if (!record || record.key !== key) { - return NextResponse.json({ error: 'File not found' }, { status: 404 }) - } - - const slice = await getCsvPreviewSlice({ - key: record.key, - context: 'workspace', - signal: request.signal, - }) - +export const GET = defineInternalJsonRoute({ + contract: getWorkspaceCsvPreviewContract, + auth: internalSessionOrServiceAuth, + operation: csvPreviewWorkspaceFile.operation, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal CSV preview behavior' }), + errorPolicy: internalFileErrorPolicies.plain, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: params.id, + key: query.key, + }), + useCase: csvPreviewWorkspaceFile, + onSuccess: ({ result }) => { logger.info('CSV preview served', { - workspaceId, - rows: slice.rows.length, - truncated: slice.truncated, + rows: result.rows.length, + truncated: result.truncated, }) - - return NextResponse.json({ success: true, ...slice }) - } -) + }, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.test.ts index b6413ea6818..52db9ebbf0f 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.test.ts @@ -1,93 +1,96 @@ /** * @vitest-environment node */ -import { authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing' +import { authMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockUpdateWorkspaceFileDimensions } = vi.hoisted(() => ({ - mockUpdateWorkspaceFileDimensions: vi.fn(), -})) +const mocks = vi.hoisted(() => ({ updateDimensions: vi.fn() })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - updateWorkspaceFileDimensions: mockUpdateWorkspaceFileDimensions, +vi.mock('@/lib/workspace-files/application/update-workspace-file-dimensions', () => ({ + updateWorkspaceFileDimensionsOperation: { + operation: { id: 'files.update_metadata', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.updateDimensions, + }, })) -vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) - -const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785' -const FILE = 'wf_abc123' -const KEY = 'workspace/7727ef3f/screenshot.png' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { PATCH } from '@/app/api/workspaces/[id]/files/[fileId]/dimensions/route' -const routeContext = { params: Promise.resolve({ id: WS, fileId: FILE }) } +const WORKSPACE_ID = '7727ef3f-8cf6-4686-b063-2bb006a10785' +const FILE_ID = 'wf_abc123' +const KEY = 'workspace/7727ef3f/screenshot.png' +const USER = { id: 'user-1' } +const PRINCIPAL = { kind: 'session' as const, userId: USER.id, sessionId: 'session-1' } +const context = { params: Promise.resolve({ id: WORKSPACE_ID, fileId: FILE_ID }) } -function buildRequest(body: unknown): NextRequest { - return new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE}/dimensions`, { - method: 'PATCH', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - }) +function request(body: unknown): NextRequest { + return new NextRequest( + `http://localhost/api/workspaces/${WORKSPACE_ID}/files/${FILE_ID}/dimensions`, + { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + } + ) } describe('PATCH /api/workspaces/[id]/files/[fileId]/dimensions', () => { beforeEach(() => { vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - mockUpdateWorkspaceFileDimensions.mockResolvedValue(true) + authMockFns.mockGetSession.mockResolvedValue({ user: USER, session: { id: 'session-1' } }) + mocks.updateDimensions.mockResolvedValue({ success: true }) }) - it('stores dimensions for a writer, keyed to the content version', async () => { - const res = await PATCH(buildRequest({ key: KEY, width: 1600, height: 900 }), routeContext) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ success: true }) - expect(mockUpdateWorkspaceFileDimensions).toHaveBeenCalledWith(WS, FILE, { - key: KEY, - width: 1600, - height: 900, + it('updates dimensions through the shared operation', async () => { + const req = request({ key: KEY, width: 1600, height: 900 }) + const response = await PATCH(req, context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ success: true }) + expect(mocks.updateDimensions).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + fileId: FILE_ID, + assertedWorkspaceId: WORKSPACE_ID, + key: KEY, + width: 1600, + height: 900, + }, + request: req, }) }) - it('allows an admin', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin') - const res = await PATCH(buildRequest({ key: KEY, width: 10, height: 20 }), routeContext) - expect(res.status).toBe(200) - expect(mockUpdateWorkspaceFileDimensions).toHaveBeenCalledOnce() - }) + it('preserves the stale content-version result', async () => { + mocks.updateDimensions.mockResolvedValue({ success: false }) + const response = await PATCH(request({ key: KEY, width: 10, height: 20 }), context) - it('reports success:false when the content-version guard rejects the write (key changed)', async () => { - mockUpdateWorkspaceFileDimensions.mockResolvedValue(false) - const res = await PATCH(buildRequest({ key: KEY, width: 10, height: 20 }), routeContext) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ success: false }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ success: false }) }) - it('rejects an unauthenticated caller before touching the DB', async () => { + it('authenticates before parsing or dispatching', async () => { authMockFns.mockGetSession.mockResolvedValue(null) - const res = await PATCH(buildRequest({ key: KEY, width: 10, height: 10 }), routeContext) - expect(res.status).toBe(401) - expect(mockUpdateWorkspaceFileDimensions).not.toHaveBeenCalled() + const response = await PATCH(request({ key: KEY, width: 10, height: 10 }), context) + + expect(response.status).toBe(401) + expect(mocks.updateDimensions).not.toHaveBeenCalled() }) - it('rejects a read-only member (backfill requires write)', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read') - const res = await PATCH(buildRequest({ key: KEY, width: 10, height: 10 }), routeContext) - expect(res.status).toBe(403) - expect(mockUpdateWorkspaceFileDimensions).not.toHaveBeenCalled() + it('rejects invalid dimensions after authentication', async () => { + const response = await PATCH(request({ key: KEY, width: 0, height: 10 }), context) + + expect(response.status).toBe(400) + expect(mocks.updateDimensions).not.toHaveBeenCalled() }) - it('rejects a missing key or non-positive / non-integer dimensions', async () => { - for (const body of [ - { width: 10, height: 10 }, // missing key - { key: KEY, width: 0, height: 10 }, - { key: KEY, width: 10, height: -5 }, - { key: KEY, width: 10.5, height: 10 }, - { key: KEY, width: 10 }, - ]) { - const res = await PATCH(buildRequest(body), routeContext) - expect(res.status).toBe(400) - } - expect(mockUpdateWorkspaceFileDimensions).not.toHaveBeenCalled() + it('renders typed authorization errors', async () => { + mocks.updateDimensions.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) + const response = await PATCH(request({ key: KEY, width: 10, height: 10 }), context) + + expect(response.status).toBe(403) + expect(mocks.updateDimensions).toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.ts index 0d91d38563e..96a1c73ca53 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.ts @@ -1,57 +1,25 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' import { updateWorkspaceFileDimensionsContract } from '@/lib/api/contracts/workspace-files' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { updateWorkspaceFileDimensions } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { updateWorkspaceFileDimensionsOperation } from '@/lib/workspace-files/application/update-workspace-file-dimensions' -const logger = createLogger('WorkspaceFileDimensionsAPI') - -/** - * PATCH /api/workspaces/[id]/files/[fileId]/dimensions - * - * Store an image file's intrinsic pixel dimensions — a pure rendering hint the editor uses to reserve - * layout space before the image loads. Requires write permission. The write commits whenever the row - * still holds the measured storage key, overwriting any stale value so a wrong size self-corrects; the - * client reports only on a real mismatch, so this is not storm-y despite not being a backfill-once no-op. - */ -export const PATCH = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(updateWorkspaceFileDimensionsContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - const { key, width, height } = parsed.data.body - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - // `written` is false when the content-version guard rejected the write (the row's storage key no - // longer matches the key the client measured — the content was replaced since). That is not an - // error; the client's next measurement, once its file list has the new key, persists correctly. - const written = await updateWorkspaceFileDimensions(workspaceId, fileId, { - key, - width, - height, - }) - return NextResponse.json({ success: written }) - } catch (error) { - logger.error('Failed to store workspace file dimensions', { - workspaceId, - fileId, - error: getErrorMessage(error), - }) - return NextResponse.json({ error: 'Failed to update dimensions' }, { status: 500 }) - } - } -) +export const PATCH = defineInternalJsonRoute({ + contract: updateWorkspaceFileDimensionsContract, + auth: internalSessionAuth, + operation: fileOperations.updateMetadata, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal dimensions behavior' }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: params.id, + key: body.key, + width: body.width, + height: body.height, + }), + useCase: updateWorkspaceFileDimensionsOperation, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts index 77c8900a718..d4916e2260c 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts @@ -1,96 +1,30 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { workspaceFileParamsSchema } from '@/lib/api/contracts/workspace-files' -import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' -import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' +import { downloadWorkspaceFileUrlContract } from '@/lib/api/contracts/workspace-files' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + internalFileAnalytics, + internalFileErrorPolicies, + internalFilePresenters, +} from '@/lib/workspace-files/api' +import { downloadWorkspaceFile } from '@/lib/workspace-files/application/download-workspace-file' export const dynamic = 'force-dynamic' -const logger = createLogger('WorkspaceFileDownloadAPI') - -/** - * POST /api/workspaces/[id]/files/[fileId]/download - * Return authenticated file serve URL (requires read permission) - * Uses /api/files/serve endpoint which enforces authentication and context - */ -export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => { - const requestId = generateRequestId() - const paramsResult = workspaceFileParamsSchema.safeParse(await params) - if (!paramsResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, - { status: 400 } - ) - } - const { id: workspaceId, fileId } = paramsResult.data - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userPermission = await verifyWorkspaceMembership(session.user.id, workspaceId) - if (!userPermission) { - logger.warn( - `[${requestId}] User ${session.user.id} lacks permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const fileRecord = await getWorkspaceFile(workspaceId, fileId) - if (!fileRecord) { - return NextResponse.json({ error: 'File not found' }, { status: 404 }) - } - - const { getBaseUrl } = await import('@/lib/core/utils/urls') - const serveUrl = `${getBaseUrl()}/api/files/serve/${encodeURIComponent(fileRecord.key)}?context=workspace` - const viewerUrl = `${getBaseUrl()}/workspace/${workspaceId}/files/${fileId}` - - logger.info(`[${requestId}] Generated download URL for workspace file: ${fileRecord.name}`) - - recordAudit({ - workspaceId, - actorId: session.user.id, - action: AuditAction.FILE_DOWNLOADED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: fileRecord.name, - description: `Downloaded file "${fileRecord.name}"`, - metadata: { fileId, fileName: fileRecord.name, bytes: fileRecord.size }, - request, - }) - captureServerEvent( - session.user.id, - 'file_downloaded', - { workspace_id: workspaceId, is_bulk: false, file_count: 1 }, - { groups: { workspace: workspaceId } } - ) - - return NextResponse.json({ - success: true, - downloadUrl: serveUrl, - viewerUrl: viewerUrl, - fileName: fileRecord.name, - expiresIn: null, - }) - } catch (error) { - logger.error(`[${requestId}] Error generating download URL:`, error) - return NextResponse.json( - { - success: false, - error: getErrorMessage(error, 'Failed to generate download URL'), - }, - { status: 500 } - ) - } - } -) +/** POST /api/workspaces/[id]/files/[fileId]/download — Create an authenticated serve URL. */ +export const POST = defineInternalJsonRoute({ + contract: downloadWorkspaceFileUrlContract, + auth: internalSessionAuth, + operation: downloadWorkspaceFile.operation, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal download behavior' }), + errorPolicy: internalFileErrorPolicies.downloadUrl, + mapInput: ({ params }) => ({ + fileId: params.fileId, + assertedWorkspaceId: params.id, + }), + useCase: downloadWorkspaceFile, + onSuccess: internalFileAnalytics.downloaded, + present: internalFilePresenters.downloadUrl, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts index 0d41810c77e..f286697997a 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts @@ -1,64 +1,21 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { workspaceFileParamsSchema } from '@/lib/api/contracts/workspace-files' -import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { performRestoreWorkspaceFile } from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { restoreWorkspaceFileContract } from '@/lib/api/contracts/workspace-files' +import { + defineInternalJsonRoute, + internalJsonPresenters, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { restoreWorkspaceFileOperation } from '@/lib/workspace-files/application/restore-workspace-file' -const logger = createLogger('RestoreWorkspaceFileAPI') - -export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => { - const requestId = generateRequestId() - const paramsResult = workspaceFileParamsSchema.safeParse(await params) - if (!paramsResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, - { status: 400 } - ) - } - const { id: workspaceId, fileId } = paramsResult.data - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userPermission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workspaceId - ) - if (userPermission !== 'admin' && userPermission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const result = await performRestoreWorkspaceFile({ - workspaceId, - fileId, - userId: session.user.id, - }) - if (!result.success) { - return NextResponse.json( - { error: result.error }, - { status: result.errorCode === 'conflict' ? 409 : 500 } - ) - } - - logger.info(`[${requestId}] Restored workspace file ${fileId}`) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error(`[${requestId}] Error restoring workspace file ${fileId}`, error) - return NextResponse.json( - { error: getErrorMessage(error, 'Internal server error') }, - { status: 500 } - ) - } - } -) +export const POST = defineInternalJsonRoute({ + contract: restoreWorkspaceFileContract, + auth: internalSessionAuth, + operation: fileOperations.restore, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal restore behavior' }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }), + useCase: restoreWorkspaceFileOperation, + present: internalJsonPresenters.successFrom('restored'), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.test.ts new file mode 100644 index 00000000000..da7a2a60614 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.test.ts @@ -0,0 +1,146 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + rename: vi.fn(), + deleteItems: vi.fn(), + getUserEntityPermissions: vi.fn(), + captureServerEvent: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) + +vi.mock('@/lib/workspace-files/application/rename-workspace-file', () => ({ + renameWorkspaceFile: { + operation: { id: 'files.rename', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.rename, + }, +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + performDeleteWorkspaceFileItems: mocks.deleteItems, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mocks.getUserEntityPermissions, +})) + +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { PATCH } from '@/app/api/workspaces/[id]/files/[fileId]/route' + +const WORKSPACE_ID = 'workspace-1' +const FILE_ID = 'wf_1' +const context = { params: Promise.resolve({ id: WORKSPACE_ID, fileId: FILE_ID }) } + +function callRename(body: unknown) { + return PATCH( + new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/${FILE_ID}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + context + ) +} + +function fileRecord() { + return { + id: FILE_ID, + workspaceId: WORKSPACE_ID, + name: 'renamed.csv', + key: 'workspace/ws/file.csv', + path: '/api/files/serve/file.csv', + size: 42, + type: 'text/csv', + uploadedBy: 'user-1', + folderId: undefined, + uploadedAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + } +} + +describe('PATCH /api/workspaces/[id]/files/[fileId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mocks.rename.mockResolvedValue({ file: fileRecord() }) + }) + + it('authenticates before parsing the request', async () => { + mocks.getSession.mockResolvedValue(null) + + const response = await callRename({ name: 'nested/invalid.csv' }) + + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ error: 'Unauthorized' }) + expect(mocks.rename).not.toHaveBeenCalled() + }) + + it('rejects invalid rename input before the use case', async () => { + const response = await callRename({ name: 'nested/invalid.csv' }) + + expect(response.status).toBe(400) + expect(mocks.rename).not.toHaveBeenCalled() + }) + + it('passes a session principal and canonical assertion to the shared use case', async () => { + const response = await callRename({ name: 'renamed.csv' }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + file: expect.objectContaining({ id: FILE_ID, name: 'renamed.csv', folderId: null }), + }) + expect(mocks.rename).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + fileId: FILE_ID, + assertedWorkspaceId: WORKSPACE_ID, + name: 'renamed.csv', + }, + request: expect.anything(), + }) + expect(mocks.captureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'file_renamed', + { workspace_id: WORKSPACE_ID }, + { groups: { workspace: WORKSPACE_ID } } + ) + }) + + it('renders typed authorization errors in the internal envelope', async () => { + mocks.rename.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) + + const response = await callRename({ name: 'renamed.csv' }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + success: false, + error: 'Insufficient workspace permissions', + }) + expect(mocks.captureServerEvent).not.toHaveBeenCalled() + }) + + it('hides unexpected failures behind the internal 500 envelope', async () => { + mocks.rename.mockRejectedValue(new Error('update workspace_files failed')) + + const response = await callRename({ name: 'renamed.csv' }) + + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ + success: false, + error: 'Internal server error', + }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts index 1826988ea08..2f5bb14e2d1 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts @@ -1,168 +1,56 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' import { + deleteWorkspaceFileContract, renameWorkspaceFileContract, - workspaceFileParamsSchema, } from '@/lib/api/contracts/workspace-files' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' import { - performDeleteWorkspaceFileItems, - performRenameWorkspaceFile, -} from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + defineInternalJsonRoute, + internalJsonPresenters, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + internalFileAnalytics, + internalFileErrorPolicies, + internalFilePresenters, +} from '@/lib/workspace-files/api' +import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' export const dynamic = 'force-dynamic' -const logger = createLogger('WorkspaceFileAPI') - /** * PATCH /api/workspaces/[id]/files/[fileId] * Rename a workspace file (requires write permission) */ -export const PATCH = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const requestId = generateRequestId() - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(renameWorkspaceFileContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - const { name } = parsed.data.body - - const userPermission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workspaceId - ) - if (userPermission !== 'admin' && userPermission !== 'write') { - logger.warn( - `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const result = await performRenameWorkspaceFile({ - workspaceId, - fileId, - name, - userId: session.user.id, - }) - if (!result.success || !result.file) { - return NextResponse.json( - { success: false, error: result.error }, - { status: result.errorCode === 'conflict' ? 409 : 500 } - ) - } - - logger.info(`[${requestId}] Renamed workspace file: ${fileId} to "${result.file.name}"`) - - captureServerEvent( - session.user.id, - 'file_renamed', - { workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - return NextResponse.json({ - success: true, - file: result.file, - }) - } catch (error) { - logger.error(`[${requestId}] Error renaming workspace file:`, error) - return NextResponse.json( - { - success: false, - error: getErrorMessage(error, 'Failed to rename file'), - }, - { status: 500 } - ) - } - } -) +export const PATCH = defineInternalJsonRoute({ + contract: renameWorkspaceFileContract, + auth: internalSessionAuth, + operation: fileOperations.rename, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal rename behavior' }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: params.id, + name: body.name, + }), + useCase: renameWorkspaceFile, + onSuccess: internalFileAnalytics.renamed, + present: internalFilePresenters.successFile, +}) /** * DELETE /api/workspaces/[id]/files/[fileId] * Archive a workspace file (requires write permission) */ -export const DELETE = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => { - const requestId = generateRequestId() - const paramsResult = workspaceFileParamsSchema.safeParse(await params) - if (!paramsResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, - { status: 400 } - ) - } - const { id: workspaceId, fileId } = paramsResult.data - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check workspace permissions (requires write) - const userPermission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workspaceId - ) - if (userPermission !== 'admin' && userPermission !== 'write') { - logger.warn( - `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const result = await performDeleteWorkspaceFileItems({ - workspaceId, - userId: session.user.id, - fileIds: [fileId], - }) - if (!result.success) { - return NextResponse.json( - { success: false, error: result.error }, - { - status: - result.errorCode === 'validation' - ? 400 - : result.errorCode === 'not_found' - ? 404 - : 500, - } - ) - } - - logger.info(`[${requestId}] Archived workspace file: ${fileId}`) - - captureServerEvent( - session.user.id, - 'file_deleted', - { workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - return NextResponse.json({ - success: true, - }) - } catch (error) { - logger.error(`[${requestId}] Error deleting workspace file:`, error) - return NextResponse.json( - { - success: false, - error: getErrorMessage(error, 'Failed to delete file'), - }, - { status: 500 } - ) - } - } -) +export const DELETE = defineInternalJsonRoute({ + contract: deleteWorkspaceFileContract, + auth: internalSessionAuth, + operation: fileOperations.delete, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal delete behavior' }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }), + useCase: deleteWorkspaceFileOperation, + onSuccess: internalFileAnalytics.deleted, + present: internalJsonPresenters.successFrom('deleted'), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.test.ts index 9865aee2651..2fe55b10776 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.test.ts @@ -1,171 +1,163 @@ /** * @vitest-environment node */ -import { auditMock, authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetWorkspaceFile, mockGetShareForResource, mockUpsertFileShare, mockValidateSharing } = - vi.hoisted(() => ({ - mockGetWorkspaceFile: vi.fn(), - mockGetShareForResource: vi.fn(), - mockUpsertFileShare: vi.fn(), - mockValidateSharing: vi.fn(), - })) - -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - getWorkspaceFile: mockGetWorkspaceFile, +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + getShare: vi.fn(), + updateShare: vi.fn(), })) -vi.mock('@/lib/public-shares/share-manager', () => { - class ShareValidationError extends Error { - constructor(message: string) { - super(message) - this.name = 'ShareValidationError' - } - } - return { - getShareForResource: mockGetShareForResource, - upsertFileShare: mockUpsertFileShare, - ShareValidationError, - } -}) - -vi.mock('@/ee/access-control/utils/permission-check', () => { - class PublicFileSharingNotAllowedError extends Error { - constructor() { - super('Public file sharing is not allowed based on your permission group settings') - this.name = 'PublicFileSharingNotAllowedError' - } - } - return { validatePublicFileSharing: mockValidateSharing, PublicFileSharingNotAllowedError } -}) - -vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) -vi.mock('@sim/audit', () => auditMock) - -const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785' -const FILE_ID = 'wf_abc' +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) + +vi.mock('@/lib/workspace-files/application/share-workspace-file', () => ({ + getWorkspaceFileShare: { + operation: { id: 'files.share.read', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.getShare, + }, + updateWorkspaceFileShare: { + operation: { id: 'files.share.update', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.updateShare, + }, +})) -import { ShareValidationError } from '@/lib/public-shares/share-manager' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET, PUT } from '@/app/api/workspaces/[id]/files/[fileId]/share/route' -const params = (id = WS, fileId = FILE_ID) => ({ params: Promise.resolve({ id, fileId }) }) - -const putRequest = (body: unknown) => - new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE_ID}/share`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - -const getRequest = () => - new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE_ID}/share`) - +const WORKSPACE_ID = 'workspace-1' +const FILE_ID = 'wf_1' +const PRINCIPAL = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } const SHARE = { - id: 'sh_1', + id: 'shr_1', token: 'tok_1', url: 'https://sim.ai/f/tok_1', isActive: true, resourceType: 'file' as const, resourceId: FILE_ID, + authType: 'public' as const, + hasPassword: false, + allowedEmails: [] as string[], +} +const context = { + params: Promise.resolve({ id: WORKSPACE_ID, fileId: FILE_ID }), +} + +function getRequest() { + return new NextRequest( + `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/${FILE_ID}/share` + ) +} + +function putRequest(body: unknown) { + return new NextRequest( + `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/${FILE_ID}/share`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + } + ) } -describe('share route', () => { +describe('/api/workspaces/[id]/files/[fileId]/share', () => { beforeEach(() => { vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-1', name: 'User One', email: 'u@example.com' }, - }) - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - mockGetWorkspaceFile.mockResolvedValue({ id: FILE_ID, name: 'report.pdf' }) - mockGetShareForResource.mockResolvedValue(SHARE) - mockUpsertFileShare.mockResolvedValue(SHARE) - mockValidateSharing.mockResolvedValue(undefined) // policy allows by default + mocks.getSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) + mocks.getShare.mockResolvedValue({ share: SHARE }) + mocks.updateShare.mockResolvedValue({ share: SHARE }) }) - describe('GET', () => { - it('returns 401 when unauthenticated', async () => { - authMockFns.mockGetSession.mockResolvedValueOnce(null) - const res = await GET(getRequest(), params()) - expect(res.status).toBe(401) - }) + it('authenticates before parsing or executing', async () => { + mocks.getSession.mockResolvedValueOnce(null) - it('returns 403 when the caller has no workspace access', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce(null) - const res = await GET(getRequest(), params()) - expect(res.status).toBe(403) - }) + const response = await GET(getRequest(), context) - it('returns the share for a member', async () => { - const res = await GET(getRequest(), params()) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ share: SHARE }) - }) + expect(response.status).toBe(401) + expect(mocks.getShare).not.toHaveBeenCalled() }) - describe('PUT', () => { - it('returns 403 for a read-only member', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read') - const res = await PUT(putRequest({ isActive: true }), params()) - expect(res.status).toBe(403) - expect(mockUpsertFileShare).not.toHaveBeenCalled() - }) + it('returns the share through the internal envelope', async () => { + const response = await GET(getRequest(), context) - it('maps a ShareValidationError to 400, not 500', async () => { - mockUpsertFileShare.mockRejectedValueOnce( - new ShareValidationError('Password is required for password-protected shares') - ) - const res = await PUT(putRequest({ isActive: true, authType: 'password' }), params()) - expect(res.status).toBe(400) - expect((await res.json()).error).toBe('Password is required for password-protected shares') + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ share: SHARE }) + expect(mocks.getShare).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, + request: expect.anything(), }) + }) - it('returns 404 when the file is not in the workspace', async () => { - mockGetWorkspaceFile.mockResolvedValueOnce(null) - const res = await PUT(putRequest({ isActive: true }), params()) - expect(res.status).toBe(404) - expect(mockUpsertFileShare).not.toHaveBeenCalled() - }) + it('renders authorization failures as 403', async () => { + mocks.getShare.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Access denied')) + + const response = await GET(getRequest(), context) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ success: false, error: 'Access denied' }) + }) + + it('renders resource absence as 404', async () => { + mocks.getShare.mockRejectedValueOnce(new OrchestrationError('not_found', 'File not found')) + + const response = await GET(getRequest(), context) + + expect(response.status).toBe(404) + }) + + it('rejects malformed update input before the use case', async () => { + const response = await PUT(putRequest({}), context) + + expect(response.status).toBe(400) + expect(mocks.updateShare).not.toHaveBeenCalled() + }) - it('enables the share for a writer', async () => { - const res = await PUT(putRequest({ isActive: true }), params()) - expect(res.status).toBe(200) - expect(mockUpsertFileShare).toHaveBeenCalledWith({ - workspaceId: WS, + it('passes the session principal and asserted workspace to the shared update use case', async () => { + const response = await PUT(putRequest({ isActive: true, authType: 'password' }), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ share: SHARE }) + expect(mocks.updateShare).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { fileId: FILE_ID, - userId: 'user-1', + assertedWorkspaceId: WORKSPACE_ID, isActive: true, - }) - expect(await res.json()).toEqual({ share: SHARE }) + authType: 'password', + password: undefined, + allowedEmails: undefined, + token: undefined, + }, + request: expect.anything(), }) + }) - it('returns 403 when org access-control disables public sharing (enable)', async () => { - const { PublicFileSharingNotAllowedError } = await import( - '@/ee/access-control/utils/permission-check' - ) - mockValidateSharing.mockRejectedValueOnce(new PublicFileSharingNotAllowedError()) - const res = await PUT(putRequest({ isActive: true }), params()) - expect(res.status).toBe(403) - expect(mockUpsertFileShare).not.toHaveBeenCalled() - }) + it('renders typed update failures without exposing a 500', async () => { + mocks.updateShare.mockRejectedValueOnce( + new OrchestrationError('validation', 'Password is required') + ) - it('allows disabling a share even when policy disallows enabling', async () => { - mockValidateSharing.mockRejectedValue(new Error('should not be called for disable')) - const res = await PUT(putRequest({ isActive: false }), params()) - expect(res.status).toBe(200) - expect(mockValidateSharing).not.toHaveBeenCalled() - expect(mockUpsertFileShare).toHaveBeenCalledWith({ - workspaceId: WS, - fileId: FILE_ID, - userId: 'user-1', - isActive: false, - }) - }) + const response = await PUT(putRequest({ isActive: true }), context) - it('rejects a missing isActive body', async () => { - const res = await PUT(putRequest({}), params()) - expect(res.status).toBe(400) - }) + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ success: false, error: 'Password is required' }) + }) + + it('preserves the internal caller-supplied token field for compatibility', async () => { + await PUT( + putRequest({ + isActive: true, + token: 'client-reserved-token', + }), + context + ) + + expect(mocks.updateShare).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ token: 'client-reserved-token' }), + }) + ) }) }) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts index f5810627a1b..10629599374 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts @@ -1,106 +1,44 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { getFileShareContract, upsertFileShareContract } from '@/lib/api/contracts/public-shares' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' import { - messageForOrchestrationError, - statusForOrchestrationError, -} from '@/lib/core/orchestration/types' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' import { - performGetWorkspaceFileShare, - performUpsertWorkspaceFileShare, -} from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + getWorkspaceFileShare, + updateWorkspaceFileShare, +} from '@/lib/workspace-files/application/share-workspace-file' export const dynamic = 'force-dynamic' -const logger = createLogger('WorkspaceFileShareAPI') - -/** - * GET /api/workspaces/[id]/files/[fileId]/share - * Fetch the public share state for a file (requires workspace membership). - */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const requestId = generateRequestId() - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(getFileShareContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission === null) { - logger.warn(`[${requestId}] User ${session.user.id} lacks access to workspace ${workspaceId}`) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const result = await performGetWorkspaceFileShare({ workspaceId, fileId }) - if (!result.success) { - return NextResponse.json( - { error: messageForOrchestrationError(result, 'Failed to fetch share') }, - { status: statusForOrchestrationError(result.errorCode) } - ) - } - - return NextResponse.json({ share: result.share ?? null }) - } -) - -/** - * PUT /api/workspaces/[id]/files/[fileId]/share - * Enable or disable the public share for a file (requires write permission). - */ -export const PUT = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const requestId = generateRequestId() - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(upsertFileShareContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - const { isActive, authType, password, allowedEmails, token } = parsed.data.body - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - logger.warn( - `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const result = await performUpsertWorkspaceFileShare({ - workspaceId, - fileId, - userId: session.user.id, - isActive, - authType, - password, - allowedEmails, - token, - actorName: session.user.name, - actorEmail: session.user.email, - request, - }) - - if (!result.success || !result.share) { - return NextResponse.json( - { error: messageForOrchestrationError(result, 'Failed to update share') }, - { status: statusForOrchestrationError(result.errorCode) } - ) - } - - return NextResponse.json({ share: result.share }) - } -) +export const GET = defineInternalJsonRoute({ + contract: getFileShareContract, + auth: internalSessionAuth, + operation: fileOperations.readShare, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal share-read behavior' }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }), + useCase: getWorkspaceFileShare, +}) + +export const PUT = defineInternalJsonRoute({ + contract: upsertFileShareContract, + auth: internalSessionAuth, + operation: fileOperations.updateShare, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal share-update behavior', + }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: params.id, + isActive: body.isActive, + authType: body.authType, + password: body.password, + allowedEmails: body.allowedEmails, + token: body.token, + }), + useCase: updateWorkspaceFileShare, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts index cc68e4dc348..c9355e47a56 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts @@ -1,90 +1,23 @@ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' import { workspaceFileStyleContract } from '@/lib/api/contracts/workspace-files' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' -import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileErrorPolicies, internalFilePresenters } from '@/lib/workspace-files/api' +import { styleWorkspaceFile } from '@/lib/workspace-files/application/style-workspace-file' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -const logger = createLogger('WorkspaceFileStyleAPI') - -/** - * GET /api/workspaces/[id]/files/[fileId]/style - * Extract a compact JSON style summary from an uploaded .docx, .pptx, or .pdf file. - * OOXML files return theme colors, font pair, and named styles. - * PDF files return page dimensions and embedded font names. - */ -const MAX_STYLE_FILE_BYTES = 100 * 1024 * 1024 // 100 MB - -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(workspaceFileStyleContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - - const membership = await verifyWorkspaceMembership(session.user.id, workspaceId) - if (!membership) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const fileRecord = await getWorkspaceFile(workspaceId, fileId) - if (!fileRecord) { - return NextResponse.json({ error: 'File not found' }, { status: 404 }) - } - - const rawExt = fileRecord.name.split('.').pop()?.toLowerCase() - if (rawExt !== 'docx' && rawExt !== 'pptx' && rawExt !== 'pdf') { - return NextResponse.json( - { error: 'Style extraction supports .docx, .pptx, and .pdf files' }, - { status: 422 } - ) - } - const ext: 'docx' | 'pptx' | 'pdf' = rawExt - - if (fileRecord.size > MAX_STYLE_FILE_BYTES) { - return NextResponse.json( - { error: 'File is too large for style extraction (limit: 100 MB)' }, - { status: 422 } - ) - } - - let buffer: Buffer - try { - buffer = await fetchWorkspaceFileBuffer(fileRecord) - } catch (err) { - logger.error('Failed to download file for style extraction', { - fileId, - error: toError(err).message, - }) - return NextResponse.json({ error: 'Failed to read file' }, { status: 500 }) - } - - const summary = await extractDocumentStyle(buffer, ext) - if (!summary) { - return NextResponse.json( - { - error: - 'Could not extract style — file may be encrypted, corrupt, image-only, or contain no parseable style information', - }, - { status: 422 } - ) - } - - logger.info('Extracted style summary via API', { fileId, format: ext }) - - return NextResponse.json(summary, { - headers: { 'Cache-Control': 'private, max-age=300' }, - }) - } -) +export const GET = defineInternalJsonRoute({ + contract: workspaceFileStyleContract, + auth: internalSessionAuth, + operation: styleWorkspaceFile.operation, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal style behavior' }), + errorPolicy: internalFileErrorPolicies.style, + mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }), + useCase: styleWorkspaceFile, + present: internalFilePresenters.style, + responseHeaders: () => ({ 'Cache-Control': 'private, max-age=300' }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.test.ts new file mode 100644 index 00000000000..7c973596afc --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.test.ts @@ -0,0 +1,79 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + execute: vi.fn(), + captureServerEvent: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) +vi.mock('@/lib/workspace-files/application/archive-workspace-file-items', () => ({ + archiveWorkspaceFileItemsOperation: { + operation: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.execute, + }, +})) + +import { POST } from '@/app/api/workspaces/[id]/files/bulk-archive/route' + +const WORKSPACE_ID = 'workspace-1' +const context = { params: Promise.resolve({ id: WORKSPACE_ID }) } + +function request(body: unknown) { + return new NextRequest( + `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/bulk-archive`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + } + ) +} + +describe('/api/workspaces/[id]/files/bulk-archive', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) + mocks.execute.mockResolvedValue({ deletedItems: { files: 2, folders: 1 } }) + }) + + it('archives selected files and folders through the shared operation', async () => { + const response = await POST( + request({ fileIds: ['wf_1', 'wf_2'], folderIds: ['folder-1'] }), + context + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + deletedItems: { files: 2, folders: 1 }, + }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: WORKSPACE_ID, + fileIds: ['wf_1', 'wf_2'], + folderIds: ['folder-1'], + }, + request: expect.anything(), + }) + expect(mocks.captureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'file_bulk_deleted', + { workspace_id: WORKSPACE_ID, file_count: 2, folder_count: 1 }, + { groups: { workspace: WORKSPACE_ID } } + ) + }) + + it('rejects an empty selection before the use case', async () => { + const response = await POST(request({}), context) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts index 1f47f650bd6..b4d529d1945 100644 --- a/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts @@ -1,70 +1,27 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { bulkArchiveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileAnalytics, internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { archiveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/archive-workspace-file-items' +import { fileOperations } from '@/lib/workspace-files/application/operations' -const logger = createLogger('WorkspaceFileBulkArchiveAPI') - -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(bulkArchiveWorkspaceFileItemsContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - const { fileIds, folderIds } = parsed.data.body - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const result = await performDeleteWorkspaceFileItems({ - workspaceId, - userId: session.user.id, - fileIds, - folderIds, - }) - if (!result.success) { - return NextResponse.json( - { success: false, error: result.error }, - { - status: - result.errorCode === 'validation' - ? 400 - : result.errorCode === 'not_found' - ? 404 - : 500, - } - ) - } - if (!result.deletedItems) { - return NextResponse.json( - { success: false, error: 'Failed to delete workspace file items' }, - { status: 500 } - ) - } - - captureServerEvent( - session.user.id, - 'file_bulk_deleted', - { workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length }, - { groups: { workspace: workspaceId } } - ) - - return NextResponse.json({ success: true, deletedItems: result.deletedItems }) - } catch (error) { - logger.error('Failed to bulk archive workspace file items:', error) - return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) - } - } -) +export const POST = defineInternalJsonRoute({ + contract: bulkArchiveWorkspaceFileItemsContract, + auth: internalSessionAuth, + operation: fileOperations.delete, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal bulk archive behavior', + }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params, body }) => ({ + workspaceId: params.id, + fileIds: body.fileIds, + folderIds: body.folderIds, + }), + useCase: archiveWorkspaceFileItemsOperation, + onSuccess: internalFileAnalytics.bulkDeleted, + present: ({ deletedItems }) => ({ success: true, deletedItems }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.integration.test.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.integration.test.ts index d7a871fbcbf..2636bde5ac1 100644 --- a/apps/sim/app/api/workspaces/[id]/files/download/route.integration.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/download/route.integration.test.ts @@ -20,13 +20,15 @@ const run = promisify(execFile) const { mockGetSession, - mockVerifyWorkspaceMembership, + mockLoadWorkspaceFileOperationContext, + mockResolvePermission, mockListWorkspaceFiles, mockListFolders, mockDownloadFileStream, } = vi.hoisted(() => ({ mockGetSession: vi.fn(), - mockVerifyWorkspaceMembership: vi.fn(), + mockLoadWorkspaceFileOperationContext: vi.fn(), + mockResolvePermission: vi.fn(), mockListWorkspaceFiles: vi.fn(), mockListFolders: vi.fn(), mockDownloadFileStream: vi.fn(), @@ -36,8 +38,14 @@ vi.mock('@/lib/auth', () => ({ auth: { api: { getSession: vi.fn() } }, getSession: mockGetSession, })) -vi.mock('@/app/api/workflows/utils', () => ({ - verifyWorkspaceMembership: mockVerifyWorkspaceMembership, +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mockResolvePermission, })) vi.mock('@/lib/uploads/contexts/workspace', () => ({ listWorkspaceFiles: mockListWorkspaceFiles, @@ -45,6 +53,7 @@ vi.mock('@/lib/uploads/contexts/workspace', () => ({ buildWorkspaceFileFolderPathMap: (folders: Array<{ id: string; name: string }>) => new Map(folders.map((folder) => [folder.id, folder.name])), fetchServableWorkspaceFileBuffer: vi.fn(), + loadWorkspaceFileOperationContext: mockLoadWorkspaceFileOperationContext, })) vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFileStream: mockDownloadFileStream, @@ -88,8 +97,14 @@ afterAll(async () => { describe('workspace files download — real archive', () => { beforeEach(() => { vi.clearAllMocks() - mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - mockVerifyWorkspaceMembership.mockResolvedValue({ role: 'member' }) + mockGetSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) + mockResolvePermission.mockResolvedValue('admin') + mockLoadWorkspaceFileOperationContext.mockResolvedValue({ + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'user-1', + }) mockListFolders.mockResolvedValue([{ id: 'folder-1', name: 'Reports', parentId: null }]) // A real fs stream, not a single pre-made buffer. mockDownloadFileStream.mockImplementation(async () => createReadStream(bigPath)) diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.test.ts index 41dc0680b7b..538c3aa196e 100644 --- a/apps/sim/app/api/workspaces/[id]/files/download/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/download/route.test.ts @@ -1,80 +1,41 @@ /** * @vitest-environment node */ -import { Readable } from 'stream' +import { Readable } from 'node:stream' import { createMockRequest } from '@sim/testing' import JSZip from 'jszip' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockGetSession, - mockVerifyWorkspaceMembership, - mockListWorkspaceFiles, - mockListWorkspaceFileFolders, - mockFetchServableWorkspaceFileBuffer, - mockDownloadFileStream, -} = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockVerifyWorkspaceMembership: vi.fn(), - mockListWorkspaceFiles: vi.fn(), - mockListWorkspaceFileFolders: vi.fn(), - mockFetchServableWorkspaceFileBuffer: vi.fn(), - mockDownloadFileStream: vi.fn(), -})) +const { mockGetSession, mockDownloadItems, mockDownloadFileStream, mockCaptureServerEvent } = + vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockDownloadItems: vi.fn(), + mockDownloadFileStream: vi.fn(), + mockCaptureServerEvent: vi.fn(), + })) vi.mock('@/lib/auth', () => ({ auth: { api: { getSession: vi.fn() } }, getSession: mockGetSession, })) -vi.mock('@/app/api/workflows/utils', () => ({ - verifyWorkspaceMembership: mockVerifyWorkspaceMembership, -})) - -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - listWorkspaceFiles: mockListWorkspaceFiles, - listWorkspaceFileFolders: mockListWorkspaceFileFolders, - buildWorkspaceFileFolderPathMap: (folders: Array<{ id: string; name: string }>) => - new Map(folders.map((folder) => [folder.id, folder.name])), - fetchServableWorkspaceFileBuffer: mockFetchServableWorkspaceFileBuffer, +vi.mock('@/lib/workspace-files/application/download-workspace-file-items', () => ({ + downloadWorkspaceFileItems: { + operation: { id: 'files.download', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mockDownloadItems, + }, })) vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFileStream: mockDownloadFileStream, })) -vi.mock('@sim/audit', () => ({ - recordAudit: vi.fn(), - AuditAction: { FILE_DOWNLOADED: 'file.downloaded' }, - AuditResourceType: { FILE: 'file' }, -})) - -vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) -import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile' -import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { GET } from '@/app/api/workspaces/[id]/files/download/route' const WORKSPACE_ID = 'ws-1' const context = { params: Promise.resolve({ id: WORKSPACE_ID }) } -const MB = 1024 * 1024 - -function workspaceFile(id: string, name: string, folderId: string | null = 'folder-1') { - return { - id, - name, - key: `workspace/${WORKSPACE_ID}/${id}`, - path: `/serve/${id}`, - size: 100, - type: 'application/octet-stream', - folderId, - } -} - -/** A file whose stored bytes are a generator source, so it must be resolved. */ -function generatedDocument(id: string, name: string, folderId: string | null = 'folder-1') { - return { ...workspaceFile(id, name, folderId), type: 'text/x-docxjs' } -} function requestFor(query: string) { return createMockRequest( @@ -85,265 +46,85 @@ function requestFor(query: string) { ) } +function result(files: Array<{ id: string; name: string; folderId: string | null }>) { + return { + filesToZip: files.map((file) => ({ + ...file, + key: `workspace/${WORKSPACE_ID}/${file.id}`, + size: 5, + storageContext: 'workspace', + })), + folderPaths: new Map([['folder-1', 'Reports']]), + renderedDocuments: new Map(), + declaredBytes: files.length * 5, + } +} + async function zipFrom(response: Response) { return JSZip.loadAsync(Buffer.from(await response.arrayBuffer())) } -describe('workspace files download route', () => { +describe('GET /api/workspaces/[id]/files/download', () => { beforeEach(() => { vi.clearAllMocks() - mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - mockVerifyWorkspaceMembership.mockResolvedValue({ role: 'member' }) - mockListWorkspaceFileFolders.mockResolvedValue([ - { id: 'folder-1', name: 'Reports', parentId: null }, - ]) + mockGetSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) + mockDownloadItems.mockResolvedValue(result([{ id: 'f1', name: 'clip.mp4', folderId: null }])) mockDownloadFileStream.mockImplementation(async () => Readable.from([Buffer.from('plain')])) }) - it('zips the rendered bytes for a generated doc, not its stored source', async () => { - mockListWorkspaceFiles.mockResolvedValue([generatedDocument('f1', 'overview.docx')]) - // A real .docx is a ZIP; the stored source would be plain JS text. - const rendered = Buffer.from('PKrendered-docx') - mockFetchServableWorkspaceFileBuffer.mockResolvedValue({ - buffer: rendered, - contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - }) - + it('streams a zip assembled from the shared download use case result', async () => { const response = await GET(requestFor('fileIds=f1'), context) expect(response.status).toBe(200) - const entry = (await zipFrom(response)).file('Reports/overview.docx') - expect(entry).not.toBeNull() - expect(Buffer.from(await entry!.async('uint8array'))).toEqual(rendered) - }) - - it('streams ordinary files instead of materializing them', async () => { - mockListWorkspaceFiles.mockResolvedValue([workspaceFile('f1', 'clip.mp4')]) - - const response = await GET(requestFor('fileIds=f1'), context) - - expect(response.status).toBe(200) - // Nothing has been read yet: the entry opens its storage read only once the - // consumer pulls the archive, which is what keeps peak memory to one entry. - expect(mockDownloadFileStream).not.toHaveBeenCalled() - + expect(response.headers.get('Content-Type')).toBe('application/zip') + expect(response.headers.get('Content-Disposition')).toBe( + 'attachment; filename="workspace-files.zip"' + ) + expect(response.headers.get('Cache-Control')).toBe('no-store') const zip = await zipFrom(response) - - expect(mockDownloadFileStream).toHaveBeenCalledTimes(1) - // Never routed through the buffering document reader. - expect(mockFetchServableWorkspaceFileBuffer).not.toHaveBeenCalled() - - const entry = zip.file('Reports/clip.mp4') - expect(entry).not.toBeNull() - expect(await entry!.async('string')).toBe('plain') + expect(await zip.file('clip.mp4')?.async('string')).toBe('plain') }) - it('preserves nested folder paths across both entry kinds', async () => { - mockListWorkspaceFileFolders.mockResolvedValue([ - { id: 'folder-1', name: 'Reports', parentId: null }, - { id: 'folder-2', name: 'visuals', parentId: 'folder-1' }, - ]) - mockListWorkspaceFiles.mockResolvedValue([ - generatedDocument('f1', 'summary.docx', 'folder-1'), - workspaceFile('f2', 'hero.png', 'folder-2'), + it('preserves rendered entries and folder paths supplied by the use case', async () => { + const archiveResult = result([ + { id: 'f1', name: 'overview.docx', folderId: 'folder-1' }, + { id: 'f2', name: 'hero.png', folderId: null }, ]) - mockFetchServableWorkspaceFileBuffer.mockResolvedValue({ - buffer: Buffer.from('PKdoc'), - contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - }) + archiveResult.renderedDocuments.set('f1', Buffer.from('rendered')) + mockDownloadItems.mockResolvedValue(archiveResult) const zip = await zipFrom(await GET(requestFor('fileIds=f1&fileIds=f2'), context)) - expect(zip.file('Reports/summary.docx')).not.toBeNull() - expect(zip.file('visuals/hero.png')).not.toBeNull() - }) - - it('returns 409 naming the documents whose artifacts are still compiling', async () => { - mockListWorkspaceFiles.mockResolvedValue([ - generatedDocument('f1', 'ready.docx'), - generatedDocument('f2', 'pending.docx'), - ]) - mockFetchServableWorkspaceFileBuffer.mockImplementation(async (file: { name: string }) => { - if (file.name === 'pending.docx') - throw new DocCompileUserError('Document is still being generated') - return { buffer: Buffer.from('PKok'), contentType: 'application/octet-stream' } - }) - - const response = await GET(requestFor('fileIds=f1&fileIds=f2'), context) - - expect(response.status).toBe(409) - const body = await response.json() - expect(body.error).toContain('pending.docx') - expect(body.error).not.toContain('ready.docx') - }) - - it('rejects with 400, not 500, when a document blows its own allowance', async () => { - mockListWorkspaceFiles.mockResolvedValue([generatedDocument('f1', 'huge.docx')]) - mockFetchServableWorkspaceFileBuffer.mockRejectedValue( - new PayloadSizeLimitError({ label: 'servable file download', maxBytes: 1 }) - ) - - const response = await GET(requestFor('fileIds=f1'), context) - - expect(response.status).toBe(400) - const body = await response.json() - expect(body.error).toContain('huge.docx') - expect(body.error).not.toContain('Selected files total') + expect(await zip.file('Reports/overview.docx')?.async('string')).toBe('rendered') + expect(await zip.file('hero.png')?.async('string')).toBe('plain') }) - it('counts streamed files against the same budget as rendered documents', async () => { - // 200 MB of ordinary files leaves 50 MB of the 250 MB budget for documents. - mockListWorkspaceFiles.mockResolvedValue([ - { ...workspaceFile('f1', 'clip.mp4'), size: 200 * MB }, - generatedDocument('f2', 'report.docx'), - ]) - mockFetchServableWorkspaceFileBuffer.mockResolvedValue({ - buffer: Buffer.from('PKdoc'), - contentType: 'application/octet-stream', - }) - - await zipFrom(await GET(requestFor('fileIds=f1&fileIds=f2'), context)) - - // Without reserving the streamed bytes the document would get the full 50 MB - // ceiling, letting the archive ship 250 MB of documents on top of 200 MB of video. - expect(mockFetchServableWorkspaceFileBuffer.mock.calls[0][1].maxBytes).toBe(50 * MB) - }) + it('preserves the internal product analytics event after authorization and selection', async () => { + await GET(requestFor('fileIds=f1'), context) - it('rejects when streamed files leave no budget for a document', async () => { - mockListWorkspaceFiles.mockResolvedValue([ - { ...workspaceFile('f1', 'clip.mp4'), size: 249 * MB }, - generatedDocument('f2', 'report.docx'), - ]) - mockFetchServableWorkspaceFileBuffer.mockRejectedValue( - new PayloadSizeLimitError({ label: 'servable file download', maxBytes: 1 }) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'file_downloaded', + { workspace_id: WORKSPACE_ID, is_bulk: true, file_count: 1 }, + { groups: { workspace: WORKSPACE_ID } } ) - - const response = await GET(requestFor('fileIds=f1&fileIds=f2'), context) - - expect(response.status).toBe(400) - const body = await response.json() - expect(body.error).toContain('once documents are rendered') - // No byte count: the rendered total is not knowable, so quoting one would mislead. - expect(body.error).not.toContain('Selected files total') }) - it('blames the shared budget once earlier documents have consumed it', async () => { - mockListWorkspaceFiles.mockResolvedValue([ - generatedDocument('f1', 'first.docx'), - generatedDocument('f2', 'second.docx'), - ]) - mockFetchServableWorkspaceFileBuffer.mockImplementation(async (file: { name: string }) => { - // The first document eats the whole budget, so the second's cap is the remainder. - if (file.name === 'first.docx') { - return { buffer: Buffer.alloc(240 * MB), contentType: 'application/octet-stream' } - } - throw new PayloadSizeLimitError({ label: 'servable file download', maxBytes: 1 }) - }) - - const response = await GET(requestFor('fileIds=f1&fileIds=f2'), context) - - expect(response.status).toBe(400) - const body = await response.json() - expect(body.error).toContain('once documents are rendered') - expect(body.error).not.toContain('second.docx') - }) - - it.each([ - ['text/x-docxjs', 'report.docx'], - ['text/x-pptxgenjs', 'deck.pptx'], - ['text/x-pdflibjs', 'isolated.pdf'], - ['text/x-python-pdf', 'sandboxed.pdf'], - ['text/x-python-xlsx', 'sheet.xlsx'], - ])('resolves %s rather than streaming its source', async (type, name) => { - // Both PDF generators must be covered: the isolated-vm path stores pdf-lib JS and - // the E2B path stores Python, and either one streamed raw is the corruption bug. - mockListWorkspaceFiles.mockResolvedValue([{ ...workspaceFile('f1', name), type }]) - mockFetchServableWorkspaceFileBuffer.mockResolvedValue({ - buffer: Buffer.from('PKrendered'), - contentType: 'application/octet-stream', - }) - - await zipFrom(await GET(requestFor('fileIds=f1'), context)) - - expect(mockFetchServableWorkspaceFileBuffer).toHaveBeenCalledTimes(1) - expect(mockDownloadFileStream).not.toHaveBeenCalled() - }) - - it('streams an uploaded office file rather than resolving it', async () => { - // A real upload serves exactly its stored bytes, so it must not take the buffered - // path — otherwise a selection of large decks is held in memory for nothing. - const upload = { - ...workspaceFile('f1', 'deck.pptx'), - size: 80 * MB, - type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - } - mockListWorkspaceFiles.mockResolvedValue([upload]) + it('authenticates before parsing and dispatching the use case', async () => { + mockGetSession.mockResolvedValue(null) const response = await GET(requestFor('fileIds=f1'), context) - await zipFrom(response) - expect(response.status).toBe(200) - expect(mockFetchServableWorkspaceFileBuffer).not.toHaveBeenCalled() - expect(mockDownloadFileStream).toHaveBeenCalledTimes(1) + expect(response.status).toBe(401) + expect(mockDownloadItems).not.toHaveBeenCalled() }) - it('caps a generated document at the render headroom', async () => { - mockListWorkspaceFiles.mockResolvedValue([generatedDocument('f1', 'report.docx')]) - mockFetchServableWorkspaceFileBuffer.mockResolvedValue({ - buffer: Buffer.from('PKdoc'), - contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - }) + it('keeps application validation errors in the legacy error envelope', async () => { + mockDownloadItems.mockRejectedValue(new Error('should not be raw')) - await zipFrom(await GET(requestFor('fileIds=f1'), context)) - - expect(mockFetchServableWorkspaceFileBuffer.mock.calls[0][1].maxBytes).toBe(50 * MB) - }) - - it('surfaces a storage failure as a 500 even when another document is pending', async () => { - mockListWorkspaceFiles.mockResolvedValue([ - generatedDocument('f1', 'pending.docx'), - generatedDocument('f2', 'broken.docx'), - ]) - mockFetchServableWorkspaceFileBuffer.mockImplementation(async (file: { name: string }) => { - if (file.name === 'pending.docx') - throw new DocCompileUserError('Document is still being generated') - throw new Error('storage down') - }) - - const response = await GET(requestFor('fileIds=f1&fileIds=f2'), context) - - // A 409 would tell the client to retry something that can never succeed. - expect(response.status).toBe(500) - }) - - it('stops resolving documents once one hard-fails', async () => { - const files = Array.from({ length: 20 }, (_, index) => - generatedDocument(`f${index}`, `doc${index}.docx`) - ) - mockListWorkspaceFiles.mockResolvedValue(files) - mockFetchServableWorkspaceFileBuffer.mockImplementation(async (file: { name: string }) => { - if (file.name === 'doc0.docx') throw new Error('storage down') - return { buffer: Buffer.from('PKok'), contentType: 'application/octet-stream' } - }) - - const response = await GET( - requestFor(files.map((file) => `fileIds=${file.id}`).join('&')), - context - ) + const response = await GET(requestFor('fileIds=f1'), context) expect(response.status).toBe(500) - expect(mockFetchServableWorkspaceFileBuffer.mock.calls.length).toBeLessThan(files.length) - }) - - it('rejects a selection whose declared sizes already exceed the limit', async () => { - mockListWorkspaceFiles.mockResolvedValue([ - { ...workspaceFile('f1', 'a.mp4'), size: 200 * MB }, - { ...workspaceFile('f2', 'b.mp4'), size: 200 * MB }, - ]) - - const response = await GET(requestFor('fileIds=f1&fileIds=f2'), context) - - expect(response.status).toBe(400) - expect(mockDownloadFileStream).not.toHaveBeenCalled() + expect(await response.json()).toEqual({ error: 'Internal server error' }) }) }) diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.ts index 67b070f303f..6d4220fd1a2 100644 --- a/apps/sim/app/api/workspaces/[id]/files/download/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/download/route.ts @@ -1,57 +1,21 @@ import { Readable } from 'node:stream' -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { ZipArchive } from 'archiver' -import { type NextRequest, NextResponse } from 'next/server' import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' +import { + defineInternalBinaryRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' -import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' -import { - buildWorkspaceFileFolderPathMap, - fetchServableWorkspaceFileBuffer, - listWorkspaceFileFolders, - listWorkspaceFiles, -} from '@/lib/uploads/contexts/workspace' import { downloadFileStream } from '@/lib/uploads/core/storage-service' -import { - formatFileSize, - isGeneratedDocumentSourceType, - isRenderableDocumentName, - MAX_RENDERED_DOCUMENT_BYTES, -} from '@/lib/uploads/utils/file-utils' -import { docNotReadyMessage, isDocNotReadyError } from '@/lib/uploads/utils/servable-file-response' import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path' -import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' +import { internalFileAnalytics, internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { downloadWorkspaceFileItems } from '@/lib/workspace-files/application/download-workspace-file-items' const logger = createLogger('WorkspaceFilesDownloadAPI') -const MAX_ZIP_DOWNLOAD_FILES = 100 -const MAX_ZIP_DOWNLOAD_BYTES = 250 * 1024 * 1024 - -/** - * Whether this entry's stored bytes are a generation source that has to be resolved - * before it can go in the archive. An ordinary uploaded `.docx` serves exactly what is - * stored, so it streams like anything else — routing every office extension through the - * buffered path would hold a whole selection of real documents in memory for a check - * the resolver settles on the first few magic bytes. Metadata without a type falls back - * to the extension: better to resolve and pass through than to stream source text under - * a document name. - */ -function needsRendering(file: WorkspaceFileRecord): boolean { - return file.type ? isGeneratedDocumentSourceType(file.type) : isRenderableDocumentName(file.name) -} -/** - * A `Readable` that opens its storage read on first pull rather than up front. The - * archiver works through entries sequentially, so handing it an open stream per entry - * would hold a connection per selected file — more than the storage client pools — with - * most sitting idle until their turn. The generator body does not run until the first - * read, and a failure to open surfaces as the stream's `error` event. - */ function lazyWorkspaceFileStream(file: WorkspaceFileRecord): Readable { return Readable.from( (async function* () { @@ -60,204 +24,48 @@ function lazyWorkspaceFileStream(file: WorkspaceFileRecord): Readable { context: file.storageContext ?? 'workspace', }) })(), - // `Readable.from` defaults to object mode; these are bytes headed for an archive. { objectMode: false } ) } -function selectionTooLargeResponse(bytes: number): NextResponse { - return NextResponse.json( - { - error: `Selected files total ${formatFileSize(bytes)}, which exceeds the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit.`, - }, - { status: 400 } - ) -} - -/** - * The rendered archive would exceed the limit even though the declared sizes did not — - * generated documents render to more than the source they declared, so no accurate byte - * count exists to quote here. - */ -function archiveTooLargeResponse(): NextResponse { - return NextResponse.json( - { - error: `The selected files exceed the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit once documents are rendered. Select fewer files.`, - }, - { status: 400 } - ) -} - -function collectDescendantFolderIds( - selectedFolderIds: string[], - folders: Array<{ id: string; parentId: string | null }> -): Set { - const folderIds = new Set(selectedFolderIds) - let changed = true - while (changed) { - changed = false - for (const folder of folders) { - if (folder.parentId && folderIds.has(folder.parentId) && !folderIds.has(folder.id)) { - folderIds.add(folder.id) - changed = true - } - } - } - return folderIds -} - -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(downloadWorkspaceFileItemsContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - const { fileIds, folderIds } = parsed.data.query - - const permission = await verifyWorkspaceMembership(session.user.id, workspaceId) - if (!permission) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const [files, folders] = await Promise.all([ - listWorkspaceFiles(workspaceId, { hydrateFolderPaths: false }), - listWorkspaceFileFolders(workspaceId), - ]) - const folderPaths = buildWorkspaceFileFolderPathMap(folders) - const selectedFolderIds = collectDescendantFolderIds(folderIds, folders) - const requestedFileIds = new Set(fileIds) - const filesToZip = files.filter( - (file) => - requestedFileIds.has(file.id) || (file.folderId && selectedFolderIds.has(file.folderId)) - ) - - if (filesToZip.length === 0) { - return NextResponse.json({ error: 'No files selected for download' }, { status: 400 }) - } - - if (filesToZip.length > MAX_ZIP_DOWNLOAD_FILES) { - return NextResponse.json( - { - error: `Too many files selected for download. Select ${MAX_ZIP_DOWNLOAD_FILES} or fewer files.`, - }, - { status: 400 } - ) - } - - const declaredBytes = filesToZip.reduce((sum, file) => sum + file.size, 0) - if (declaredBytes > MAX_ZIP_DOWNLOAD_BYTES) { - return selectionTooLargeResponse(declaredBytes) - } - - // Streamed entries ship exactly what they declared, so their share of the budget is - // known up front and is reserved here. Documents then draw from what is left — - // one budget across both kinds, or the archive could ship two full limits' worth. - const reservedForStreamed = filesToZip - .filter((file) => !needsRendering(file)) - .reduce((sum, file) => sum + file.size, 0) - - // Generated documents are resolved before the archive starts: once the first byte - // is written the status code is committed, so anything that can still fail the - // request has to fail here. Their buffers are held until the archive is assembled, - // bounded by what is left of the request's byte budget. - const renderedDocuments = new Map() - const pendingNames: string[] = [] - let renderedBytes = 0 - - for (const file of filesToZip) { - if (!needsRendering(file)) continue - - const remaining = Math.max(0, MAX_ZIP_DOWNLOAD_BYTES - reservedForStreamed - renderedBytes) - // A source's declared size says nothing about what it renders to, so the cap is - // the per-document ceiling, bounded by what is left of the budget. - const allowance = Math.min(remaining, MAX_RENDERED_DOCUMENT_BYTES) - - try { - const { buffer } = await fetchServableWorkspaceFileBuffer(file, { maxBytes: allowance }) - renderedBytes += buffer.length - renderedDocuments.set(file.id, buffer) - } catch (error) { - if (error instanceof PayloadSizeLimitError) { - // Blamed on the entry when its own ceiling was the binding cap; otherwise the - // documents ahead of it have consumed the budget. - return allowance === MAX_RENDERED_DOCUMENT_BYTES - ? NextResponse.json( - { - error: `"${file.name}" renders to more than ${formatFileSize(MAX_RENDERED_DOCUMENT_BYTES)} and is too large to include in a zip; download it on its own instead.`, - }, - { status: 400 } - ) - : archiveTooLargeResponse() - } - // Pending artifacts are collected so the 409 can name all of them; anything - // else dooms the request and waiting cannot fix it. - if (!isDocNotReadyError(error)) throw error - pendingNames.push(file.name) - } - } - - if (pendingNames.length > 0) { - return NextResponse.json({ error: docNotReadyMessage(pendingNames) }, { status: 409 }) - } - - // Entry paths stay workspace-root-relative so a mixed selection of folders and - // loose files keeps the layout the user sees in the files list. - const entryPaths = buildZipEntryPaths( - filesToZip.map((file) => ({ - name: file.name, - folderPath: file.folderId ? folderPaths.get(file.folderId) : null, - })) - ) - - // Ordinary files are never materialized: each entry opens its storage read only - // when the archiver reaches it, so one entry is resident rather than the archive. - const archive = new ZipArchive({ store: true }) - archive.on('warning', (error: Error) => { - logger.warn('Archive warning while streaming workspace files', { error }) - }) - - filesToZip.forEach((file, index) => { - const rendered = renderedDocuments.get(file.id) - archive.append(rendered ?? lazyWorkspaceFileStream(file), { name: entryPaths[index] }) - }) - archive.finalize().catch((error) => { - // The archive's `error` event already fails the response stream; this keeps the - // same failure from also surfacing as an unhandled rejection. - logger.error('Failed to finalize workspace file archive', { error }) - }) - - recordAudit({ - workspaceId, - actorId: session.user.id, - action: AuditAction.FILE_DOWNLOADED, - resourceType: AuditResourceType.FILE, - description: `Downloaded ${filesToZip.length} file${filesToZip.length === 1 ? '' : 's'} as zip`, - metadata: { fileCount: filesToZip.length, totalBytes: declaredBytes }, - request, - }) - captureServerEvent( - session.user.id, - 'file_downloaded', - { workspace_id: workspaceId, is_bulk: true, file_count: filesToZip.length }, - { groups: { workspace: workspaceId } } - ) - - // No Content-Length: the archive size is not known until it has been produced. - return new NextResponse(nodeReadableToWebStream(archive), { - headers: { - 'Content-Type': 'application/zip', - 'Content-Disposition': 'attachment; filename="workspace-files.zip"', - 'Cache-Control': 'no-store', - }, +export const GET = defineInternalBinaryRoute({ + contract: downloadWorkspaceFileItemsContract, + auth: internalSessionAuth, + operation: downloadWorkspaceFileItems.operation, + rateLimit: internalRateLimits.none({ reason: 'Internal workspace zip download' }), + errorPolicy: internalFileErrorPolicies.downloadArchive, + mapInput: ({ params, query }) => ({ + workspaceId: params.id, + fileIds: query.fileIds, + folderIds: query.folderIds, + }), + useCase: downloadWorkspaceFileItems, + onSuccess: internalFileAnalytics.bulkDownloaded, + present: ({ filesToZip, folderPaths, renderedDocuments }) => { + const entryPaths = buildZipEntryPaths( + filesToZip.map((file) => ({ + name: file.name, + folderPath: file.folderId ? folderPaths.get(file.folderId) : null, + })) + ) + const archive = new ZipArchive({ store: true }) + archive.on('warning', (error: Error) => { + logger.warn('Archive warning while streaming workspace files', { error }) + }) + filesToZip.forEach((file, index) => { + archive.append(renderedDocuments.get(file.id) ?? lazyWorkspaceFileStream(file), { + name: entryPaths[index], }) - } catch (error) { - logger.error('Failed to download workspace file selection:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + }) + archive.finalize().catch((error) => { + logger.error('Failed to finalize workspace file archive', { error }) + }) + + return { + body: nodeReadableToWebStream(archive), + contentType: 'application/zip', + contentDisposition: 'attachment; filename="workspace-files.zip"', + headers: { 'Cache-Control': 'no-store' }, } - } -) + }, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts index ecf7b17b281..9c492438d8d 100644 --- a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts @@ -1,64 +1,24 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { restoreWorkspaceFileFolderContract } from '@/lib/api/contracts/workspace-file-folders' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { statusForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { performRestoreWorkspaceFileFolder } from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { + defineInternalJsonRoute, + internalJsonPresenters, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileAnalytics, internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { restoreWorkspaceFileFolderOperation } from '@/lib/workspace-files/application/workspace-file-folders' -const logger = createLogger('WorkspaceFileFolderRestoreAPI') - -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(restoreWorkspaceFileFolderContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, folderId } = parsed.data.params - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const result = await performRestoreWorkspaceFileFolder({ - workspaceId, - folderId, - userId: session.user.id, - }) - if (!result.success) { - return NextResponse.json( - { success: false, error: result.error }, - { status: statusForOrchestrationError(result.errorCode) } - ) - } - const { folder, restoredItems } = result - if (!folder || !restoredItems) { - return NextResponse.json( - { success: false, error: 'Failed to restore workspace file folder' }, - { status: 500 } - ) - } - - logger.info(`Restored workspace file folder: ${folderId}`) - - captureServerEvent( - session.user.id, - 'folder_restored', - { folder_id: folderId, workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - return NextResponse.json({ success: true, folder, restoredItems }) - } catch (error) { - logger.error('Failed to restore workspace file folder:', error) - return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) - } - } -) +export const POST = defineInternalJsonRoute({ + contract: restoreWorkspaceFileFolderContract, + auth: internalSessionAuth, + operation: fileOperations.restoreFolder, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal folder restore behavior', + }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params }) => ({ workspaceId: params.id, folderId: params.folderId }), + useCase: restoreWorkspaceFileFolderOperation, + onSuccess: internalFileAnalytics.folderRestored, + present: internalJsonPresenters.withSuccess, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.test.ts new file mode 100644 index 00000000000..8a9873a8eb0 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.test.ts @@ -0,0 +1,143 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + updateFolder: vi.fn(), + deleteFolder: vi.fn(), + restoreFolder: vi.fn(), + captureServerEvent: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) +vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ + updateWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.update', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.updateFolder, + }, + deleteWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.deleteFolder, + }, + restoreWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.restore', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.restoreFolder, + }, +})) + +import { POST as RESTORE } from '@/app/api/workspaces/[id]/files/folders/[folderId]/restore/route' +import { DELETE, PATCH } from '@/app/api/workspaces/[id]/files/folders/[folderId]/route' + +const WORKSPACE_ID = 'workspace-1' +const FOLDER_ID = 'folder-1' +const PRINCIPAL = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } +const context = { + params: Promise.resolve({ id: WORKSPACE_ID, folderId: FOLDER_ID }), +} +const folder = { + id: FOLDER_ID, + workspaceId: WORKSPACE_ID, + userId: 'user-1', + name: 'Reports', + parentId: null, + path: '/Reports', + sortOrder: 0, + deletedAt: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), +} +const serializedFolder = { + ...folder, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', +} + +function request(method: 'PATCH' | 'DELETE' | 'POST', body?: unknown) { + return new NextRequest( + `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/folders/${FOLDER_ID}${method === 'POST' ? '/restore' : ''}`, + { + method, + ...(body === undefined + ? {} + : { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + } + ) +} + +describe('/api/workspaces/[id]/files/folders/[folderId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) + mocks.updateFolder.mockResolvedValue({ folder }) + mocks.deleteFolder.mockResolvedValue({ deletedItems: { folders: 1, files: 2 } }) + mocks.restoreFolder.mockResolvedValue({ + folder, + restoredItems: { folders: 1, files: 2 }, + }) + }) + + it('updates a folder through the shared use case', async () => { + const response = await PATCH(request('PATCH', { name: 'Reports' }), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ success: true, folder: serializedFolder }) + expect(mocks.updateFolder).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, folderId: FOLDER_ID, name: 'Reports' }, + request: expect.anything(), + }) + expect(mocks.captureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'folder_renamed', + { workspace_id: WORKSPACE_ID }, + { groups: { workspace: WORKSPACE_ID } } + ) + }) + + it('deletes a folder through the shared use case', async () => { + const response = await DELETE(request('DELETE'), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + deletedItems: { folders: 1, files: 2 }, + }) + expect(mocks.deleteFolder).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, folderId: FOLDER_ID }, + request: expect.anything(), + }) + }) + + it('restores a folder through the shared use case', async () => { + const response = await RESTORE(request('POST'), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + folder: serializedFolder, + restoredItems: { folders: 1, files: 2 }, + }) + expect(mocks.restoreFolder).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, folderId: FOLDER_ID }, + request: expect.anything(), + }) + }) + + it('authenticates before parsing a folder mutation', async () => { + mocks.getSession.mockResolvedValueOnce(null) + + const response = await PATCH(request('PATCH', { name: 'Reports' }), context) + + expect(response.status).toBe(401) + expect(mocks.updateFolder).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts index 079f25e8459..c5873d2fdfa 100644 --- a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts @@ -1,121 +1,44 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { deleteWorkspaceFileFolderContract, updateWorkspaceFileFolderContract, } from '@/lib/api/contracts/workspace-file-folders' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { statusForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' import { - performDeleteWorkspaceFileItems, - performUpdateWorkspaceFileFolder, -} from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('WorkspaceFileFolderAPI') - -async function assertWritePermission(userId: string, workspaceId: string) { - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - return permission === 'admin' || permission === 'write' -} - -export const PATCH = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(updateWorkspaceFileFolderContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, folderId } = parsed.data.params - - if (!(await assertWritePermission(session.user.id, workspaceId))) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const result = await performUpdateWorkspaceFileFolder({ - workspaceId, - folderId, - userId: session.user.id, - ...parsed.data.body, - }) - if (!result.success || !result.folder) { - return NextResponse.json( - { success: false, error: result.error }, - { status: statusForOrchestrationError(result.errorCode) } - ) - } - captureServerEvent( - session.user.id, - 'folder_renamed', - { workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - return NextResponse.json({ success: true, folder: result.folder }) - } catch (error) { - logger.error('Failed to update workspace file folder:', error) - return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) - } - } -) - -export const DELETE = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(deleteWorkspaceFileFolderContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, folderId } = parsed.data.params - - if (!(await assertWritePermission(session.user.id, workspaceId))) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const result = await performDeleteWorkspaceFileItems({ - workspaceId, - userId: session.user.id, - folderIds: [folderId], - }) - if (!result.success) { - return NextResponse.json( - { success: false, error: result.error }, - { - status: - result.errorCode === 'validation' - ? 400 - : result.errorCode === 'not_found' - ? 404 - : 500, - } - ) - } - if (!result.deletedItems) { - return NextResponse.json( - { success: false, error: 'Failed to delete workspace file folder' }, - { status: 500 } - ) - } - - captureServerEvent( - session.user.id, - 'folder_deleted', - { workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - - return NextResponse.json({ success: true, deletedItems: result.deletedItems }) - } catch (error) { - logger.error('Failed to delete workspace file folder:', error) - return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) - } - } -) + defineInternalJsonRoute, + internalJsonPresenters, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileAnalytics, internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { + deleteWorkspaceFileFolderOperation, + updateWorkspaceFileFolderOperation, +} from '@/lib/workspace-files/application/workspace-file-folders' + +export const PATCH = defineInternalJsonRoute({ + contract: updateWorkspaceFileFolderContract, + auth: internalSessionAuth, + operation: fileOperations.updateFolder, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal folder update behavior', + }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params, body }) => ({ workspaceId: params.id, folderId: params.folderId, ...body }), + useCase: updateWorkspaceFileFolderOperation, + onSuccess: internalFileAnalytics.folderRenamed, + present: internalJsonPresenters.withSuccess, +}) + +export const DELETE = defineInternalJsonRoute({ + contract: deleteWorkspaceFileFolderContract, + auth: internalSessionAuth, + operation: fileOperations.deleteFolder, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal folder deletion behavior', + }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params }) => ({ workspaceId: params.id, folderId: params.folderId }), + useCase: deleteWorkspaceFileFolderOperation, + onSuccess: internalFileAnalytics.folderDeleted, + present: internalJsonPresenters.withSuccess, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/folders/route.test.ts new file mode 100644 index 00000000000..e9645fe19ba --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/folders/route.test.ts @@ -0,0 +1,114 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + listFolders: vi.fn(), + createFolder: vi.fn(), + captureServerEvent: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) +vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ + listWorkspaceFileFoldersOperation: { + operation: { id: 'files.folders.list', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.listFolders, + }, + createWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.create', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.createFolder, + }, +})) + +import { GET, POST } from '@/app/api/workspaces/[id]/files/folders/route' + +const WORKSPACE_ID = 'workspace-1' +const context = { params: Promise.resolve({ id: WORKSPACE_ID }) } +const folder = { + id: 'folder-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + name: 'Reports', + parentId: null, + path: '/Reports', + sortOrder: 0, + deletedAt: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), +} +const serializedFolder = { + ...folder, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', +} + +function request(method: 'GET' | 'POST', body?: unknown) { + return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/folders`, { + method, + ...(body === undefined + ? {} + : { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + }) +} + +describe('/api/workspaces/[id]/files/folders', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) + mocks.listFolders.mockResolvedValue({ folders: [folder] }) + mocks.createFolder.mockResolvedValue({ folder }) + }) + + it('authenticates before listing folders', async () => { + mocks.getSession.mockResolvedValueOnce(null) + + const response = await GET(request('GET'), context) + + expect(response.status).toBe(401) + expect(mocks.listFolders).not.toHaveBeenCalled() + }) + + it('lists folders through the shared use case', async () => { + const response = await GET(request('GET'), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ success: true, folders: [serializedFolder] }) + expect(mocks.listFolders).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: WORKSPACE_ID, scope: 'active' }, + request: expect.anything(), + }) + }) + + it('creates a folder and preserves the internal success event', async () => { + const response = await POST(request('POST', { name: 'Reports' }), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ success: true, folder: serializedFolder }) + expect(mocks.createFolder).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: WORKSPACE_ID, name: 'Reports', parentId: undefined }, + request: expect.anything(), + }) + expect(mocks.captureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'folder_created', + { workspace_id: WORKSPACE_ID }, + { groups: { workspace: WORKSPACE_ID } } + ) + }) + + it('rejects an invalid folder name before the use case', async () => { + const response = await POST(request('POST', { name: 'nested/name' }), context) + + expect(response.status).toBe(400) + expect(mocks.createFolder).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/route.ts index ba3180cb609..4165d8f42d7 100644 --- a/apps/sim/app/api/workspaces/[id]/files/folders/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/folders/route.ts @@ -1,86 +1,47 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { createWorkspaceFileFolderContract, listWorkspaceFileFoldersContract, } from '@/lib/api/contracts/workspace-file-folders' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { statusForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace' -import { performCreateWorkspaceFileFolder } from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('WorkspaceFileFoldersAPI') - -async function getWorkspacePermission(userId: string, workspaceId: string) { - return getUserEntityPermissions(userId, 'workspace', workspaceId) -} - -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(listWorkspaceFileFoldersContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - const { scope } = parsed.data.query - - const permission = await getWorkspacePermission(session.user.id, workspaceId) - if (!permission) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const folders = await listWorkspaceFileFolders(workspaceId, { scope }) - return NextResponse.json({ success: true, folders }) - } -) - -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(createWorkspaceFileFolderContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - const { name, parentId } = parsed.data.body - - const permission = await getWorkspacePermission(session.user.id, workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const result = await performCreateWorkspaceFileFolder({ - workspaceId, - userId: session.user.id, - name, - parentId, - }) - if (!result.success || !result.folder) { - return NextResponse.json( - { success: false, error: result.error }, - { status: statusForOrchestrationError(result.errorCode) } - ) - } - captureServerEvent( - session.user.id, - 'folder_created', - { workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - return NextResponse.json({ success: true, folder: result.folder }) - } catch (error) { - logger.error('Failed to create workspace file folder:', error) - return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) - } - } -) +import { + defineInternalJsonRoute, + internalJsonPresenters, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileAnalytics, internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { + createWorkspaceFileFolderOperation, + listWorkspaceFileFoldersOperation, +} from '@/lib/workspace-files/application/workspace-file-folders' + +export const GET = defineInternalJsonRoute({ + contract: listWorkspaceFileFoldersContract, + auth: internalSessionAuth, + operation: fileOperations.listFolders, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal folder listing behavior', + }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params, query }) => ({ workspaceId: params.id, scope: query.scope }), + useCase: listWorkspaceFileFoldersOperation, + present: internalJsonPresenters.withSuccess, +}) + +export const POST = defineInternalJsonRoute({ + contract: createWorkspaceFileFolderContract, + auth: internalSessionAuth, + operation: fileOperations.createFolder, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal folder creation behavior', + }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params, body }) => ({ + workspaceId: params.id, + name: body.name, + parentId: body.parentId, + }), + useCase: createWorkspaceFileFolderOperation, + onSuccess: internalFileAnalytics.folderCreated, + present: internalJsonPresenters.withSuccess, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts index 11726424281..495044427bb 100644 --- a/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts @@ -4,77 +4,83 @@ import { authMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' -const { mockGetPerms, mockResolveImage, mockDownloadFile } = vi.hoisted(() => ({ - mockGetPerms: vi.fn(), - mockResolveImage: vi.fn(), - mockDownloadFile: vi.fn(), -})) +const { mockReadInline } = vi.hoisted(() => ({ mockReadInline: vi.fn() })) -vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetPerms })) -vi.mock('@/lib/uploads/server/inline-image', () => ({ - resolveWorkspaceInlineImage: mockResolveImage, +vi.mock('@/lib/workspace-files/application/read-workspace-inline-file', () => ({ + readWorkspaceInlineFile: { + operation: { id: 'files.read_content', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mockReadInline, + }, })) -vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloadFile })) import { GET } from '@/app/api/workspaces/[id]/files/inline/route' const mockGetSession = authMockFns.mockGetSession - const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]) const params = { params: Promise.resolve({ id: 'ws-1' }) } -const req = (q: string) => new NextRequest(`http://localhost/api/workspaces/ws-1/files/inline?${q}`) +const req = (q: string) => + new NextRequest(`http://localhost/api/workspaces/ws-1/files/inline${q ? `?${q}` : ''}`) describe('GET /api/workspaces/[id]/files/inline', () => { beforeEach(() => { vi.clearAllMocks() - mockGetSession.mockResolvedValue({ user: { id: 'u1' } }) - mockGetPerms.mockResolvedValue('read') - mockResolveImage.mockResolvedValue({ - key: 'workspace/ws-1/x-photo.png', - contentType: 'image/png', - filename: 'photo.png', + mockGetSession.mockResolvedValue({ user: { id: 'u1' }, session: { id: 's1' } }) + mockReadInline.mockResolvedValue({ + file: { name: 'photo.png', type: 'image/png', size: PNG.length }, + stream: new Blob([new Uint8Array(PNG)]).stream(), }) - mockDownloadFile.mockResolvedValue(PNG) }) - it('serves a workspace-scoped image by fileId, always revalidating', async () => { + it('serves authenticated workspace-scoped content by file id', async () => { const res = await GET(req('fileId=wf_abc'), params) + expect(res.status).toBe(200) - expect(mockResolveImage).toHaveBeenCalledWith('ws-1', { fileId: 'wf_abc' }) - // Authenticated content: always revalidate so a deletion/revocation is enforced on the next request. + expect(mockReadInline).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', userId: 'u1', sessionId: 's1' }, + input: { workspaceId: 'ws-1', fileId: 'wf_abc' }, + }) + ) expect(res.headers.get('Cache-Control')).toBe('private, no-cache, must-revalidate') + expect(res.headers.get('Content-Disposition')).toBe('inline; filename="photo.png"') }) - it('serves a workspace-scoped image by key, always revalidating', async () => { - const res = await GET(req(`key=${encodeURIComponent('workspace/ws-1/x-photo.png')}`), params) + it('passes key references to the shared read use case', async () => { + const res = await GET(req('key=workspace%2Fws-1%2Fphoto.png'), params) + expect(res.status).toBe(200) - // Same policy as fileId: authenticated content never cached past a revalidation, so a deleted or - // access-revoked image drops out immediately rather than lingering in a private browser cache. - expect(res.headers.get('Cache-Control')).toBe('private, no-cache, must-revalidate') + expect(mockReadInline.mock.calls[0][0].input).toEqual({ + workspaceId: 'ws-1', + key: 'workspace/ws-1/photo.png', + fileId: undefined, + }) }) - it('404s when the reference does not resolve in the workspace (cross-workspace)', async () => { - mockResolveImage.mockResolvedValue(null) + it('returns the concealed 404 response for an unauthorized or missing file', async () => { + mockReadInline.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient permissions') + ) + const res = await GET(req('fileId=wf_other'), params) - expect(res.status).toBe(404) - }) - it('404s without workspace membership, before resolving the file', async () => { - mockGetPerms.mockResolvedValue(null) - const res = await GET(req('fileId=wf_abc'), params) expect(res.status).toBe(404) - expect(mockResolveImage).not.toHaveBeenCalled() + expect(await res.json()).toEqual({ error: 'FileNotFoundError', message: 'Not found' }) }) - it('401s without a session', async () => { + it('authenticates before parsing invalid input', async () => { mockGetSession.mockResolvedValue(null) - const res = await GET(req('fileId=wf_abc'), params) + + const res = await GET(req(''), params) + expect(res.status).toBe(401) + expect(mockReadInline).not.toHaveBeenCalled() }) - it('400s when neither key nor fileId is provided', async () => { - const res = await GET(req(''), params) + it('returns a validation response when both references are supplied', async () => { + const res = await GET(req('key=k&fileId=f'), params) expect(res.status).toBe(400) + expect(mockReadInline).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/workspaces/[id]/files/inline/route.ts b/apps/sim/app/api/workspaces/[id]/files/inline/route.ts index 245fb5731d8..ad3780eb4be 100644 --- a/apps/sim/app/api/workspaces/[id]/files/inline/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/inline/route.ts @@ -1,59 +1,53 @@ -import { createLogger } from '@sim/logger' -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' import { getInlineWorkspaceFileContract } from '@/lib/api/contracts/workspace-files' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { serveInlineImage } from '@/app/api/files/serve-inline-image' -import { createErrorResponse, FileNotFoundError } from '@/app/api/files/utils' +import { + defineInternalBinaryRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { readWorkspaceInlineFile } from '@/lib/workspace-files/application/read-workspace-inline-file' +import { encodeFilenameForHeader, getSecureFileHeaders } from '@/app/api/files/utils' export const dynamic = 'force-dynamic' -const logger = createLogger('WorkspaceInlineFileAPI') - /** * GET /api/workspaces/[id]/files/inline?key=|fileId= * - * Serves an image embedded in a workspace markdown document, **scoped to the workspace in the path**. - * The markdown editor rewrites its embedded `/api/files/serve/` and `/api/files/view/` srcs to - * this route so a referenced file resolves only within the document's workspace — a cross-workspace - * reference returns 404 and does not render, even for a viewer who belongs to the other workspace. Read - * access to the workspace is required; disposition/content-type handling mirrors the serve route. + * Serves an authenticated workspace-scoped image. Authentication and the + * `files.read_content` authorization check happen before resolving or reading + * the referenced object, preserving cross-workspace concealment. */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - try { - const parsed = await parseRequest(getInlineWorkspaceFileContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - const ref = parsed.data.query - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Authorize before disclosing anything; deny with 404 so a non-member can't probe existence. - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (!permission) { - throw new FileNotFoundError('Not found') - } - - const image = await resolveWorkspaceInlineImage(workspaceId, ref) - if (!image) { - throw new FileNotFoundError('Not found') - } - - return await serveInlineImage(image, { sniff: false }) - } catch (error) { - if (error instanceof FileNotFoundError) { - return createErrorResponse(error) - } - logger.error('Error serving workspace inline image:', error) - return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file')) +export const GET = defineInternalBinaryRoute({ + contract: getInlineWorkspaceFileContract, + auth: internalSessionAuth, + operation: readWorkspaceInlineFile.operation, + rateLimit: internalRateLimits.none({ reason: 'Internal workspace inline image delivery' }), + errorPolicy: internalFileErrorPolicies.inline, + mapInput: ({ params, query }) => ({ + workspaceId: params.id, + key: query.key, + fileId: query.fileId, + }), + useCase: readWorkspaceInlineFile, + present: ({ file, stream }) => { + const secure = getSecureFileHeaders(file.name, file.type) + const headers = new Headers({ + 'Content-Type': secure.contentType, + 'Content-Disposition': `${secure.disposition}; ${encodeFilenameForHeader(file.name)}`, + 'Cache-Control': 'private, no-cache, must-revalidate', + 'X-Content-Type-Options': 'nosniff', + }) + if (secure.contentType === 'image/svg+xml') { + headers.set( + 'Content-Security-Policy', + "default-src 'none'; style-src 'unsafe-inline'; sandbox;" + ) + } + return { + body: stream, + contentType: secure.contentType, + contentLength: file.size, + headers, } - } -) + }, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/move/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/move/route.test.ts new file mode 100644 index 00000000000..46c81932a2c --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/move/route.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + execute: vi.fn(), + captureServerEvent: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) +vi.mock('@/lib/workspace-files/application/move-workspace-file-items', () => ({ + moveWorkspaceFileItemsOperation: { + operation: { id: 'files.move', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.execute, + }, +})) + +import { POST } from '@/app/api/workspaces/[id]/files/move/route' + +const WORKSPACE_ID = 'workspace-1' +const context = { params: Promise.resolve({ id: WORKSPACE_ID }) } + +function request(body: unknown) { + return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/move`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +describe('/api/workspaces/[id]/files/move', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) + mocks.execute.mockResolvedValue({ movedItems: { files: 2, folders: 1 } }) + }) + + it('moves selected files and folders through the shared operation', async () => { + const response = await POST( + request({ fileIds: ['wf_1', 'wf_2'], folderIds: ['folder-1'], targetFolderId: null }), + context + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + movedItems: { files: 2, folders: 1 }, + }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: WORKSPACE_ID, + fileIds: ['wf_1', 'wf_2'], + folderIds: ['folder-1'], + targetFolderId: null, + }, + request: expect.anything(), + }) + expect(mocks.captureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'file_moved', + { workspace_id: WORKSPACE_ID, file_count: 2, folder_count: 1 }, + { groups: { workspace: WORKSPACE_ID } } + ) + }) + + it('rejects an empty selection before the use case', async () => { + const response = await POST(request({}), context) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('authenticates before parsing the selection', async () => { + mocks.getSession.mockResolvedValueOnce(null) + + const response = await POST(request({ fileIds: ['wf_1'] }), context) + + expect(response.status).toBe(401) + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/move/route.ts b/apps/sim/app/api/workspaces/[id]/files/move/route.ts index 81861789eee..bc219539eaf 100644 --- a/apps/sim/app/api/workspaces/[id]/files/move/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/move/route.ts @@ -1,78 +1,26 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { moveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileAnalytics, internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' +import { fileOperations } from '@/lib/workspace-files/application/operations' -const logger = createLogger('WorkspaceFileMoveAPI') - -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(moveWorkspaceFileItemsContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - const { fileIds, folderIds, targetFolderId } = parsed.data.body - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const result = await performMoveWorkspaceFileItems({ - workspaceId, - userId: session.user.id, - fileIds, - folderIds, - targetFolderId, - }) - if (!result.success || !result.movedItems) { - return NextResponse.json( - { success: false, error: result.error }, - { - status: - result.errorCode === 'conflict' - ? 409 - : result.errorCode === 'not_found' - ? 404 - : result.errorCode === 'validation' - ? 400 - : 500, - } - ) - } - if (fileIds.length > 0) { - captureServerEvent( - session.user.id, - 'file_moved', - { workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length }, - { groups: { workspace: workspaceId } } - ) - } - if (folderIds.length > 0) { - captureServerEvent( - session.user.id, - 'folder_moved', - { workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length }, - { groups: { workspace: workspaceId } } - ) - } - return NextResponse.json({ - success: true, - movedItems: result.movedItems, - }) - } catch (error) { - logger.error('Failed to move workspace file items:', error) - return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) - } - } -) +export const POST = defineInternalJsonRoute({ + contract: moveWorkspaceFileItemsContract, + auth: internalSessionAuth, + operation: fileOperations.move, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal file move behavior' }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params, body }) => ({ + workspaceId: params.id, + fileIds: body.fileIds, + folderIds: body.folderIds, + targetFolderId: body.targetFolderId, + }), + useCase: moveWorkspaceFileItemsOperation, + onSuccess: internalFileAnalytics.moved, + present: ({ movedItems }) => ({ success: true, movedItems }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/route.test.ts index 7ce4f9f1a4c..5add87b4660 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.test.ts @@ -5,59 +5,50 @@ import { authMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockGetUserEntityPermissions, - mockGetWorkspaceShares, - mockListWorkspaceFiles, - mockPerformCreateWorkspaceFile, -} = vi.hoisted(() => ({ - mockGetUserEntityPermissions: vi.fn(), - mockGetWorkspaceShares: vi.fn(), - mockListWorkspaceFiles: vi.fn(), - mockPerformCreateWorkspaceFile: vi.fn(), +const mocks = vi.hoisted(() => ({ + admitCreate: vi.fn(), + createFile: vi.fn(), + listFiles: vi.fn(), + captureServerEvent: vi.fn(), })) -vi.mock('@/lib/public-shares/share-manager', () => ({ - getWorkspaceShares: mockGetWorkspaceShares, +vi.mock('@/lib/workspace-files/application/create-workspace-file', () => ({ + admitCreateWorkspaceFile: mocks.admitCreate, + createWorkspaceFile: { + operation: { id: 'files.create', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.createFile, + }, })) -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - listWorkspaceFiles: mockListWorkspaceFiles, +vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ + listAllWorkspaceFiles: { + operation: { id: 'files.list', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.listFiles, + }, })) -vi.mock('@/lib/workspace-files/orchestration', () => ({ - MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, - performCreateWorkspaceFile: mockPerformCreateWorkspaceFile, -})) - -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: mockGetUserEntityPermissions, -})) -vi.mock('@/app/api/workflows/utils', () => ({ - verifyWorkspaceMembership: vi.fn().mockResolvedValue('write'), -})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) -import { POST } from '@/app/api/workspaces/[id]/files/route' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GET, POST } from '@/app/api/workspaces/[id]/files/route' -const WORKSPACE_ID = '7727ef3f-8cf6-4686-b063-2bb006a10785' +const WORKSPACE_ID = 'workspace-1' const USER = { id: 'user-1', name: 'Test User', email: 'test@sim.ai' } -const CREATED_FILE = { - id: 'wf_created', +const PRINCIPAL = { kind: 'session' as const, userId: USER.id, sessionId: 'session-1' } +const FILE = { + id: 'wf_1', workspaceId: WORKSPACE_ID, - name: 'untitled.md', - key: `workspace/${WORKSPACE_ID}/untitled.md`, - path: '/api/files/serve/untitled.md?context=workspace', + name: 'notes.md', + key: `workspace/${WORKSPACE_ID}/notes.md`, + path: '/api/files/serve/notes.md?context=workspace', size: 0, type: 'text/markdown', uploadedBy: USER.id, folderId: null, - folderPath: null, - deletedAt: null, uploadedAt: new Date('2026-08-04T00:00:00.000Z'), updatedAt: new Date('2026-08-04T00:00:00.000Z'), } - -const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID }) } +const context = { params: Promise.resolve({ id: WORKSPACE_ID }) } function createRequest(body: unknown): NextRequest { return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files`, { @@ -67,187 +58,102 @@ function createRequest(body: unknown): NextRequest { }) } -describe('POST /api/workspaces/[id]/files', () => { +describe('/api/workspaces/[id]/files', () => { beforeEach(() => { vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: USER }) - mockGetUserEntityPermissions.mockResolvedValue('write') - mockGetWorkspaceShares.mockResolvedValue(new Map()) - mockListWorkspaceFiles.mockResolvedValue([]) - mockPerformCreateWorkspaceFile.mockResolvedValue({ success: true, file: CREATED_FILE }) + authMockFns.mockGetSession.mockResolvedValue({ user: USER, session: { id: 'session-1' } }) + mocks.admitCreate.mockResolvedValue(undefined) + mocks.createFile.mockResolvedValue({ file: FILE }) + mocks.listFiles.mockResolvedValue({ files: [FILE] }) + }) + + it('lists files through the shared read operation', async () => { + const request = new NextRequest( + `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files?scope=archived` + ) + const response = await GET(request, context) + + expect(response.status).toBe(200) + expect((await response.json()).files).toHaveLength(1) + expect(mocks.listFiles).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, scope: 'archived' }, + request, + }) }) - it('authenticates before parsing an invalid request body', async () => { + it('authenticates before create admission or body parsing', async () => { authMockFns.mockGetSession.mockResolvedValue(null) - const response = await POST(createRequest('{not-json'), routeContext) + const response = await POST(createRequest('{not-json'), context) expect(response.status).toBe(401) - await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }) - expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + expect(mocks.admitCreate).not.toHaveBeenCalled() + expect(mocks.createFile).not.toHaveBeenCalled() }) - it('authorizes the workspace before parsing the request body', async () => { - mockGetUserEntityPermissions.mockResolvedValue('read') + it('authorizes the asserted workspace before buffering the create body', async () => { + mocks.admitCreate.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) - const response = await POST(createRequest({ content: 'missing a name' }), routeContext) + const response = await POST(createRequest('{not-json'), context) expect(response.status).toBe(403) - await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' }) - expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER.id, 'workspace', WORKSPACE_ID) - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + expect(mocks.admitCreate).toHaveBeenCalledWith(PRINCIPAL, WORKSPACE_ID) + expect(mocks.createFile).not.toHaveBeenCalled() }) - it('rejects an invalid body after workspace authorization', async () => { - const response = await POST(createRequest({ content: 'missing a name' }), routeContext) - const body = await response.json() + it('rejects malformed base64 after admission', async () => { + const response = await POST( + createRequest({ name: 'notes.md', content: 'not-base64!', encoding: 'base64' }), + context + ) expect(response.status).toBe(400) - expect(body.error).toBe('Validation error') - expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER.id, 'workspace', WORKSPACE_ID) - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + expect(mocks.admitCreate).toHaveBeenCalled() + expect(mocks.createFile).not.toHaveBeenCalled() }) - it.each(['read', null])( - 'requires write or admin permission (%s is rejected)', - async (permission) => { - mockGetUserEntityPermissions.mockResolvedValue(permission) - - const response = await POST(createRequest({ name: 'untitled.md' }), routeContext) - - expect(response.status).toBe(403) - await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' }) - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() - } - ) - - it.each(['write', 'admin'])( - 'creates an empty file with defaults for %s users', - async (permission) => { - mockGetUserEntityPermissions.mockResolvedValue(permission) - const request = createRequest({ name: 'untitled.md' }) - - const response = await POST(request, routeContext) - const body = await response.json() - - expect(response.status).toBe(201) - expect(body).toMatchObject({ success: true, file: { id: CREATED_FILE.id } }) - expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledTimes(1) - const params = mockPerformCreateWorkspaceFile.mock.calls[0][0] - expect(params).toMatchObject({ + it('creates through the shared use case and preserves internal analytics', async () => { + const request = createRequest({ name: 'notes.md', content: 'TQ==', encoding: 'base64' }) + const response = await POST(request, context) + + expect(response.status).toBe(201) + expect((await response.json()).file.id).toBe(FILE.id) + expect(mocks.createFile).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, - userId: USER.id, - actorName: USER.name, - actorEmail: USER.email, - name: 'untitled.md', + name: 'notes.md', contentType: 'text/markdown', + content: 'TQ==', + encoding: 'base64', + folderId: undefined, exactName: false, - }) - expect(params.folderId).toBeUndefined() - expect(params.content).toEqual(Buffer.alloc(0)) - expect(params.request).toBe(request) - } - ) - - it('decodes initialized base64 content and preserves folder and content type', async () => { - const content = Buffer.from([0, 1, 2, 255]) - const request = createRequest({ - name: 'data.bin', - contentType: 'application/octet-stream', - folderId: 'folder-1', - content: content.toString('base64'), - encoding: 'base64', - }) - mockPerformCreateWorkspaceFile.mockResolvedValue({ - success: true, - file: { - ...CREATED_FILE, - name: 'data.bin', - type: 'application/octet-stream', - size: content.length, - folderId: 'folder-1', }, + request, }) - - const response = await POST(request, routeContext) - - expect(response.status).toBe(201) - expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: WORKSPACE_ID, - name: 'data.bin', - contentType: 'application/octet-stream', - folderId: 'folder-1', - content, - exactName: false, - }) - ) - }) - - it('rejects malformed base64 after authorization and before orchestration', async () => { - const response = await POST( - createRequest({ name: 'data.bin', content: 'not-base64!', encoding: 'base64' }), - routeContext - ) - - expect(response.status).toBe(400) - await expect(response.json()).resolves.toMatchObject({ error: 'Validation error' }) - expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER.id, 'workspace', WORKSPACE_ID) - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() - }) - - it('accepts empty base64 as a zero-byte file', async () => { - const response = await POST( - createRequest({ name: 'empty.bin', content: '', encoding: 'base64' }), - routeContext - ) - - expect(response.status).toBe(201) - expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith( - expect.objectContaining({ content: Buffer.alloc(0) }) + expect(mocks.captureServerEvent).toHaveBeenCalledWith( + USER.id, + 'file_uploaded', + { workspace_id: WORKSPACE_ID, file_type: 'text/markdown' }, + { groups: { workspace: WORKSPACE_ID } } ) }) - it.each([ - ['validation', 400, 'Invalid file name'], - ['not_found', 404, 'Target folder not found'], - ['conflict', 409, 'A file with this name already exists'], - ['payload_too_large', 413, 'File size exceeds 50MB limit'], - ] as const)('maps a %s orchestration failure to %i', async (errorCode, expectedStatus, error) => { - mockPerformCreateWorkspaceFile.mockResolvedValue({ success: false, error, errorCode }) - - const response = await POST(createRequest({ name: 'untitled.md' }), routeContext) - - expect(response.status).toBe(expectedStatus) - await expect(response.json()).resolves.toEqual({ success: false, error }) - }) - - it('does not expose an internal orchestration error', async () => { - mockPerformCreateWorkspaceFile.mockResolvedValue({ - success: false, - error: 'update workspace_files set ... failed', - errorCode: 'internal', - }) - - const response = await POST(createRequest({ name: 'untitled.md' }), routeContext) - - expect(response.status).toBe(500) - await expect(response.json()).resolves.toEqual({ - success: false, - error: 'Failed to create file', - }) - }) - - it('maps an unexpected throw to a 500 response', async () => { - mockPerformCreateWorkspaceFile.mockRejectedValue(new Error('storage unavailable')) - - const response = await POST(createRequest({ name: 'untitled.md' }), routeContext) + it('renders typed create conflicts without exposing unknown errors', async () => { + mocks.createFile.mockRejectedValueOnce(new OrchestrationError('conflict', 'Name exists')) + const conflict = await POST(createRequest({ name: 'notes.md' }), context) + expect(conflict.status).toBe(409) + expect(await conflict.json()).toEqual({ success: false, error: 'Name exists' }) - expect(response.status).toBe(500) - await expect(response.json()).resolves.toEqual({ + mocks.createFile.mockRejectedValueOnce(new Error('database details')) + const unexpected = await POST(createRequest({ name: 'notes.md' }), context) + expect(unexpected.status).toBe(500) + expect(await unexpected.json()).toEqual({ success: false, - error: 'Failed to create file', + error: 'Internal server error', }) }) }) diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index 9a370fb4f94..2d29ddfad10 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -1,177 +1,61 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' import { createWorkspaceFileContract, - listWorkspaceFilesQuerySchema, - workspaceFilesParamsSchema, + listWorkspaceFilesContract, } from '@/lib/api/contracts/workspace-files' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' import { - messageForOrchestrationError, - statusForOrchestrationError, -} from '@/lib/core/orchestration/types' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getWorkspaceShares } from '@/lib/public-shares/share-manager' -import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { - MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, - performCreateWorkspaceFile, -} from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' + internalFileAnalytics, + internalFileErrorPolicies, + internalFilePresenters, +} from '@/lib/workspace-files/api' +import { + admitCreateWorkspaceFile, + createWorkspaceFile, +} from '@/lib/workspace-files/application/create-workspace-file' +import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' export const dynamic = 'force-dynamic' -const logger = createLogger('WorkspaceFilesAPI') - -/** - * GET /api/workspaces/[id]/files - * List all files for a workspace (requires read permission) - */ -export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const paramsResult = workspaceFilesParamsSchema.safeParse(await params) - if (!paramsResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, - { status: 400 } - ) - } - const { id: workspaceId } = paramsResult.data - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check workspace permissions (requires read) - const userPermission = await verifyWorkspaceMembership(session.user.id, workspaceId) - if (!userPermission) { - logger.warn( - `[${requestId}] User ${session.user.id} lacks permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const queryResult = listWorkspaceFilesQuerySchema.safeParse( - Object.fromEntries(request.nextUrl.searchParams.entries()) - ) - if (!queryResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(queryResult.error, 'Invalid scope') }, - { status: 400 } - ) - } - const { scope } = queryResult.data - - const files = await listWorkspaceFiles(workspaceId, { scope }) - - const shares = await getWorkspaceShares('file', workspaceId) - const filesWithShares = files.map((file) => ({ - ...file, - share: shares.get(file.id) ?? null, - })) - - logger.info(`[${requestId}] Listed ${files.length} files for workspace ${workspaceId}`) - - return NextResponse.json({ - success: true, - files: filesWithShares, - }) - } catch (error) { - logger.error(`[${requestId}] Error listing workspace files:`, error) - return NextResponse.json( - { - success: false, - error: getErrorMessage(error, 'Failed to list files'), - }, - { status: 500 } - ) - } - } -) - -/** - * POST /api/workspaces/[id]/files - * Create an authored workspace file (requires write permission) - */ -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const paramsResult = workspaceFilesParamsSchema.safeParse(await context.params) - if (!paramsResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, - { status: 400 } - ) - } - const { id: workspaceId } = paramsResult.data - - const userPermission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workspaceId - ) - if (userPermission !== 'admin' && userPermission !== 'write') { - logger.warn( - `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const parsed = await parseRequest(createWorkspaceFileContract, request, context, { - maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, - }) - if (!parsed.success) return parsed.response - const { name, contentType, folderId, content, encoding } = parsed.data.body - - const result = await performCreateWorkspaceFile({ - workspaceId, - userId: session.user.id, - name, - contentType: contentType ?? getMimeTypeFromExtension(getFileExtension(name)), - folderId, - content: Buffer.from(content, encoding), - exactName: false, - actorName: session.user.name, - actorEmail: session.user.email, - request, - }) - if (!result.success || !result.file) { - return NextResponse.json( - { - success: false, - error: messageForOrchestrationError(result, 'Failed to create file'), - }, - { status: statusForOrchestrationError(result.errorCode) } - ) - } - - logger.info(`[${requestId}] Created workspace file: ${result.file.name}`) - return NextResponse.json({ success: true, file: result.file }, { status: 201 }) - } catch (error) { - logger.error(`[${requestId}] Error creating workspace file:`, error) - - return NextResponse.json( - { - success: false, - error: 'Failed to create file', - }, - { status: 500 } - ) - } - } -) +/** GET /api/workspaces/[id]/files — List workspace files. */ +export const GET = defineInternalJsonRoute({ + contract: listWorkspaceFilesContract, + auth: internalSessionAuth, + operation: fileOperations.list, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal file-list behavior' }), + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params, query }) => ({ workspaceId: params.id, scope: query.scope }), + useCase: listAllWorkspaceFiles, + present: internalFilePresenters.successFiles, +}) + +/** POST /api/workspaces/[id]/files — Create an authored workspace file. */ +export const POST = defineInternalJsonRoute({ + contract: createWorkspaceFileContract, + auth: internalSessionAuth, + operation: fileOperations.create, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal file-create behavior' }), + errorPolicy: internalFileErrorPolicies.default, + parseOptions: { maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES }, + beforeParse: async ({ principal, params }) => { + if (typeof params.id === 'string') await admitCreateWorkspaceFile(principal, params.id) + }, + mapInput: ({ params, body }) => ({ + workspaceId: params.id, + name: body.name, + contentType: body.contentType ?? getMimeTypeFromExtension(getFileExtension(body.name)), + content: body.content, + encoding: body.encoding, + folderId: body.folderId, + exactName: false, + }), + useCase: createWorkspaceFile, + onSuccess: internalFileAnalytics.uploaded, + present: internalFilePresenters.successFile, +}) diff --git a/apps/sim/lib/api/contracts/primitives.test.ts b/apps/sim/lib/api/contracts/primitives.test.ts index 0a5534d50e9..4e8a605a98f 100644 --- a/apps/sim/lib/api/contracts/primitives.test.ts +++ b/apps/sim/lib/api/contracts/primitives.test.ts @@ -12,9 +12,28 @@ import { resolvedSecretTraceProvenanceSchema, workflowIdSchema, workspaceFileIdSchema, + workspaceFileNameSchema, workspaceIdSchema, } from '@/lib/api/contracts/primitives' +describe('workspaceFileNameSchema', () => { + it('trims and accepts one bounded file name', () => { + expect(workspaceFileNameSchema.parse(' report.pdf ')).toBe('report.pdf') + expect(workspaceFileNameSchema.safeParse('a'.repeat(255)).success).toBe(true) + }) + + it.each([undefined, '', ' ', '.', '..', 'folder/report.pdf', 'folder\\report.pdf'])( + 'rejects invalid file name %j', + (name) => { + expect(workspaceFileNameSchema.safeParse(name).success).toBe(false) + } + ) + + it('rejects names longer than 255 characters', () => { + expect(workspaceFileNameSchema.safeParse('a'.repeat(256)).success).toBe(false) + }) +}) + describe('isCanonicalBase64', () => { it.each(['', 'TQ==', 'TWE=', 'TWFu', 'AAEC/w=='])('accepts canonical base64 %j', (value) => { expect(isCanonicalBase64(value)).toBe(true) diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index 97c7e814109..fa96521db31 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -199,6 +199,20 @@ export function requiredFieldSchema(message: string) { /** Non-empty `workspaceId` field with a stable, human-readable message. */ export const workspaceIdSchema = requiredFieldSchema('Workspace ID is required') +/** + * A single workspace-file name, not a path. Folder placement is carried by a + * separate folder id or path field, so separators and dot segments are invalid. + */ +export const workspaceFileNameSchema = z + .string({ error: 'Name is required' }) + .trim() + .min(1, 'Name is required') + .max(255, 'Name is too long') + .refine( + (name) => name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\'), + 'Name cannot contain path separators or dot segments' + ) + /** Non-empty `organizationId` field with a stable, human-readable message. */ export const organizationIdSchema = requiredFieldSchema('Organization ID is required') diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 7305fc9690e..607f582d695 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { isCanonicalBase64, workspaceFileIdSchema, + workspaceFileNameSchema, workspaceIdSchema, } from '@/lib/api/contracts/primitives' import { shareAuthTypeSchema, shareRecordSchema } from '@/lib/api/contracts/public-shares' @@ -11,6 +12,7 @@ import { v2CursorListResponse, v2DataResponse, v2DeleteFolderQuerySchema, + v2ErrorResponseSchema, v2FolderPathInputSchema, v2FolderPathSchema, v2FolderSchema, @@ -65,7 +67,7 @@ export type V2FileUploadParams = z.output export const v2CreateFileUploadBodySchema = z .object({ workspaceId: workspaceIdSchema, - name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), + name: workspaceFileNameSchema, contentType: z.string().trim().min(1, 'contentType is required').max(255), size: z.number().int().nonnegative().max(MAX_WORKSPACE_FILE_SIZE), folderPath: v2FolderPathInputSchema.optional(), @@ -110,25 +112,10 @@ export const v2FileParamsSchema = z.object({ export type V2FileParams = z.output -/** - * A file-folder name becomes a path segment, so path separators and dot - * segments are rejected rather than normalized. Mirrors - * `normalizeWorkspaceFileItemName`, which enforces the same rule in the manager. - */ -const v2FileItemNameSchema = z - .string() - .trim() - .min(1, 'name is required') - .max(255, 'name is too long') - .refine( - (name) => name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\'), - 'name cannot contain path separators or dot segments' - ) - export const v2CreateFileBodySchema = z .object({ workspaceId: workspaceIdSchema, - name: v2FileItemNameSchema, + name: workspaceFileNameSchema, contentType: z .string() .trim() @@ -194,7 +181,7 @@ export type V2FileWorkspaceQuery = z.output export const v2RenameFileBodySchema = z .object({ workspaceId: workspaceIdSchema, - name: v2FileItemNameSchema, + name: workspaceFileNameSchema, }) .strict() @@ -360,6 +347,7 @@ export const v2CreateFileContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2FileSchema), + status: 201, }, }) @@ -367,7 +355,7 @@ export const v2CreateFileUploadContract = defineRouteContract({ method: 'POST', path: '/api/v2/files/uploads', body: v2CreateFileUploadBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2CreateFileUploadDataSchema) }, + response: { mode: 'json', schema: v2DataResponse(v2CreateFileUploadDataSchema), status: 201 }, }) export const v2AbortFileUploadContract = defineRouteContract({ @@ -428,6 +416,7 @@ export const v2RenameFileContract = defineRouteContract({ mode: 'json', schema: v2DataResponse(v2FileSchema), }, + error: v2ErrorResponseSchema, }) export const v2DeleteFileContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index fbd33c2f396..653ef97c0bc 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -67,6 +67,8 @@ export const v2ErrorResponseSchema = z.object({ }), }) +export type V2ErrorResponse = z.output + /** `{ data: T }` */ export const v2DataResponse = (dataSchema: T) => z.object({ data: dataSchema }) diff --git a/apps/sim/lib/api/contracts/workspace-file-folders.ts b/apps/sim/lib/api/contracts/workspace-file-folders.ts index 11fad5227ab..66620f36363 100644 --- a/apps/sim/lib/api/contracts/workspace-file-folders.ts +++ b/apps/sim/lib/api/contracts/workspace-file-folders.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts/types' +import { MAX_WORKSPACE_FILE_BULK_REQUEST_IDS } from '@/lib/workspace-files/limits' export const workspaceFileFolderScopeSchema = z.enum(['active', 'archived', 'all']) @@ -56,8 +57,14 @@ export const updateWorkspaceFileFolderBodySchema = z.object({ export const moveWorkspaceFileItemsBodySchema = z .object({ - fileIds: z.array(z.string()).default([]), - folderIds: z.array(z.string()).default([]), + fileIds: z + .array(z.string()) + .max(MAX_WORKSPACE_FILE_BULK_REQUEST_IDS, 'Too many file IDs') + .default([]), + folderIds: z + .array(z.string()) + .max(MAX_WORKSPACE_FILE_BULK_REQUEST_IDS, 'Too many folder IDs') + .default([]), targetFolderId: z.string().nullable().optional(), }) .refine((body) => body.fileIds.length > 0 || body.folderIds.length > 0, { @@ -66,8 +73,14 @@ export const moveWorkspaceFileItemsBodySchema = z export const bulkArchiveWorkspaceFileItemsBodySchema = z .object({ - fileIds: z.array(z.string()).default([]), - folderIds: z.array(z.string()).default([]), + fileIds: z + .array(z.string()) + .max(MAX_WORKSPACE_FILE_BULK_REQUEST_IDS, 'Too many file IDs') + .default([]), + folderIds: z + .array(z.string()) + .max(MAX_WORKSPACE_FILE_BULK_REQUEST_IDS, 'Too many folder IDs') + .default([]), }) .refine((body) => body.fileIds.length > 0 || body.folderIds.length > 0, { message: 'At least one file or folder must be selected', diff --git a/apps/sim/lib/api/contracts/workspace-files.ts b/apps/sim/lib/api/contracts/workspace-files.ts index 625fc161eb4..3540f4b4db0 100644 --- a/apps/sim/lib/api/contracts/workspace-files.ts +++ b/apps/sim/lib/api/contracts/workspace-files.ts @@ -3,6 +3,7 @@ import { folderIdSchema, inlineFileRefQuerySchema, isCanonicalBase64, + workspaceFileNameSchema, workspaceIdSchema, } from '@/lib/api/contracts/primitives' import { shareRecordSchema } from '@/lib/api/contracts/public-shares' @@ -43,20 +44,16 @@ export const getInlineWorkspaceFileContract = defineRouteContract({ }, }) -export const workspaceFileNameSchema = z - .string({ error: 'Name is required' }) - .trim() - .min(1, 'Name is required') - .max(255, 'Name is too long') - .refine( - (name) => name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\'), - 'Name cannot contain path separators or dot segments' - ) - export const renameWorkspaceFileBodySchema = z.object({ name: workspaceFileNameSchema, }) +export const renameWorkspaceFileErrorSchema = z.union([ + z.object({ error: z.string() }), + z.object({ error: z.string(), details: z.array(z.unknown()) }), + z.object({ success: z.literal(false), error: z.string() }), +]) + export const updateWorkspaceFileContentBodySchema = z .object({ content: z.string().max(70_000_000, 'Content is too large'), @@ -169,6 +166,7 @@ export const createWorkspaceFileContract = defineRouteContract({ schema: workspaceFileSuccessSchema.extend({ file: workspaceFileRecordSchema, }), + status: 201, }, }) @@ -183,6 +181,7 @@ export const renameWorkspaceFileContract = defineRouteContract({ file: workspaceFileRecordSchema, }), }, + error: renameWorkspaceFileErrorSchema, }) export const updateWorkspaceFileDimensionsContract = defineRouteContract({ @@ -231,6 +230,22 @@ export const updateWorkspaceFileContentContract = defineRouteContract({ }, }) +export const downloadWorkspaceFileUrlContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/files/[fileId]/download', + params: workspaceFileParamsSchema, + response: { + mode: 'json', + schema: z.object({ + success: z.literal(true), + downloadUrl: z.string().min(1), + viewerUrl: z.string().min(1), + fileName: z.string().min(1), + expiresIn: z.null(), + }), + }, +}) + const documentStyleSummarySchema = z .object({ format: z.enum(['docx', 'pptx', 'pdf']), diff --git a/apps/sim/lib/api/server/routes/definition.test.ts b/apps/sim/lib/api/server/routes/definition.test.ts new file mode 100644 index 00000000000..20653b5d36c --- /dev/null +++ b/apps/sim/lib/api/server/routes/definition.test.ts @@ -0,0 +1,112 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts' +import { + requireBinaryRouteDefinition, + requireJsonRouteDefinition, +} from '@/lib/api/server/routes/definition' + +const renameOperation = { + id: 'files.rename', + minimumRole: 'write', + workspaceApiKey: 'allow', +} as const + +describe('declarative route definition invariants', () => { + it('accepts one successful JSON response status', () => { + const contract = defineRouteContract({ + method: 'PATCH', + path: '/files/[fileId]', + response: { mode: 'json', schema: z.object({ ok: z.literal(true) }), status: 202 }, + }) + + expect(requireJsonRouteDefinition(contract, renameOperation, renameOperation)).toEqual({ + successStatus: 202, + }) + }) + + it('fails immediately when route and use-case operations differ', () => { + expect(() => + requireJsonRouteDefinition( + defineRouteContract({ + method: 'PATCH', + path: '/files/[fileId]', + response: { mode: 'json', schema: z.object({ ok: z.literal(true) }) }, + }), + renameOperation, + { ...renameOperation, id: 'files.delete' } + ) + ).toThrow('does not match') + }) + + it('fails immediately for binary mode or ambiguous success statuses', () => { + expect(() => + requireJsonRouteDefinition( + defineRouteContract({ + method: 'GET', + path: '/files/[fileId]', + response: { mode: 'binary' }, + }), + renameOperation, + renameOperation + ) + ).toThrow('requires a JSON response contract') + + expect(() => + requireJsonRouteDefinition( + defineRouteContract({ + method: 'PATCH', + path: '/files/[fileId]', + response: { + mode: 'json', + schema: z.object({ ok: z.literal(true) }), + status: [200, 202], + }, + }), + renameOperation, + renameOperation + ) + ).toThrow('must declare one success status') + }) + + it('accepts binary contracts and rejects JSON contracts at the binary boundary', () => { + const binary = defineRouteContract({ + method: 'GET', + path: '/files/[fileId]', + response: { mode: 'binary' }, + }) + expect(requireBinaryRouteDefinition(binary, renameOperation, renameOperation)).toEqual({ + successStatus: 200, + }) + + expect(() => + requireBinaryRouteDefinition( + defineRouteContract({ + method: 'GET', + path: '/files/[fileId]', + response: { mode: 'json', schema: z.object({ ok: z.literal(true) }) }, + }), + renameOperation, + renameOperation + ) + ).toThrow('requires a binary response contract') + }) + + it('rejects operation mismatches at the binary boundary', () => { + expect(() => + requireBinaryRouteDefinition( + defineRouteContract({ + method: 'GET', + path: '/files/[fileId]', + response: { mode: 'binary' }, + }), + renameOperation, + { ...renameOperation, id: 'files.download' } + ) + ).toThrow('does not match') + }) +}) diff --git a/apps/sim/lib/api/server/routes/definition.ts b/apps/sim/lib/api/server/routes/definition.ts new file mode 100644 index 00000000000..580653ad9e3 --- /dev/null +++ b/apps/sim/lib/api/server/routes/definition.ts @@ -0,0 +1,55 @@ +import type { AnyApiRouteContract } from '@/lib/api/contracts' +import type { ApplicationOperation } from '@/lib/core/application' + +export interface JsonRouteDefinitionMetadata { + successStatus: number +} + +export function requireJsonRouteDefinition( + contract: AnyApiRouteContract, + declaredOperation: ApplicationOperation, + useCaseOperation: ApplicationOperation +): JsonRouteDefinitionMetadata { + if (contract.response.mode !== 'json') { + throw new Error(`${contract.method} ${contract.path} requires a JSON response contract`) + } + if (declaredOperation.id !== useCaseOperation.id) { + throw new Error( + `Route operation ${declaredOperation.id} does not match use case ${useCaseOperation.id}` + ) + } + + const configuredStatus = contract.response.status + if (configuredStatus !== undefined && typeof configuredStatus !== 'number') { + throw new Error(`${contract.method} ${contract.path} must declare one success status`) + } + const successStatus = configuredStatus ?? 200 + if (successStatus < 200 || successStatus >= 300) { + throw new Error(`${contract.method} ${contract.path} has a non-success response status`) + } + return { successStatus } +} + +export function requireBinaryRouteDefinition( + contract: AnyApiRouteContract, + declaredOperation: ApplicationOperation, + useCaseOperation: ApplicationOperation +): JsonRouteDefinitionMetadata { + if (contract.response.mode !== 'binary') { + throw new Error(`${contract.method} ${contract.path} requires a binary response contract`) + } + if (declaredOperation.id !== useCaseOperation.id) { + throw new Error( + `Route operation ${declaredOperation.id} does not match use case ${useCaseOperation.id}` + ) + } + const configuredStatus = contract.response.status + if (configuredStatus !== undefined && typeof configuredStatus !== 'number') { + throw new Error(`${contract.method} ${contract.path} must declare one success status`) + } + const successStatus = configuredStatus ?? 200 + if (successStatus < 200 || successStatus >= 300) { + throw new Error(`${contract.method} ${contract.path} has a non-success response status`) + } + return { successStatus } +} diff --git a/apps/sim/lib/api/server/routes/index.ts b/apps/sim/lib/api/server/routes/index.ts new file mode 100644 index 00000000000..f9fdcfd39c1 --- /dev/null +++ b/apps/sim/lib/api/server/routes/index.ts @@ -0,0 +1,23 @@ +export { defineInternalBinaryRoute } from '@/lib/api/server/routes/internal-binary-route' +export { + createInternalSessionOrServiceAuth, + defineInternalJsonRoute, + extendInternalErrorPolicy, + type InternalAuthPolicy, + type InternalErrorPolicy, + InternalUnauthenticatedError, + internalErrorResponse, + internalJsonPresenters, + internalOrchestrationErrorPolicy, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes/internal-json-route' +export { defineV2BinaryRoute } from '@/lib/api/server/routes/v2-binary-route' +export { + defineV2JsonRoute, + type V2ErrorPolicy, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes/v2-json-route' diff --git a/apps/sim/lib/api/server/routes/internal-binary-route.ts b/apps/sim/lib/api/server/routes/internal-binary-route.ts new file mode 100644 index 00000000000..5e40e070dc2 --- /dev/null +++ b/apps/sim/lib/api/server/routes/internal-binary-route.ts @@ -0,0 +1,127 @@ +import type { Principal, SessionPrincipal } from '@sim/auth/principal' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { requireBinaryRouteDefinition } from '@/lib/api/server/routes/definition' +import { + type InternalErrorPolicy, + InternalUnauthenticatedError, + type internalSessionAuth, +} from '@/lib/api/server/routes/internal-json-route' +import type { + BinaryApiRouteContract, + BinaryResponseDescriptor, + JsonErrorResponseDescriptor, + JsonNextRouteHandler, + JsonRouteContext, +} from '@/lib/api/server/routes/types' +import type { ParsedRequest } from '@/lib/api/server/validation' +import { parseRequest } from '@/lib/api/server/validation' +import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +interface InternalBinaryRateLimitPolicy { + readonly kind: 'none' + readonly reason: string + enforce(request: NextRequest, principal: Principal): Promise +} + +interface InternalBinaryRouteDefinition< + C extends BinaryApiRouteContract, + O extends ApplicationOperation, + I, + R, +> { + contract: C + operation: O + mapInput(input: ParsedRequest): I + useCase: OperationUseCase, I, R> + present(result: R): BinaryResponseDescriptor | Promise +} + +interface InternalBinaryRouteOptions< + C extends BinaryApiRouteContract, + O extends ApplicationOperation, + I, + R, +> extends InternalBinaryRouteDefinition { + auth: typeof internalSessionAuth + rateLimit: InternalBinaryRateLimitPolicy + errorPolicy: InternalErrorPolicy + onSuccess?(args: { principal: SessionPrincipal; input: I; result: R }): void | Promise +} + +/** + * Defines an authenticated internal binary route, including streamed responses. + * + * The descriptor keeps storage and archive details out of the route handler while + * allowing a use-case presenter to return a Web Stream without buffering it. + */ +export function defineInternalBinaryRoute< + C extends BinaryApiRouteContract, + O extends ApplicationOperation, + I, + R, +>(options: InternalBinaryRouteOptions): JsonNextRouteHandler { + const { successStatus } = requireBinaryRouteDefinition( + options.contract, + options.operation, + options.useCase.operation + ) + + const wrapped = withRouteHandler( + async (request, context) => { + if (request.method !== options.contract.method) { + throw new Error( + `Route received ${request.method} for ${options.contract.method} contract ${options.contract.path}` + ) + } + + let principal: SessionPrincipal + try { + principal = await options.auth.authenticate() + } catch (error) { + if (error instanceof InternalUnauthenticatedError) { + return NextResponse.json({ error: error.message }, { status: 401 }) + } + throw error + } + + await options.rateLimit.enforce(request, principal) + const parsed = await parseRequest(options.contract, request, context ?? {}) + if (!parsed.success) return parsed.response + + try { + const input = options.mapInput(parsed.data) + const result = await options.useCase.execute({ principal, input, request }) + const descriptor = await options.present(result) + await options.onSuccess?.({ principal, input, result }) + const headers = new Headers(descriptor.headers) + headers.set('Content-Type', descriptor.contentType) + if (descriptor.contentDisposition) { + headers.set('Content-Disposition', descriptor.contentDisposition) + } + if (descriptor.contentLength !== undefined) { + headers.set('Content-Length', String(descriptor.contentLength)) + } + return new NextResponse(descriptor.body, { status: successStatus, headers }) + } catch (error) { + const response = options.errorPolicy.project(error) + if (response) return createJsonErrorResponse(response) + throw error + } + }, + { + unhandledErrorResponse: () => + NextResponse.json({ error: 'Internal server error' }, { status: 500 }), + } + ) + + return async (request, context) => wrapped(request, context) +} + +function createJsonErrorResponse(descriptor: JsonErrorResponseDescriptor): NextResponse { + return NextResponse.json(descriptor.body, { + status: descriptor.status, + headers: descriptor.headers, + }) +} diff --git a/apps/sim/lib/api/server/routes/internal-json-route.test.ts b/apps/sim/lib/api/server/routes/internal-json-route.test.ts new file mode 100644 index 00000000000..c295caec6d4 --- /dev/null +++ b/apps/sim/lib/api/server/routes/internal-json-route.test.ts @@ -0,0 +1,85 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts' +import { + defineInternalJsonRoute, + internalErrorResponse, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes/internal-json-route' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const operation = { id: 'test.read' } as const +const auth = { + authenticate: vi.fn(async () => ({ + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', + })), +} + +const contract = defineRouteContract({ + method: 'GET', + path: '/api/test/internal-json-route', + response: { + mode: 'json', + schema: z.object({ value: z.string() }), + }, +}) + +describe('defineInternalJsonRoute', () => { + it('uses the use-case result directly when it already matches the contract', async () => { + const handler = defineInternalJsonRoute({ + contract, + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: () => undefined, + useCase: { + operation, + async execute() { + return { value: 'ok' } + }, + }, + }) + + const response = await handler(new NextRequest('http://localhost/api/test/internal-json-route')) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ value: 'ok' }) + expect(response.headers.get('x-request-id')).toBeTruthy() + }) + + it('renders typed error descriptors through the shared builder', async () => { + const handler = defineInternalJsonRoute({ + contract, + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: () => undefined, + useCase: { + operation, + async execute(): Promise<{ value: string }> { + throw new OrchestrationError('conflict', 'Already exists') + }, + }, + }) + + const response = await handler(new NextRequest('http://localhost/api/test/internal-json-route')) + + expect(response.status).toBe(409) + await expect(response.json()).resolves.toEqual({ error: 'Already exists' }) + }) + + it('rejects invalid error statuses immediately', () => { + expect(() => internalErrorResponse(200, { error: 'Invalid' })).toThrow( + 'Internal error responses require a 4xx or 5xx status' + ) + }) +}) diff --git a/apps/sim/lib/api/server/routes/internal-json-route.ts b/apps/sim/lib/api/server/routes/internal-json-route.ts new file mode 100644 index 00000000000..8191e353794 --- /dev/null +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -0,0 +1,277 @@ +import type { DelegatedPrincipal, Principal, SessionPrincipal } from '@sim/auth/principal' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import type { ContractJsonResponse } from '@/lib/api/contracts' +import { requireJsonRouteDefinition } from '@/lib/api/server/routes/definition' +import type { + JsonApiRouteContract, + JsonErrorResponseDescriptor, + JsonNextRouteHandler, + JsonRouteContext, +} from '@/lib/api/server/routes/types' +import { + type ParsedRequest, + type ParseRequestOptions, + parseRequest, +} from '@/lib/api/server/validation' +import { getSession } from '@/lib/auth' +import { verifyInternalToken } from '@/lib/auth/internal' +import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +export class InternalUnauthenticatedError extends Error { + constructor(message = 'Unauthorized') { + super(message) + this.name = 'InternalUnauthenticatedError' + } +} + +export const internalSessionAuth = { + async authenticate(): Promise { + const session = await getSession() + if (!session?.user?.id) throw new InternalUnauthenticatedError() + const sessionId = session.session?.id + if (!sessionId) throw new Error('Authenticated session is missing its session ID') + return { kind: 'session', userId: session.user.id, sessionId } + }, +} as const + +export function createInternalSessionOrServiceAuth

( + bindDelegation: (args: { + subjectUserId: string + params: Record + }) => P +): InternalAuthPolicy { + return { + async authenticate(request, params) { + if (request.headers.has('x-api-key')) { + throw new InternalUnauthenticatedError('Authentication required') + } + + const authorization = request.headers.get('authorization') + if (!authorization?.startsWith('Bearer ')) return internalSessionAuth.authenticate() + + const verification = await verifyInternalToken(authorization.slice('Bearer '.length)) + if (!verification.valid || !verification.userId) { + throw new InternalUnauthenticatedError('Authentication required') + } + return bindDelegation({ subjectUserId: verification.userId, params }) + }, + } +} + +interface InternalRateLimitPolicy { + readonly kind: 'none' + readonly reason: string + enforce(request: NextRequest, principal: Principal): Promise +} + +export const internalRateLimits = { + none({ reason }: { reason: string }): InternalRateLimitPolicy { + if (!reason.trim()) throw new Error('A rate-limit exemption reason is required') + return { + kind: 'none', + reason, + async enforce() {}, + } + }, +} as const + +export interface InternalErrorPolicy { + project(error: unknown): JsonErrorResponseDescriptor | null + unhandled?(): JsonErrorResponseDescriptor +} + +export const internalOrchestrationErrorPolicy: InternalErrorPolicy = { + project(error) { + const classified = asOrchestrationError(error) + if (!classified) return null + return internalErrorResponse(statusForOrchestrationError(classified.code), { + success: false, + error: classified.message, + }) + }, +} + +export const internalPlainOrchestrationErrorPolicy: InternalErrorPolicy = { + project(error) { + const classified = asOrchestrationError(error) + if (!classified) return null + return internalErrorResponse(statusForOrchestrationError(classified.code), { + error: classified.message, + }) + }, + unhandled() { + return internalErrorResponse(500, { error: 'Internal server error' }) + }, +} + +export function internalErrorResponse( + status: number, + body: unknown, + headers?: HeadersInit +): JsonErrorResponseDescriptor { + if (!Number.isInteger(status) || status < 400 || status >= 600) { + throw new Error(`Internal error responses require a 4xx or 5xx status, received ${status}`) + } + return { body, status, headers } +} + +export function extendInternalErrorPolicy( + base: InternalErrorPolicy, + project: (error: unknown) => JsonErrorResponseDescriptor | null +): InternalErrorPolicy { + return { + project(error) { + return project(error) ?? base.project(error) + }, + unhandled: base.unhandled, + } +} + +export const internalJsonPresenters = { + withSuccess(result: R) { + return { ...result, success: true as const } + }, + successFrom(key: K) { + return >(result: R) => ({ success: result[key] }) + }, +} as const + +export interface InternalAuthPolicy

{ + authenticate( + request: NextRequest, + params: Record + ): Promise

+} + +type InternalJsonPresenter = [R] extends [ + ContractJsonResponse, +] + ? { + present?(result: NoInfer): ContractJsonResponse | Promise> + } + : { + present(result: NoInfer): ContractJsonResponse | Promise> + } + +type InternalJsonRouteOptions< + C extends JsonApiRouteContract, + O extends ApplicationOperation, + I, + R, + P extends Principal, +> = { + contract: C + operation: O + mapInput(input: ParsedRequest): I + useCase: OperationUseCase, I, R> + auth: InternalAuthPolicy

+ rateLimit: InternalRateLimitPolicy + errorPolicy: InternalErrorPolicy + parseOptions?: Omit + beforeParse?(args: { + request: NextRequest + principal: P + params: Record + }): void | Promise + onSuccess?(args: { principal: P; input: NoInfer; result: NoInfer }): void | Promise + responseHeaders?(args: { principal: P; input: NoInfer; result: NoInfer }): HeadersInit +} & InternalJsonPresenter + +function createJsonErrorResponse(descriptor: JsonErrorResponseDescriptor): NextResponse { + return NextResponse.json(descriptor.body, { + status: descriptor.status, + headers: descriptor.headers, + }) +} + +export function defineInternalJsonRoute< + C extends JsonApiRouteContract, + O extends ApplicationOperation, + I, + R, + P extends Principal, +>(options: InternalJsonRouteOptions): JsonNextRouteHandler { + const { successStatus } = requireJsonRouteDefinition( + options.contract, + options.operation, + options.useCase.operation + ) + + const wrapped = withRouteHandler( + async (request, context) => { + if (request.method !== options.contract.method) { + throw new Error( + `Route received ${request.method} for ${options.contract.method} contract ${options.contract.path}` + ) + } + + const rawParams = context?.params ? await context.params : {} + let principal: P + try { + principal = await options.auth.authenticate(request, rawParams) + } catch (error) { + if (error instanceof InternalUnauthenticatedError) { + return NextResponse.json({ error: error.message }, { status: 401 }) + } + throw error + } + + await options.rateLimit.enforce(request, principal) + if (options.beforeParse) { + try { + await options.beforeParse({ request, principal, params: rawParams }) + } catch (error) { + const response = options.errorPolicy.project(error) + if (response) return createJsonErrorResponse(response) + throw error + } + } + const parsed = await parseRequest( + options.contract, + request, + context ?? {}, + options.parseOptions + ) + if (!parsed.success) return parsed.response + + try { + const input = options.mapInput(parsed.data) + const result = await options.useCase.execute({ + principal, + input, + request, + }) + await options.onSuccess?.({ principal, input, result }) + const body = options.present ? await options.present(result) : result + const responseSchema = options.contract.response + if (responseSchema.mode !== 'json') { + throw new Error('Internal JSON route response mode changed after initialization') + } + const validatedBody = responseSchema.schema.parse(body) + return NextResponse.json(validatedBody, { + status: successStatus, + headers: options.responseHeaders?.({ principal, input, result }), + }) + } catch (error) { + const response = options.errorPolicy.project(error) + if (response) return createJsonErrorResponse(response) + throw error + } + }, + { + unhandledErrorResponse: () => + createJsonErrorResponse( + options.errorPolicy.unhandled?.() ?? + internalErrorResponse(500, { + success: false, + error: 'Internal server error', + }) + ), + } + ) + + return async (request, context) => wrapped(request, context) +} diff --git a/apps/sim/lib/api/server/routes/types.ts b/apps/sim/lib/api/server/routes/types.ts new file mode 100644 index 00000000000..f2411298f38 --- /dev/null +++ b/apps/sim/lib/api/server/routes/types.ts @@ -0,0 +1,68 @@ +import type { NextRequest } from 'next/server' +import type { + AnyApiRouteContract, + BinaryResponseMode, + ContractJsonResponse, + JsonResponseMode, +} from '@/lib/api/contracts' +import type { ParsedRequest } from '@/lib/api/server/validation' +import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application' + +export interface JsonRouteContext { + params?: + | Promise> + | Record +} + +export type JsonApiRouteContract = AnyApiRouteContract & { + response: JsonResponseMode +} + +export type BinaryApiRouteContract = AnyApiRouteContract & { + response: BinaryResponseMode +} + +export interface BinaryResponseDescriptor { + body: BodyInit + contentType: string + contentDisposition?: string + contentLength?: number + headers?: HeadersInit +} + +export interface JsonErrorResponseDescriptor { + body: unknown + status: number + headers?: HeadersInit +} + +export interface JsonRouteDefinition< + C extends JsonApiRouteContract, + O extends ApplicationOperation, + I, + R, +> { + contract: C + operation: O + mapInput(input: ParsedRequest): I + useCase: OperationUseCase, I, R> + present(result: R): ContractJsonResponse | Promise> +} + +export type JsonNextRouteHandler = ( + request: NextRequest, + context?: JsonRouteContext +) => Promise + +export interface BinaryRouteDefinition< + C extends BinaryApiRouteContract, + O extends ApplicationOperation, + I, + R, +> { + contract: C + operation: O + mapInput(input: ParsedRequest): I + useCase: OperationUseCase, I, R> + present(result: R): BinaryResponseDescriptor | Promise +} diff --git a/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts b/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts new file mode 100644 index 00000000000..1ffe79bbf4b --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts @@ -0,0 +1,156 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + updateLastUsed: vi.fn(), + resolveWorkspaceBillingPayer: vi.fn(), + getHighestPrioritySubscription: vi.fn(), +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ isAuthDisabled: false })) +vi.mock('@/lib/api-key/crypto', () => ({ hashApiKey: (value: string) => `hash:${value}` })) +vi.mock('@/lib/api-key/service', () => ({ updateApiKeyLastUsed: mocks.updateLastUsed })) +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + resolveWorkspaceBillingPayer: mocks.resolveWorkspaceBillingPayer, +})) +vi.mock('@/lib/billing/core/subscription', () => ({ + getHighestPrioritySubscription: mocks.getHighestPrioritySubscription, +})) + +import { + authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError, +} from '@/lib/api/server/routes/v2-api-key-auth' + +describe('v2 API key authentication', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.updateLastUsed.mockResolvedValue(undefined) + mocks.getHighestPrioritySubscription.mockResolvedValue(null) + }) + + it('normalizes a personal key without exposing loose optional identity fields', async () => { + queueTableRows(schemaMock.apiKey, [ + { + id: 'key-1', + userId: 'user-1', + workspaceId: null, + type: 'personal', + expiresAt: null, + userBanned: false, + }, + ]) + + const result = await authenticateV2ApiKey('secret') + + expect(result).toEqual({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'], + rateLimitSubscription: null, + keyType: 'personal', + }) + expect(mocks.getHighestPrioritySubscription).toHaveBeenCalledWith('user-1', { + onError: 'throw', + }) + }) + + it('normalizes a workspace key as the workspace, not its creator', async () => { + queueTableRows(schemaMock.apiKey, [ + { + id: 'key-1', + userId: 'creator-1', + workspaceId: 'workspace-1', + type: 'workspace', + expiresAt: null, + userBanned: false, + }, + ]) + mocks.resolveWorkspaceBillingPayer.mockResolvedValue({ + billedAccountUserId: 'billing-owner-1', + organizationId: 'organization-1', + payerSubscription: { + plan: 'team', + referenceId: 'organization-1', + }, + }) + + const result = await authenticateV2ApiKey('secret') + + expect(result).toEqual({ + principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', 'workspace:workspace-1'], + rateLimitSubscription: { plan: 'team', referenceId: 'organization-1' }, + keyType: 'workspace', + }) + expect(mocks.getHighestPrioritySubscription).not.toHaveBeenCalled() + }) + + it('does not couple a workspace key to its creator ban state', async () => { + queueTableRows(schemaMock.apiKey, [ + { + id: 'key-1', + userId: 'creator-1', + workspaceId: 'workspace-1', + type: 'workspace', + expiresAt: null, + userBanned: true, + }, + ]) + mocks.resolveWorkspaceBillingPayer.mockResolvedValue({ + billedAccountUserId: 'billing-owner-1', + organizationId: null, + payerSubscription: null, + }) + + await expect(authenticateV2ApiKey('secret')).resolves.toMatchObject({ + principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + }) + }) + + it('treats missing, banned, and expired credentials as unauthenticated', async () => { + await expect(authenticateV2ApiKey('missing')).rejects.toBeInstanceOf( + V2ApiKeyUnauthenticatedError + ) + + queueTableRows(schemaMock.apiKey, [ + { + id: 'key-1', + userId: 'user-1', + workspaceId: null, + type: 'personal', + expiresAt: null, + userBanned: true, + }, + ]) + await expect(authenticateV2ApiKey('banned')).rejects.toBeInstanceOf( + V2ApiKeyUnauthenticatedError + ) + + queueTableRows(schemaMock.apiKey, [ + { + id: 'key-2', + userId: 'user-1', + workspaceId: null, + type: 'personal', + expiresAt: new Date(Date.now() - 1), + userBanned: false, + }, + ]) + await expect(authenticateV2ApiKey('expired')).rejects.toBeInstanceOf( + V2ApiKeyUnauthenticatedError + ) + }) + + it('propagates auth-store failures instead of converting them to invalid credentials', async () => { + const failure = new Error('database unavailable') + dbChainMockFns.limit.mockRejectedValueOnce(failure) + + await expect(authenticateV2ApiKey('secret')).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/api/server/routes/v2-api-key-auth.ts b/apps/sim/lib/api/server/routes/v2-api-key-auth.ts new file mode 100644 index 00000000000..d647ec9a778 --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-api-key-auth.ts @@ -0,0 +1,132 @@ +import type { PersonalApiKeyPrincipal, WorkspaceApiKeyPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { apiKey, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { hashApiKey } from '@/lib/api-key/crypto' +import { updateApiKeyLastUsed } from '@/lib/api-key/service' +import { ANONYMOUS_USER_ID } from '@/lib/auth/constants' +import { resolveWorkspaceBillingPayer } from '@/lib/billing/core/billing-attribution' +import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { isAuthDisabled } from '@/lib/core/config/env-flags' + +const logger = createLogger('V2ApiKeyAuth') + +export type V2ApiKeyPrincipal = PersonalApiKeyPrincipal | WorkspaceApiKeyPrincipal + +interface RateLimitSubscription { + plan: string + referenceId: string +} + +export interface V2ApiKeyAuthContext { + principal: V2ApiKeyPrincipal + rolloutUserId: string + rateLimitSubjectIds: readonly [string, ...string[]] + rateLimitSubscription: RateLimitSubscription | null + keyType: 'personal' | 'workspace' +} + +export class V2ApiKeyUnauthenticatedError extends Error { + constructor(message = 'Invalid API key') { + super(message) + this.name = 'V2ApiKeyUnauthenticatedError' + } +} + +interface ApiKeyRow { + id: string + userId: string + workspaceId: string | null + type: string + expiresAt: Date | null + userBanned: boolean | null +} + +function requireValidRow(row: ApiKeyRow | undefined): ApiKeyRow { + if (!row || (row.expiresAt && row.expiresAt < new Date())) { + throw new V2ApiKeyUnauthenticatedError() + } + if (row.type === 'personal' && row.workspaceId === null) { + if (row.userBanned === null) { + throw new Error(`Personal API key ${row.id} is missing its credential owner`) + } + if (row.userBanned) throw new V2ApiKeyUnauthenticatedError() + return row + } + if (row.type === 'workspace' && row.workspaceId) return row + throw new Error(`API key ${row.id} has an invalid persisted type/workspace combination`) +} + +export async function authenticateV2ApiKey( + apiKeyHeader: string | null +): Promise { + if (isAuthDisabled) { + return { + principal: { + kind: 'personal_api_key', + userId: ANONYMOUS_USER_ID, + keyId: 'auth-disabled', + }, + rolloutUserId: ANONYMOUS_USER_ID, + rateLimitSubjectIds: [`user:${ANONYMOUS_USER_ID}`], + rateLimitSubscription: null, + keyType: 'personal', + } + } + if (!apiKeyHeader) { + throw new V2ApiKeyUnauthenticatedError('API key required') + } + + const [candidate] = await db + .select({ + id: apiKey.id, + userId: apiKey.userId, + workspaceId: apiKey.workspaceId, + type: apiKey.type, + expiresAt: apiKey.expiresAt, + userBanned: user.banned, + }) + .from(apiKey) + .leftJoin(user, eq(apiKey.userId, user.id)) + .where(eq(apiKey.keyHash, hashApiKey(apiKeyHeader))) + .limit(1) + const row = requireValidRow(candidate) + + await updateApiKeyLastUsed(row.id) + logger.debug('Authenticated v2 API key', { keyId: row.id, keyType: row.type }) + + if (row.type === 'personal') { + const subscription = await getHighestPrioritySubscription(row.userId, { onError: 'throw' }) + return { + principal: { kind: 'personal_api_key', userId: row.userId, keyId: row.id }, + rolloutUserId: row.userId, + rateLimitSubjectIds: [`api-key:${row.id}`, `user:${row.userId}`], + rateLimitSubscription: subscription + ? { plan: subscription.plan, referenceId: subscription.referenceId } + : null, + keyType: 'personal', + } + } + + const workspaceId = row.workspaceId + if (!workspaceId) { + throw new Error(`Workspace API key ${row.id} is missing its workspace scope`) + } + const payer = await resolveWorkspaceBillingPayer(workspaceId) + if (!payer) { + throw new Error(`Workspace ${workspaceId} is missing its billing owner`) + } + return { + principal: { kind: 'workspace_api_key', workspaceId, keyId: row.id }, + rolloutUserId: payer.billedAccountUserId, + rateLimitSubjectIds: [`api-key:${row.id}`, `workspace:${workspaceId}`], + rateLimitSubscription: payer.payerSubscription + ? { + plan: payer.payerSubscription.plan, + referenceId: payer.payerSubscription.referenceId, + } + : null, + keyType: 'workspace', + } +} diff --git a/apps/sim/lib/api/server/routes/v2-binary-route.ts b/apps/sim/lib/api/server/routes/v2-binary-route.ts new file mode 100644 index 00000000000..eaf1a76ef60 --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-binary-route.ts @@ -0,0 +1,97 @@ +import type { NextRequest } from 'next/server' +import { requireBinaryRouteDefinition } from '@/lib/api/server/routes/definition' +import type { + BinaryApiRouteContract, + BinaryRouteDefinition, + JsonNextRouteHandler, + JsonRouteContext, +} from '@/lib/api/server/routes/types' +import { + admitV2Request, + type V2ErrorPolicy, + type V2RateLimitPolicy, + V2RouteInfrastructureError, + type v2ApiKeyAuth, +} from '@/lib/api/server/routes/v2-json-route' +import { parseRequest } from '@/lib/api/server/validation' +import type { ApplicationOperation } from '@/lib/core/application' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { v2Error, v2ValidationError } from '@/app/api/v2/lib/response' + +interface V2BinaryRouteOptions< + C extends BinaryApiRouteContract, + O extends ApplicationOperation, + I, + R, +> extends BinaryRouteDefinition { + auth: typeof v2ApiKeyAuth + rateLimit: V2RateLimitPolicy + errorPolicy: V2ErrorPolicy +} + +export function defineV2BinaryRoute< + C extends BinaryApiRouteContract, + O extends ApplicationOperation, + I, + R, +>(options: V2BinaryRouteOptions): JsonNextRouteHandler { + const { successStatus } = requireBinaryRouteDefinition( + options.contract, + options.operation, + options.useCase.operation + ) + + const wrapped = withRouteHandler( + async (request: NextRequest, context) => { + if (request.method !== options.contract.method) { + throw new Error( + `Route received ${request.method} for ${options.contract.method} contract ${options.contract.path}` + ) + } + + const admission = await admitV2Request( + request, + options.operation, + options.auth, + options.rateLimit + ) + if (!admission.success) return admission.response + + const parsed = await parseRequest(options.contract, request, context ?? {}, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + try { + const result = await options.useCase.execute({ + principal: admission.auth.principal, + input: options.mapInput(parsed.data), + request, + }) + const descriptor = await options.present(result) + const headers = new Headers(descriptor.headers) + headers.set('Content-Type', descriptor.contentType) + headers.set('Cache-Control', 'private, no-store') + if (descriptor.contentDisposition) { + headers.set('Content-Disposition', descriptor.contentDisposition) + } + if (descriptor.contentLength !== undefined) { + headers.set('Content-Length', String(descriptor.contentLength)) + } + return new Response(descriptor.body, { status: successStatus, headers }) + } catch (error) { + const response = options.errorPolicy.render(error) + if (response) return response + throw error + } + }, + { + unhandledErrorResponse: ({ error }) => + error instanceof V2RouteInfrastructureError + ? v2Error('SERVICE_UNAVAILABLE', 'Service temporarily unavailable') + : v2Error('INTERNAL_ERROR', 'Internal server error'), + } + ) + + return async (request, context) => wrapped(request, context) +} diff --git a/apps/sim/lib/api/server/routes/v2-json-route.ts b/apps/sim/lib/api/server/routes/v2-json-route.ts new file mode 100644 index 00000000000..62a0c572814 --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -0,0 +1,246 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { recordRateLimitSnapshot } from '@/lib/api/server/rate-limit-context' +import { requireJsonRouteDefinition } from '@/lib/api/server/routes/definition' +import type { + JsonApiRouteContract, + JsonNextRouteHandler, + JsonRouteContext, + JsonRouteDefinition, +} from '@/lib/api/server/routes/types' +import { + authenticateV2ApiKey, + type V2ApiKeyAuthContext, + V2ApiKeyUnauthenticatedError, +} from '@/lib/api/server/routes/v2-api-key-auth' +import { type ParseRequestOptions, parseRequest } from '@/lib/api/server/validation' +import type { ApplicationOperation } from '@/lib/core/application' +import { getRateLimit, RateLimiter, type SubscriptionPlan } from '@/lib/core/rate-limiter' +import { getClientIp } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const rateLimiter = new RateLimiter() +const V2_PREAUTH_IP_LIMIT = { + maxTokens: 600, + refillRate: 300, + refillIntervalMs: 60_000, +} as const + +export class V2RouteInfrastructureError extends Error { + constructor(stage: 'authentication' | 'rollout_gate' | 'rate_limit', cause: unknown) { + super(`V2 ${stage} infrastructure failed`, { cause }) + this.name = 'V2RouteInfrastructureError' + } +} + +export const v2ApiKeyAuth = { + authenticate(request: NextRequest) { + return authenticateV2ApiKey(request.headers.get('x-api-key')) + }, +} as const + +export interface V2RateLimitPolicy { + readonly kind: 'public_api' + enforce( + request: NextRequest, + auth: V2ApiKeyAuthContext, + operation: ApplicationOperation + ): Promise +} + +export const v2RateLimits = { + publicApi: { + kind: 'public_api', + async enforce(request, auth, operation) { + const plan = (auth.rateLimitSubscription?.plan ?? 'free') as SubscriptionPlan + const config = getRateLimit(plan, 'api-endpoint') + const buckets = await Promise.all( + auth.rateLimitSubjectIds.map(async (subjectId) => { + try { + return await rateLimiter.checkRateLimitDirectOrThrow( + `v2:${operation.id}:${subjectId}`, + config + ) + } catch (error) { + throw new V2RouteInfrastructureError('rate_limit', error) + } + }) + ) + const rateLimit = buckets.reduce((mostRestrictive, candidate) => { + if (!candidate.allowed && mostRestrictive.allowed) return candidate + if (candidate.allowed === mostRestrictive.allowed) { + if (candidate.remaining < mostRestrictive.remaining) return candidate + if ( + candidate.remaining === mostRestrictive.remaining && + candidate.resetAt > mostRestrictive.resetAt + ) { + return candidate + } + } + return mostRestrictive + }) + const snapshot = { + allowed: rateLimit.allowed, + limit: config.maxTokens, + remaining: rateLimit.remaining, + resetAt: rateLimit.resetAt, + retryAfterMs: rateLimit.retryAfterMs, + keyType: auth.keyType, + } + recordRateLimitSnapshot(request, snapshot) + return rateLimit.allowed ? null : v2RateLimitError(snapshot) + }, + } satisfies V2RateLimitPolicy, +} as const + +export interface V2ErrorPolicy { + render(error: unknown): NextResponse | null +} + +export const v2OrchestrationErrorPolicy = { + render(error) { + return v2CaughtOrchestrationError(error) + }, +} satisfies V2ErrorPolicy + +export async function admitV2Request( + request: NextRequest, + operation: ApplicationOperation, + authPolicy: typeof v2ApiKeyAuth, + rateLimitPolicy: V2RateLimitPolicy +): Promise< + { success: true; auth: V2ApiKeyAuthContext } | { success: false; response: NextResponse } +> { + const ip = getClientIp(request) + const abuseLimit = await rateLimiter.checkRateLimitDirect( + `v2:preauth:ip:${ip}`, + V2_PREAUTH_IP_LIMIT, + { failClosed: true } + ) + if (!abuseLimit.allowed) { + return { + success: false, + response: v2RateLimitError({ ...abuseLimit, limit: V2_PREAUTH_IP_LIMIT.maxTokens }), + } + } + + let auth: V2ApiKeyAuthContext + try { + auth = await authPolicy.authenticate(request) + } catch (error) { + if (error instanceof V2ApiKeyUnauthenticatedError) { + return { success: false, response: v2Error('UNAUTHORIZED', error.message) } + } + throw new V2RouteInfrastructureError('authentication', error) + } + + let gate + try { + gate = await v2ApiGateError(auth.rolloutUserId) + } catch (error) { + throw new V2RouteInfrastructureError('rollout_gate', error) + } + if (gate) return { success: false, response: gate } + + const limited = await rateLimitPolicy.enforce(request, auth, operation) + return limited ? { success: false, response: limited } : { success: true, auth } +} + +interface V2JsonRouteOptions + extends JsonRouteDefinition { + auth: typeof v2ApiKeyAuth + rateLimit: V2RateLimitPolicy + errorPolicy: V2ErrorPolicy + parseOptions?: Omit + beforeParse?(args: { + request: NextRequest + principal: V2ApiKeyAuthContext['principal'] + params: Record + }): void | Promise +} + +export function defineV2JsonRoute< + C extends JsonApiRouteContract, + O extends ApplicationOperation, + I, + R, +>(options: V2JsonRouteOptions): JsonNextRouteHandler { + const { successStatus } = requireJsonRouteDefinition( + options.contract, + options.operation, + options.useCase.operation + ) + + const wrapped = withRouteHandler( + async (request, context) => { + if (request.method !== options.contract.method) { + throw new Error( + `Route received ${request.method} for ${options.contract.method} contract ${options.contract.path}` + ) + } + + const admission = await admitV2Request( + request, + options.operation, + options.auth, + options.rateLimit + ) + if (!admission.success) return admission.response + const { auth } = admission + + if (options.beforeParse) { + const rawParams = context?.params ? await context.params : {} + try { + await options.beforeParse({ request, principal: auth.principal, params: rawParams }) + } catch (error) { + const response = options.errorPolicy.render(error) + if (response) return response + throw error + } + } + + const parsed = await parseRequest(options.contract, request, context ?? {}, { + ...options.parseOptions, + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + try { + const result = await options.useCase.execute({ + principal: auth.principal, + input: options.mapInput(parsed.data), + request, + }) + const body = await options.present(result) + const responseSchema = options.contract.response + if (responseSchema.mode !== 'json') { + throw new Error('V2 JSON route response mode changed after initialization') + } + const validatedBody = responseSchema.schema.parse(body) + return NextResponse.json(validatedBody, { + status: successStatus, + headers: { 'Cache-Control': 'private, no-store' }, + }) + } catch (error) { + const response = options.errorPolicy.render(error) + if (response) return response + throw error + } + }, + { + unhandledErrorResponse: ({ error }) => + error instanceof V2RouteInfrastructureError + ? v2Error('SERVICE_UNAVAILABLE', 'Service temporarily unavailable') + : v2Error('INTERNAL_ERROR', 'Internal server error'), + } + ) + + return async (request, context) => wrapped(request, context) +} diff --git a/apps/sim/lib/auth/principal.test.ts b/apps/sim/lib/auth/principal.test.ts new file mode 100644 index 00000000000..27248919ee8 --- /dev/null +++ b/apps/sim/lib/auth/principal.test.ts @@ -0,0 +1,118 @@ +/** + * @vitest-environment node + */ +import { + resolvePrincipalAttribution, + resolvePrincipalAuditAttribution, + toPrincipalActor, +} from '@sim/auth/principal' +import { describe, expect, it } from 'vitest' + +describe('principal actors', () => { + it('maps every principal to an audit actor without billing-owner substitution', () => { + expect( + resolvePrincipalAuditAttribution({ + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + }) + ).toEqual({ + actor: { kind: 'session', userId: 'user-1' }, + actorId: 'user-1', + }) + expect( + resolvePrincipalAuditAttribution({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-2', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2026-01-01T00:05:00Z'), + }) + ).toMatchObject({ actorId: 'user-2' }) + expect( + resolvePrincipalAuditAttribution({ + kind: 'workspace_api_key', + keyId: 'key-1', + workspaceId: 'workspace-1', + }) + ).toEqual({ + actor: { kind: 'workspace_api_key', keyId: 'key-1', workspaceId: 'workspace-1' }, + actorId: null, + actorName: 'Workspace API key', + }) + }) + + it('projects principals into their shared actor identity', () => { + expect( + toPrincipalActor({ kind: 'personal_api_key', keyId: 'key-1', userId: 'user-1' }) + ).toEqual({ kind: 'personal_api_key', keyId: 'key-1', userId: 'user-1' }) + + expect( + toPrincipalActor({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2026-01-01T00:05:00Z'), + }) + ).toEqual({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + delegationId: 'delegation-1', + }) + }) + + it('uses the workspace billing owner for workspace-key attribution', () => { + expect( + resolvePrincipalAttribution( + { kind: 'workspace_api_key', keyId: 'key-1', workspaceId: 'workspace-1' }, + { workspaceBillingOwnerUserId: 'billing-owner-1' } + ) + ).toEqual({ + actor: { kind: 'workspace_api_key', keyId: 'key-1', workspaceId: 'workspace-1' }, + attributedUserId: 'billing-owner-1', + }) + }) + + it('attributes user-backed principals to their human subject', () => { + expect( + resolvePrincipalAttribution({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) + ).toMatchObject({ attributedUserId: 'user-1' }) + expect( + resolvePrincipalAttribution({ + kind: 'personal_api_key', + keyId: 'key-1', + userId: 'user-2', + }) + ).toMatchObject({ attributedUserId: 'user-2' }) + expect( + resolvePrincipalAttribution({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-3', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2026-01-01T00:05:00Z'), + }) + ).toMatchObject({ attributedUserId: 'user-3' }) + }) + + it('fails fast when workspace-key attribution has no billing owner', () => { + expect(() => + resolvePrincipalAttribution({ + kind: 'workspace_api_key', + keyId: 'key-1', + workspaceId: 'workspace-1', + }) + ).toThrow('Workspace API key attribution requires a workspace billing owner') + }) +}) diff --git a/apps/sim/lib/copilot/application/execute-file-use-case.test.ts b/apps/sim/lib/copilot/application/execute-file-use-case.test.ts new file mode 100644 index 00000000000..7e8d87fead1 --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-file-use-case.test.ts @@ -0,0 +1,87 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +const { resolveWorkspaceFileReference } = vi.hoisted(() => ({ + resolveWorkspaceFileReference: vi.fn(), +})) + +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ + resolveWorkspaceFileReference, +})) + +import { + executeCopilotFileUseCase, + resolveCopilotWorkspaceFileReference, +} from '@/lib/copilot/application/execute-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +const trustedContext = { + userId: 'user-1', + workspaceId: 'workspace-1', + chatId: 'chat-1', + executionId: 'execution-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, +} + +describe('executeCopilotFileUseCase', () => { + it('normalizes a file-scoped principal and calls the application use case', async () => { + const execute = vi.fn().mockResolvedValue({ fileId: 'file-1' }) + const useCase = { operation: fileOperations.rename, execute } + + await expect( + executeCopilotFileUseCase( + trustedContext, + useCase, + { fileId: 'file-1', name: 'renamed.txt' }, + { fileId: 'file-1' } + ) + ).resolves.toEqual({ fileId: 'file-1' }) + expect(execute).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-call-1', + resourceScope: expect.objectContaining({ fileId: 'file-1' }), + }), + input: { fileId: 'file-1', name: 'renamed.txt' }, + }) + }) + + it('fails before application execution for an untrusted context', () => { + const execute = vi.fn() + const useCase = { operation: fileOperations.readMetadata, execute } + + expect(() => + executeCopilotFileUseCase({ ...trustedContext, copilotToolExecution: false }, useCase, { + fileId: 'file-1', + }) + ).toThrow('trusted Copilot execution context') + expect(execute).not.toHaveBeenCalled() + }) + + it('normalizes path reference resolution through the same boundary', async () => { + resolveWorkspaceFileReference.mockResolvedValue({ id: 'file-1' }) + + await expect( + resolveCopilotWorkspaceFileReference(trustedContext, fileOperations.readContent, { + workspaceId: 'workspace-1', + reference: 'files/report.txt', + }) + ).resolves.toEqual({ id: 'file-1' }) + expect(resolveWorkspaceFileReference).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + kind: 'delegated', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-call-1', + }), + operation: fileOperations.readContent, + workspaceId: 'workspace-1', + reference: 'files/report.txt', + }) + }) +}) diff --git a/apps/sim/lib/copilot/application/execute-file-use-case.ts b/apps/sim/lib/copilot/application/execute-file-use-case.ts new file mode 100644 index 00000000000..2532fe7c370 --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-file-use-case.ts @@ -0,0 +1,45 @@ +import { + type CopilotFileDelegationContext, + resolveCopilotFilePrincipal, +} from '@/lib/copilot/auth/file-delegation' +import type { OperationUseCase } from '@/lib/core/application' +import { type FileOperation, fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' + +const registeredFileOperationIds = new Set( + Object.values(fileOperations).map((operation) => operation.id) +) + +interface ExecuteCopilotFileUseCaseOptions { + fileId?: string +} + +/** Normalizes trusted Copilot authentication before entering a file application use case. */ +export function executeCopilotFileUseCase( + context: CopilotFileDelegationContext | undefined, + useCase: OperationUseCase, + input: I, + options: ExecuteCopilotFileUseCaseOptions = {} +): Promise { + if (!registeredFileOperationIds.has(useCase.operation.id)) { + throw new Error(`Unregistered Copilot file operation: ${useCase.operation.id}`) + } + + return useCase.execute({ + principal: resolveCopilotFilePrincipal(context, options.fileId), + input, + }) +} + +/** Resolves a model-supplied VFS reference under a trusted Copilot delegation. */ +export function resolveCopilotWorkspaceFileReference( + context: CopilotFileDelegationContext | undefined, + operation: FileOperation, + input: { workspaceId: string; reference: string } +) { + return resolveWorkspaceFileReference({ + principal: resolveCopilotFilePrincipal(context), + operation, + ...input, + }) +} diff --git a/apps/sim/lib/copilot/auth/file-delegation.test.ts b/apps/sim/lib/copilot/auth/file-delegation.test.ts new file mode 100644 index 00000000000..7ced2fecb1d --- /dev/null +++ b/apps/sim/lib/copilot/auth/file-delegation.test.ts @@ -0,0 +1,100 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + createCopilotChatFilePrincipal, + createCopilotWorkspaceContextFilePrincipal, + messageForCopilotFileError, + resolveCopilotFilePrincipal, +} from '@/lib/copilot/auth/file-delegation' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const trustedContext = { + userId: 'user-1', + workspaceId: 'workspace-1', + chatId: 'chat-1', + executionId: 'execution-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, +} + +describe('Copilot file delegation', () => { + it('creates a short-lived principal scoped to the trusted workspace and file', () => { + const principal = resolveCopilotFilePrincipal(trustedContext, 'file-1') + + expect(principal).toMatchObject({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-call-1', + audience: 'sim:workspace-files', + resourceScope: { + fileId: 'file-1', + chatId: 'chat-1', + executionId: 'execution-1', + }, + }) + expect(principal.expiresAt.getTime()).toBeGreaterThan(principal.issuedAt.getTime()) + }) + + it('creates a workspace-scoped principal for file creation', () => { + const principal = resolveCopilotFilePrincipal(trustedContext) + + expect(principal.resourceScope).toEqual({ + chatId: 'chat-1', + executionId: 'execution-1', + }) + }) + + it('rejects contexts that were not issued by the Copilot execution pipeline', () => { + expect(() => + resolveCopilotFilePrincipal({ ...trustedContext, copilotToolExecution: false }, 'file-1') + ).toThrow('trusted Copilot execution context') + expect(() => + resolveCopilotFilePrincipal({ ...trustedContext, toolCallId: undefined }, 'file-1') + ).toThrow('tool call ID') + }) + + it('rejects a missing execution workspace', () => { + expect(() => + resolveCopilotFilePrincipal({ ...trustedContext, workspaceId: undefined }, 'file-1') + ).toThrow('workspace ID') + }) + + it('normalizes chat and workspace-index identities without caller-built delegation fields', () => { + expect( + createCopilotChatFilePrincipal({ + userId: 'user-1', + workspaceId: 'workspace-1', + chatId: 'chat-1', + }) + ).toMatchObject({ + delegationId: 'copilot-chat:chat-1', + resourceScope: { chatId: 'chat-1' }, + }) + expect( + createCopilotWorkspaceContextFilePrincipal({ + userId: 'user-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + }) + ).toMatchObject({ + delegationId: 'copilot-workspace-context:execution-1', + resourceScope: { executionId: 'execution-1' }, + }) + }) + + it('projects only typed domain messages to Copilot', () => { + expect(messageForCopilotFileError(new OrchestrationError('conflict', 'Name exists'))).toBe( + 'Name exists' + ) + expect( + messageForCopilotFileError( + new Error('update workspace_files set ...'), + 'Failed to rename file' + ) + ).toBe('Failed to rename file') + }) +}) diff --git a/apps/sim/lib/copilot/auth/file-delegation.ts b/apps/sim/lib/copilot/auth/file-delegation.ts new file mode 100644 index 00000000000..8f673083aae --- /dev/null +++ b/apps/sim/lib/copilot/auth/file-delegation.ts @@ -0,0 +1,88 @@ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' + +export interface CopilotFileDelegationContext { + userId: string + workspaceId?: string + chatId?: string + executionId?: string + toolCallId?: string + copilotToolExecution?: boolean +} + +export interface CopilotChatFileDelegationContext { + userId: string + workspaceId: string + chatId?: string +} + +export interface CopilotWorkspaceContextFileDelegationContext + extends CopilotChatFileDelegationContext { + executionId?: string +} + +/** Normalizes a trusted Copilot tool context into the shared file principal. */ +export function resolveCopilotFilePrincipal( + context: CopilotFileDelegationContext | undefined, + fileId?: string +): DelegatedPrincipal { + if (!context) { + throw new Error('File delegation requires a Copilot execution context') + } + if (!context.copilotToolExecution) { + throw new Error('File delegation requires a trusted Copilot execution context') + } + if (!context.toolCallId) { + throw new Error('File delegation requires a tool call ID') + } + if (!context.workspaceId) { + throw new Error('File delegation requires a workspace ID') + } + + return createWorkspaceFileDelegatedPrincipal({ + serviceId: 'copilot', + subjectUserId: context.userId, + workspaceId: context.workspaceId, + delegationId: `copilot-tool:${context.toolCallId}`, + fileId, + chatId: context.chatId, + executionId: context.executionId, + }) +} + +/** Creates the principal used while resolving user-supplied chat file context. */ +export function createCopilotChatFilePrincipal( + context: CopilotChatFileDelegationContext +): DelegatedPrincipal { + return createWorkspaceFileDelegatedPrincipal({ + serviceId: 'copilot', + subjectUserId: context.userId, + workspaceId: context.workspaceId, + delegationId: `copilot-chat:${context.chatId ?? context.workspaceId}`, + chatId: context.chatId, + }) +} + +/** Creates the principal used while materializing the Copilot workspace index. */ +export function createCopilotWorkspaceContextFilePrincipal( + context: CopilotWorkspaceContextFileDelegationContext +): DelegatedPrincipal { + return createWorkspaceFileDelegatedPrincipal({ + serviceId: 'copilot', + subjectUserId: context.userId, + workspaceId: context.workspaceId, + delegationId: `copilot-workspace-context:${context.chatId ?? context.executionId ?? context.workspaceId}`, + chatId: context.chatId, + executionId: context.executionId, + }) +} + +export function messageForCopilotFileError( + error: unknown, + fallback = 'File operation failed' +): string { + const classified = asOrchestrationError(error) + if (classified && classified.code !== 'internal') return classified.message + return fallback +} diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index 0e9b85a26f3..0c1307fd4a5 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -17,6 +17,7 @@ const { getSkillById, getUserPermissionConfig, getWorkspaceFile, + readWorkspaceFileMetadata, getTableById, getRowsByIds, getBlockVisibilityForCopilot, @@ -28,6 +29,7 @@ const { getSkillById: vi.fn(), getUserPermissionConfig: vi.fn(), getWorkspaceFile: vi.fn(), + readWorkspaceFileMetadata: vi.fn(), getTableById: vi.fn(), getRowsByIds: vi.fn(), getBlockVisibilityForCopilot: vi.fn(async () => null), @@ -43,6 +45,9 @@ vi.mock('@/lib/integrations/availability.server', () => ({ vi.mock('@/lib/workflows/skills/operations', () => ({ getSkillById })) vi.mock('@/lib/mcp/service', () => ({ mcpService: { discoverServerTools } })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ getWorkspaceFile })) +vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => ({ + readWorkspaceFileMetadata: { execute: readWorkspaceFileMetadata }, +})) vi.mock('@/lib/table/service', () => ({ getTableById })) vi.mock('@/lib/table/rows/service', () => ({ getRowsByIds })) @@ -444,6 +449,13 @@ describe('processContextsServer - logs contexts', () => { describe('processContextsServer - file_selection contexts', () => { beforeEach(() => { vi.clearAllMocks() + readWorkspaceFileMetadata.mockImplementation( + async ({ input }: { input: { fileId: string } }) => { + const file = await getWorkspaceFile('ws-1', input.fileId) + if (!file) throw new Error('File not found') + return { file } + } + ) }) it('inlines the selected passage with its line range and a path pointer', async () => { diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 28f6543841d..7dd0e3ac386 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -6,6 +6,7 @@ import { getActiveWorkflowRecord, } from '@sim/platform-authz/workflow' import { and, eq, isNull } from 'drizzle-orm' +import { createCopilotChatFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { MAX_TABLE_SELECTION_CONTENT_LENGTH, @@ -37,9 +38,9 @@ import { getRowsByIds } from '@/lib/table/rows/service' import { getTableById } from '@/lib/table/service' import type { ColumnDefinition } from '@/lib/table/types' import { getWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getSkillById } from '@/lib/workflows/skills/operations' import { listFolders } from '@/lib/workflows/utils' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { escapeRegExp } from '@/executor/constants' @@ -233,7 +234,7 @@ export async function processContextsServer( } } if (ctx.kind === 'file' && ctx.fileId && currentWorkspaceId) { - const result = await resolveFileResource(ctx.fileId, currentWorkspaceId) + const result = await resolveFileResource(ctx.fileId, currentWorkspaceId, userId, chatId) if (!result) return null return { type: 'file', @@ -249,7 +250,9 @@ export async function processContextsServer( ctx.text ?? '', ctx.label, ctx.startLine, - ctx.endLine + ctx.endLine, + userId, + chatId ) } if ( @@ -847,7 +850,7 @@ export async function resolveActiveResourceContext( return await resolveTableResource(resourceId, workspaceId) } case 'file': { - return await resolveFileResource(resourceId, workspaceId) + return await resolveFileResource(resourceId, workspaceId, userId, chatId) } case 'folder': { return await resolveFolderResource(resourceId, workspaceId) @@ -880,10 +883,19 @@ async function resolveTableResource( async function resolveFileResource( fileId: string, - workspaceId: string + workspaceId: string, + userId: string, + chatId?: string ): Promise { - const record = await getWorkspaceFile(workspaceId, fileId) - if (!record) return null + const principal = createCopilotChatFilePrincipal({ + userId, + workspaceId, + chatId, + }) + const { file: record } = await readWorkspaceFileMetadata.execute({ + principal, + input: { fileId, assertedWorkspaceId: workspaceId }, + }) return { type: 'active_resource', tag: '@active_resource', @@ -919,10 +931,20 @@ async function resolveFileSelectionResource( text: string, label: string, startLine?: number, - endLine?: number + endLine?: number, + userId?: string, + chatId?: string ): Promise { - const record = await getWorkspaceFile(workspaceId, fileId) - if (!record) return null + if (!userId) throw new Error('File selection context requires a user ID') + const principal = createCopilotChatFilePrincipal({ + userId, + workspaceId, + chatId, + }) + const { file: record } = await readWorkspaceFileMetadata.execute({ + principal, + input: { fileId, assertedWorkspaceId: workspaceId }, + }) const path = canonicalWorkspaceFilePath({ folderPath: record.folderPath, name: record.name }) const snippet = truncateSelectionText(text) const lineRange = diff --git a/apps/sim/lib/copilot/chat/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts index d2144ab844a..3652b14547f 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.ts @@ -11,6 +11,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq, inArray, isNull } from 'drizzle-orm' import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' +import { createCopilotWorkspaceContextFilePrincipal } from '@/lib/copilot/auth/file-delegation' import type { VfsSnapshotV1, VfsSnapshotV1Workflow } from '@/lib/copilot/generated/vfs-snapshot-v1' import { filterSecretNamesByMountPolicy, @@ -23,10 +24,10 @@ import { getAccessibleOAuthCredentials, } from '@/lib/credentials/environment' import { listWorkspaceSandboxes } from '@/lib/execution/remote-sandbox/workspace-sandboxes' -import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' import { listCustomBlockSummariesForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { listCustomTools } from '@/lib/workflows/custom-tools/operations' import { listSkillsForUser } from '@/lib/workflows/skills/operations' +import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' import { assertActiveWorkspaceAccess, getUsersWithPermissions, @@ -329,7 +330,7 @@ export function buildWorkspaceContextMd(data: WorkspaceMdData): string { async function buildWorkspaceMdData( workspaceId: string, userId: string, - options?: { workspaceAccess?: WorkspaceAccess } + options?: { workspaceAccess?: WorkspaceAccess; chatId?: string; executionId?: string } ): Promise { try { // Reuse the caller's already-asserted access when provided (hot chat path); @@ -409,7 +410,17 @@ async function buildWorkspaceMdData( ) ), - listWorkspaceFiles(workspaceId), + listAllWorkspaceFiles + .execute({ + principal: createCopilotWorkspaceContextFilePrincipal({ + userId, + workspaceId, + chatId: options?.chatId, + executionId: options?.executionId, + }), + input: { workspaceId, scope: 'active' }, + }) + .then(({ files }) => files), getAccessibleOAuthCredentials(workspaceId, userId), @@ -549,7 +560,12 @@ const WORKSPACE_CONTEXT_UNAVAILABLE_MD = export async function generateWorkspaceContext( workspaceId: string, userId: string, - options?: { workspaceAccess?: WorkspaceAccess; secretMountPolicy?: SecretMountPolicy } + options?: { + workspaceAccess?: WorkspaceAccess + secretMountPolicy?: SecretMountPolicy + chatId?: string + executionId?: string + } ): Promise { const data = await buildWorkspaceMdData(workspaceId, userId, options) if (!data) return WORKSPACE_CONTEXT_UNAVAILABLE_MD diff --git a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts index 3d16cff4bb1..feaa0822416 100644 --- a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts +++ b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' import { MothershipStreamV1EventType } from '@/lib/copilot/generated/mothership-stream-v1' import { createFilePreviewSession, @@ -25,7 +26,8 @@ import { loadWorkspaceFileTextForPreview, type WorkspaceFilePreviewBase, } from '@/lib/copilot/tools/server/files/file-preview' -import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { findWorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' const logger = createLogger('CopilotFilePreviewAdapter') @@ -65,14 +67,22 @@ function toPreviewTargetKind(kind: string | undefined): FilePreviewTargetKind | } async function resolvePreviewTarget(args: { + context: ExecutionContext workspaceId?: string target: FileIntent['target'] }): Promise { if (args.target.kind !== 'path' || !args.workspaceId || !args.target.path) { return args.target } + if (!args.context.copilotToolExecution || !args.context.toolCallId) { + throw new Error('Workspace file preview requires a trusted Copilot execution context') + } - const file = await resolveWorkspaceFileReference(args.workspaceId, args.target.path) + const { files } = await executeCopilotFileUseCase(args.context, listAllWorkspaceFiles, { + workspaceId: args.workspaceId, + scope: 'active', + }) + const file = findWorkspaceFileRecord(files, args.target.path) if (!file) { return args.target } @@ -366,6 +376,7 @@ export async function processFilePreviewStreamEvent(input: { if (toolCallId && parsedArgs) { const { operation, title, contentType, edit } = parsedArgs const target = await resolvePreviewTarget({ + context: execContext, workspaceId: execContext.workspaceId, target: parsedArgs.target, }) @@ -393,7 +404,11 @@ export async function processFilePreviewStreamEvent(input: { fileId && (operation === 'append' || operation === 'patch') ) { - previewBase = await loadWorkspaceFileTextForPreview(execContext.workspaceId, fileId) + previewBase = await loadWorkspaceFileTextForPreview( + execContext, + execContext.workspaceId, + fileId + ) } let session = buildPreviewSessionFromIntent(streamId, intent) @@ -464,7 +479,11 @@ export async function processFilePreviewStreamEvent(input: { execContext.workspaceId && (intent.operation === 'append' || intent.operation === 'patch') ) { - previewBase = await loadWorkspaceFileTextForPreview(execContext.workspaceId, result.fileId) + previewBase = await loadWorkspaceFileTextForPreview( + execContext, + execContext.workspaceId, + result.fileId + ) } let session = buildPreviewSessionFromIntent(streamId, intent) diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index efa9d8ef7d8..32d6a664353 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -23,9 +23,22 @@ vi.mock('@/lib/copilot/request/session', async () => { }) const resolveWorkspaceFileReferenceMock = vi.hoisted(() => vi.fn()) +const listAllWorkspaceFilesMock = vi.hoisted(() => vi.fn()) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ resolveWorkspaceFileReference: resolveWorkspaceFileReferenceMock, + findWorkspaceFileRecord: ( + files: Array<{ name: string; folderPath?: string | null }>, + path: string + ) => + files.find((file) => { + const normalized = path.replace(/^files\//, '').replaceAll('%20', ' ') + const filePath = file.folderPath ? `${file.folderPath}/${file.name}` : file.name + return filePath === normalized + }) ?? null, +})) +vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ + listAllWorkspaceFiles: { execute: listAllWorkspaceFilesMock }, })) vi.mock('@/lib/copilot/tools/server/files/file-preview', async () => { @@ -121,6 +134,8 @@ describe('copilot go stream helpers', () => { vi.stubGlobal('fetch', vi.fn()) resolveWorkspaceFileReferenceMock.mockReset() resolveWorkspaceFileReferenceMock.mockResolvedValue(null) + listAllWorkspaceFilesMock.mockReset() + listAllWorkspaceFilesMock.mockResolvedValue({ files: [] }) }) afterEach(() => { @@ -169,9 +184,8 @@ describe('copilot go stream helpers', () => { }) it('hydrates path-based workspace_file edits into file preview events before edit_content streams', async () => { - resolveWorkspaceFileReferenceMock.mockResolvedValue({ - id: 'file-1', - name: 'notes.md', + listAllWorkspaceFilesMock.mockResolvedValue({ + files: [{ id: 'file-1', name: 'notes.md', folderPath: null }], }) const workspaceFileCall = createEvent({ @@ -274,6 +288,8 @@ describe('copilot go stream helpers', () => { workflowId: 'workflow-1', workspaceId: 'workspace-1', messageId: 'msg-1', + copilotToolExecution: true, + toolCallId: 'stream-tool-1', } await runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, { @@ -308,13 +324,24 @@ describe('copilot go stream helpers', () => { previewPhase: 'file_preview_complete', fileId: 'file-1', }) - expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith('workspace-1', 'files/notes.md') + expect(listAllWorkspaceFilesMock).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + kind: 'delegated', + workspaceId: 'workspace-1', + }), + input: { workspaceId: 'workspace-1', scope: 'active' }, + }) }) it('resolves workflow alias paths to the backing file before streaming previews', async () => { - resolveWorkspaceFileReferenceMock.mockResolvedValue({ - id: 'changelog-file-1', - name: 'workflow-1.md', + listAllWorkspaceFilesMock.mockResolvedValue({ + files: [ + { + id: 'changelog-file-1', + name: 'changelog.md', + folderPath: 'workflows/My Workflow', + }, + ], }) const workspaceFileCall = createEvent({ @@ -392,6 +419,8 @@ describe('copilot go stream helpers', () => { workflowId: 'workflow-1', workspaceId: 'workspace-1', messageId: 'msg-1', + copilotToolExecution: true, + toolCallId: 'stream-tool-2', } await runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, { @@ -414,7 +443,7 @@ describe('copilot go stream helpers', () => { ]) expect(previewEvents[1].payload).toMatchObject({ previewPhase: 'file_preview_target', - target: { kind: 'file_id', fileId: 'changelog-file-1', fileName: 'workflow-1.md' }, + target: { kind: 'file_id', fileId: 'changelog-file-1', fileName: 'changelog.md' }, }) expect(previewEvents[2].payload).toMatchObject({ previewPhase: 'file_preview_content', @@ -426,10 +455,13 @@ describe('copilot go stream helpers', () => { previewPhase: 'file_preview_complete', fileId: 'changelog-file-1', }) - expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith( - 'workspace-1', - 'workflows/My%20Workflow/changelog.md' - ) + expect(listAllWorkspaceFilesMock).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + kind: 'delegated', + workspaceId: 'workspace-1', + }), + input: { workspaceId: 'workspace-1', scope: 'active' }, + }) }) it('drops duplicate tool_result events before forwarding them', async () => { diff --git a/apps/sim/lib/copilot/request/tools/files.test.ts b/apps/sim/lib/copilot/request/tools/files.test.ts index f09a7c396d6..facdd2b6f41 100644 --- a/apps/sim/lib/copilot/request/tools/files.test.ts +++ b/apps/sim/lib/copilot/request/tools/files.test.ts @@ -8,7 +8,7 @@ const { mockWriteWorkspaceFileByPath } = vi.hoisted(() => ({ })) vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ - writeWorkspaceFileByPath: mockWriteWorkspaceFileByPath, + writeCopilotWorkspaceFileByPath: mockWriteWorkspaceFileByPath, })) vi.mock('@/lib/copilot/request/otel', () => ({ @@ -113,6 +113,8 @@ describe('maybeWriteOutputToFile', () => { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'workspace-1', + toolCallId: 'tool-1', + copilotToolExecution: true, userPermission: 'write', resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry(), ...overrides, @@ -195,7 +197,7 @@ describe('maybeWriteOutputToFile', () => { ) expect(result.success).toBe(true) - const persisted = mockWriteWorkspaceFileByPath.mock.calls[0][0].buffer.toString('utf8') + const persisted = mockWriteWorkspaceFileByPath.mock.calls[0][1].buffer.toString('utf8') expect(JSON.parse(persisted)).toEqual({ token: '{{OUTPUT_SECRET}}', publicLabel: 'true', diff --git a/apps/sim/lib/copilot/request/tools/files.ts b/apps/sim/lib/copilot/request/tools/files.ts index 43ffdcc9c27..d093342703d 100644 --- a/apps/sim/lib/copilot/request/tools/files.ts +++ b/apps/sim/lib/copilot/request/tools/files.ts @@ -13,7 +13,7 @@ import { } from '@/lib/copilot/request/tools/resolved-secret-result' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' -import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' +import { writeCopilotWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' const logger = createLogger('CopilotToolResultFiles') @@ -285,9 +285,8 @@ export async function maybeWriteOutputToFile( throw new Error('Request aborted before tool mutation could be applied') } - const written = await writeWorkspaceFileByPath({ + const written = await writeCopilotWorkspaceFileByPath(context, { workspaceId: context.workspaceId!, - userId: context.userId!, target: { path: outputFile.path, mode: outputFile.mode ?? 'create', diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts index 54817fffbc3..916149e9b04 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts @@ -15,8 +15,8 @@ const { updateCustomBlockMock, deleteCustomBlockMock, getCustomBlockWithInputsByWorkflowIdMock, - listWorkspaceFilesMock, - fetchWorkspaceFileBufferMock, + resolveWorkspaceFileReferenceMock, + readWorkspaceFileContentMock, uploadFileMock, } = vi.hoisted(() => ({ ensureWorkflowAccessMock: vi.fn(), @@ -27,8 +27,8 @@ const { updateCustomBlockMock: vi.fn(), deleteCustomBlockMock: vi.fn(), getCustomBlockWithInputsByWorkflowIdMock: vi.fn(), - listWorkspaceFilesMock: vi.fn(), - fetchWorkspaceFileBufferMock: vi.fn(), + resolveWorkspaceFileReferenceMock: vi.fn(), + readWorkspaceFileContentMock: vi.fn(), uploadFileMock: vi.fn(), })) @@ -51,9 +51,14 @@ vi.mock('@/lib/billing', () => ({ isOrganizationOnEnterprisePlan: isOrganizationOnEnterprisePlanMock, })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - listWorkspaceFiles: listWorkspaceFilesMock, - fetchWorkspaceFileBuffer: fetchWorkspaceFileBufferMock, +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ + resolveWorkspaceFileReference: resolveWorkspaceFileReferenceMock, +})) +vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ + readWorkspaceFileContent: { + operation: { id: 'files.read_content' }, + execute: readWorkspaceFileContentMock, + }, })) vi.mock('@/lib/uploads/core/storage-service', () => ({ @@ -77,7 +82,13 @@ vi.mock('@/lib/workflows/custom-blocks/operations', () => { import { executeDeployCustomBlock } from './custom-block' -const context = { userId: 'user-1', workflowId: 'wf-1' } as ExecutionContext +const context = { + userId: 'user-1', + workflowId: 'wf-1', + workspaceId: 'ws-1', + toolCallId: 'tool-1', + copilotToolExecution: true, +} as ExecutionContext const publishedBlock = { id: 'cb-1', @@ -327,16 +338,18 @@ describe('executeDeployCustomBlock', () => { }) it('ingests a workspace-file icon into public icon storage', async () => { - listWorkspaceFilesMock.mockResolvedValue([ - { - name: 'icon.png', - folderPath: null, - type: 'image/png', - size: 1024, - key: 'workspace/ws-1/123-abc-icon.png', - }, - ]) - fetchWorkspaceFileBufferMock.mockResolvedValue(Buffer.from('png-bytes')) + resolveWorkspaceFileReferenceMock.mockResolvedValue({ + id: 'file-1', + name: 'icon.png', + folderPath: null, + type: 'image/png', + size: 1024, + key: 'workspace/ws-1/123-abc-icon.png', + }) + readWorkspaceFileContentMock.mockResolvedValue({ + file: { id: 'file-1', name: 'icon.png' }, + content: Buffer.from('png-bytes'), + }) uploadFileMock.mockResolvedValue({ path: '/api/files/serve/s3/workspace-logos%2Ficon.png' }) publishCustomBlockMock.mockResolvedValue(publishedBlock) @@ -384,7 +397,7 @@ describe('executeDeployCustomBlock', () => { }) it('fails when the icon workspace file does not exist', async () => { - listWorkspaceFilesMock.mockResolvedValue([]) + resolveWorkspaceFileReferenceMock.mockRejectedValue(new Error('File not found')) const result = await executeDeployCustomBlock( { @@ -436,9 +449,14 @@ describe('executeDeployCustomBlock', () => { }) it('fails when the icon workspace file is not an image', async () => { - listWorkspaceFilesMock.mockResolvedValue([ - { name: 'notes.pdf', folderPath: null, type: 'application/pdf', size: 1024, key: 'k' }, - ]) + resolveWorkspaceFileReferenceMock.mockResolvedValue({ + id: 'file-2', + name: 'notes.pdf', + folderPath: null, + type: 'application/pdf', + size: 1024, + key: 'k', + }) const result = await executeDeployCustomBlock( { diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts index 8b2182b6abe..ca51f6bafcd 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts @@ -3,13 +3,13 @@ import { toError } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import { isAllowedCustomBlockIconUrl } from '@/lib/api/contracts/custom-blocks' import { isOrganizationOnEnterprisePlan } from '@/lib/billing' +import { + executeCopilotFileUseCase, + resolveCopilotWorkspaceFileReference, +} from '@/lib/copilot/application/execute-file-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { canonicalizeVfsPath, canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' +import { canonicalizeVfsPath } from '@/lib/copilot/vfs/path-utils' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' -import { - fetchWorkspaceFileBuffer, - listWorkspaceFiles, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { uploadFile } from '@/lib/uploads/core/storage-service' import { isImageFileType } from '@/lib/uploads/utils/file-utils' import { @@ -20,6 +20,8 @@ import { publishCustomBlock, updateCustomBlock, } from '@/lib/workflows/custom-blocks/operations' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' import { ensureWorkflowAccess } from '../access' import type { DeployCustomBlockParams } from '../param-types' @@ -38,7 +40,7 @@ const MAX_OUTPUT_ENTRIES = 50 */ async function resolveIconUrl( raw: string | undefined, - userId: string, + context: ExecutionContext, workspaceId: string ): Promise { const value = raw?.trim() @@ -53,13 +55,12 @@ async function resolveIconUrl( } const canonical = canonicalizeVfsPath(value) - const files = await listWorkspaceFiles(workspaceId, { hydrateFolderPaths: true }) - const record = files.find( - (f) => canonicalWorkspaceFilePath({ folderPath: f.folderPath, name: f.name }) === canonical - ) - if (!record) { + const record = await resolveCopilotWorkspaceFileReference(context, fileOperations.readContent, { + workspaceId, + reference: canonical, + }).catch(() => { throw new CustomBlockValidationError(`Icon file not found in this workspace: ${value}`) - } + }) if (!isImageFileType(record.type)) { throw new CustomBlockValidationError( 'Icon file must be an image (PNG, JPEG, GIF, WebP, or SVG)' @@ -69,7 +70,12 @@ async function resolveIconUrl( throw new CustomBlockValidationError('Icon file must be 5MB or smaller') } - const buffer = await fetchWorkspaceFileBuffer(record) + const { content: buffer } = await executeCopilotFileUseCase( + context, + readWorkspaceFileContent, + { fileId: record.id, assertedWorkspaceId: workspaceId, maxBytes: MAX_ICON_BYTES }, + { fileId: record.id } + ) const safeFileName = record.name.replace(/[^a-zA-Z0-9.-]/g, '_') const uploaded = await uploadFile({ file: buffer, @@ -78,7 +84,7 @@ async function resolveIconUrl( context: 'workspace-logos', customKey: `workspace-logos/${Date.now()}-${generateShortId()}-${safeFileName}`, preserveKey: true, - metadata: { workspaceId, userId, originalName: record.name }, + metadata: { workspaceId, userId: context.userId, originalName: record.name }, }) return uploaded.path } @@ -218,7 +224,7 @@ export async function executeDeployCustomBlock( if (params.exposedOutputs?.some((entry) => entry.name.length > 60)) { return { success: false, error: 'exposed output names must be 60 characters or fewer' } } - const iconUrl = await resolveIconUrl(params.iconUrl, context.userId, workspaceId) + const iconUrl = await resolveIconUrl(params.iconUrl, context, workspaceId) if (existing) { await updateCustomBlock(existing.id, { diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts index 8257c9cbcd3..15f33a0ba4a 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts @@ -26,6 +26,10 @@ const { mockFetchServableWorkspaceFileBuffer, mockGetSandboxWorkspaceFilePath, mockListWorkspaceFileFolders, + mockListAllWorkspaceFiles, + mockListWorkspaceFileFoldersOperation, + mockDownloadWorkspaceFileRecord, + mockReadWorkspaceFileContent, mockMaterializeCopilotCodeSecrets, mockHasWorkspaceSandboxAccess, mockImportWorkspaceFileSecretProvenanceForRuntime, @@ -47,6 +51,10 @@ const { mockFetchServableWorkspaceFileBuffer: vi.fn(), mockGetSandboxWorkspaceFilePath: vi.fn(), mockListWorkspaceFileFolders: vi.fn(), + mockListAllWorkspaceFiles: vi.fn(), + mockListWorkspaceFileFoldersOperation: vi.fn(), + mockDownloadWorkspaceFileRecord: vi.fn(), + mockReadWorkspaceFileContent: vi.fn(), mockMaterializeCopilotCodeSecrets: vi.fn(), mockHasWorkspaceSandboxAccess: vi.fn(), mockImportWorkspaceFileSecretProvenanceForRuntime: vi.fn(), @@ -85,6 +93,18 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ listWorkspaceFileFolders: mockListWorkspaceFileFolders, })) +vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ + listAllWorkspaceFiles: { execute: mockListAllWorkspaceFiles }, +})) +vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ + listWorkspaceFileFoldersOperation: { execute: mockListWorkspaceFileFoldersOperation }, +})) +vi.mock('@/lib/workspace-files/application/read-workspace-file-record', () => ({ + downloadWorkspaceFileRecord: { execute: mockDownloadWorkspaceFileRecord }, +})) +vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ + readWorkspaceFileContent: { execute: mockReadWorkspaceFileContent }, +})) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ importWorkspaceFileSecretProvenanceForRuntime: mockImportWorkspaceFileSecretProvenanceForRuntime, })) @@ -115,7 +135,12 @@ const table = { schema: { columns: [{ id: 'col_name', name: 'name', type: 'string' }] }, } -const context = { workspaceId: 'ws_1', userId: 'u1' } +const context = { + workspaceId: 'ws_1', + userId: 'u1', + copilotToolExecution: true, + toolCallId: 'function-execute-test', +} function mountedFiles() { const params = mockExecuteTool.mock.calls[0][1] as { @@ -138,6 +163,37 @@ function resetExecutionMocks(): void { entries: [], }) mockIsTableSnapshotSafeForModelMount.mockResolvedValue(true) + mockListWorkspaceFiles.mockResolvedValue([]) + mockListWorkspaceFileFolders.mockResolvedValue([]) + mockListAllWorkspaceFiles.mockImplementation(async () => { + const files = await mockListWorkspaceFiles() + if (files.length > 0) return { files } + const fallback = mockFindWorkspaceFileRecord() + return { files: fallback ? [fallback] : [] } + }) + mockListWorkspaceFileFoldersOperation.mockImplementation(async () => ({ + folders: await mockListWorkspaceFileFolders(), + })) + mockDownloadWorkspaceFileRecord.mockImplementation( + async ({ input }: { input: { fileId: string } }) => { + const files = await mockListWorkspaceFiles() + const file = + files.find((candidate: { id: string }) => candidate.id === input.fileId) ?? + mockFindWorkspaceFileRecord() + if (!file) throw new Error('File not found') + return { file } + } + ) + mockReadWorkspaceFileContent.mockImplementation( + async ({ input }: { input: { fileId: string } }) => { + const files = await mockListWorkspaceFiles() + const file = + files.find((candidate: { id: string }) => candidate.id === input.fileId) ?? + mockFindWorkspaceFileRecord() + if (!file) throw new Error('File not found') + return { file, content: await mockFetchWorkspaceFileBuffer(file) } + } + ) } describe('executeFunctionExecute trace-secret provenance', () => { @@ -891,7 +947,9 @@ describe('executeFunctionExecute file mounts', () => { }) it('cloud storage: throws when a file exceeds the per-file URL mount limit', async () => { - mockFindWorkspaceFileRecord.mockReturnValue({ ...fileRecord, size: 600 * 1024 * 1024 }) + const oversized = { ...fileRecord, size: 600 * 1024 * 1024 } + mockFindWorkspaceFileRecord.mockReturnValue(oversized) + mockListWorkspaceFiles.mockResolvedValue([oversized]) await expect( executeFunctionExecute({ inputFiles: ['files/data.csv'] }, context as never) @@ -901,7 +959,15 @@ describe('executeFunctionExecute file mounts', () => { it('cloud storage: throws when mounts exceed the aggregate URL mount limit', async () => { // Each file is at the 500MB per-file cap; the 5th pushes the running total past 2GB. - mockFindWorkspaceFileRecord.mockReturnValue({ ...fileRecord, size: 500 * 1024 * 1024 }) + const oversized = { ...fileRecord, size: 500 * 1024 * 1024 } + mockFindWorkspaceFileRecord.mockReturnValue(oversized) + mockListWorkspaceFiles.mockResolvedValue( + Array.from({ length: 5 }, (_, i) => ({ + ...oversized, + id: `file_${i}`, + name: `big-${i}.csv`, + })) + ) const paths = Array.from({ length: 5 }, (_, i) => `files/big-${i}.csv`) await expect(executeFunctionExecute({ inputFiles: paths }, context as never)).rejects.toThrow( diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 067a41f07ca..8b07b401248 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -1,6 +1,8 @@ +import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { omit } from '@sim/utils/object' import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' +import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { applySecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import type { ToolExecutionContext, ToolExecutionResult } from '@/lib/copilot/tool-executor/types' import { @@ -27,13 +29,10 @@ import { import { queryRows } from '@/lib/table/rows/service' import { getTableById, listTables } from '@/lib/table/service' import { getOrCreateTableSnapshot, SNAPSHOT_MAX_BYTES } from '@/lib/table/snapshot-cache' -import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { fetchServableWorkspaceFileBuffer, - fetchWorkspaceFileBuffer, findWorkspaceFileRecord, getSandboxWorkspaceFilePath, - listWorkspaceFiles, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { importWorkspaceFileSecretProvenanceForRuntime } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' @@ -43,6 +42,10 @@ import { hasCloudStorage, } from '@/lib/uploads/core/storage-service' import { isGeneratedDocumentSourceType } from '@/lib/uploads/utils/file-utils' +import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' +import { downloadWorkspaceFileRecord } from '@/lib/workspace-files/application/read-workspace-file-record' +import { listWorkspaceFileFoldersOperation } from '@/lib/workspace-files/application/workspace-file-folders' import { extractCodeSecretNames } from '@/executor/utils/code-secret-references' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { executeTool as executeAppTool } from '@/tools' @@ -132,8 +135,15 @@ async function pushWorkspaceFileMount( mountPath: string, mounted: MountedBytes, workspaceId: string, + principal: Principal, registry?: ResolvedSecretTraceRegistry ): Promise { + record = ( + await downloadWorkspaceFileRecord.execute({ + principal, + input: { fileId: record.id, assertedWorkspaceId: workspaceId }, + }) + ).file await importMountedWorkspaceFileProvenance({ workspaceId, record, mountPath, registry }) // A generated document stores its generator source, so a presigned URL for @@ -191,7 +201,19 @@ async function pushWorkspaceFileMount( `Input file "${mountPath}" renders to more than the ${MAX_FILE_SIZE / 1024 / 1024}MB per-file mount limit, or than the mount budget left. Mount fewer or smaller files.` ) }) - : { buffer: await fetchWorkspaceFileBuffer(record), contentType: record.type } + : { + buffer: ( + await readWorkspaceFileContent.execute({ + principal, + input: { + fileId: record.id, + assertedWorkspaceId: workspaceId, + maxBytes: Math.min(MAX_FILE_SIZE, remainingBudget), + }, + }) + ).content, + contentType: record.type, + } // Keyed off the resolved type: a rendered document's source MIME is `text/x-…`, and // decoding the binary as UTF-8 would corrupt it just as surely as shipping the source. const isText = /^text\/|application\/json|application\/xml|application\/csv/.test( @@ -286,18 +308,25 @@ export async function resolveInputFiles( inputTables?: unknown[], inputDirectories?: unknown[], provenanceUserId?: string, - resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry + resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry, + filePrincipal?: Principal ): Promise { const sandboxFiles: SandboxFile[] = [] const mounted: MountedBytes = { buffered: 0, url: 0 } if (inputFiles?.length && workspaceId) { + if (!filePrincipal) { + throw new Error('Workspace file mounts require a trusted Copilot principal') + } if (inputFiles.length > MAX_MOUNTED_FILES) { throw new Error( `Too many input files (${inputFiles.length}). Maximum is ${MAX_MOUNTED_FILES}. Mount fewer files.` ) } - const allFiles = await listWorkspaceFiles(workspaceId) + const { files: allFiles } = await listAllWorkspaceFiles.execute({ + principal: filePrincipal, + input: { workspaceId, scope: 'active' }, + }) for (const fileRef of inputFiles) { const filePath = typeof fileRef === 'string' @@ -327,14 +356,24 @@ export async function resolveInputFiles( mountPath, mounted, workspaceId, + filePrincipal, resolvedSecretTraceRegistry ) } } if (inputDirectories?.length && workspaceId) { - const folders = await listWorkspaceFileFolders(workspaceId) - const allFiles = await listWorkspaceFiles(workspaceId, { folders }) + if (!filePrincipal) { + throw new Error('Workspace directory mounts require a trusted Copilot principal') + } + const { folders } = await listWorkspaceFileFoldersOperation.execute({ + principal: filePrincipal, + input: { workspaceId }, + }) + const { files: allFiles } = await listAllWorkspaceFiles.execute({ + principal: filePrincipal, + input: { workspaceId, scope: 'active' }, + }) for (const dirRef of inputDirectories) { const dirPath = typeof dirRef === 'string' @@ -405,6 +444,7 @@ export async function resolveInputFiles( `${mountRoot}/${relativePath}`, mounted, workspaceId, + filePrincipal, resolvedSecretTraceRegistry ) } @@ -634,7 +674,10 @@ export async function executeFunctionExecute( inputTables, inputDirectories, secretActorUserId ?? context.userId, - mountedRegistry + mountedRegistry, + inputFiles.length > 0 || inputDirectories.length > 0 + ? resolveCopilotFilePrincipal(context) + : undefined ) if (resolved.length > 0) { const existing = (enrichedParams._sandboxFiles as SandboxFile[]) || [] diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts index b789a2360a5..69848850802 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockAllocateUniqueWorkspaceFileName, + mockAdmitCreateWorkspaceFile, mockCheckStorageQuotaForBillingContext, mockDecompress, mockFetchBuffer, @@ -17,9 +18,11 @@ const { mockHeadObject, mockIncrementStorageUsageForBillingContextInTx, mockMaybeNotifyStorageLimitForBillingContext, + mockReadWorkspaceFileMetadata, mockResolveStorageBillingContext, } = vi.hoisted(() => ({ mockAllocateUniqueWorkspaceFileName: vi.fn(), + mockAdmitCreateWorkspaceFile: vi.fn(), mockCheckStorageQuotaForBillingContext: vi.fn(), mockDecompress: vi.fn(), mockFetchBuffer: vi.fn(), @@ -31,6 +34,7 @@ const { mockHeadObject: vi.fn(), mockIncrementStorageUsageForBillingContextInTx: vi.fn(), mockMaybeNotifyStorageLimitForBillingContext: vi.fn(), + mockReadWorkspaceFileMetadata: vi.fn(), mockResolveStorageBillingContext: vi.fn(), })) @@ -52,6 +56,14 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ getWorkspaceFile: mockGetWorkspaceFile, })) +vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => ({ + readWorkspaceFileMetadata: { execute: mockReadWorkspaceFileMetadata }, +})) + +vi.mock('@/lib/workspace-files/application/create-workspace-file', () => ({ + admitCreateWorkspaceFile: mockAdmitCreateWorkspaceFile, +})) + vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ getBoundWorkspaceFileSecretProvenance: mockGetBoundWorkspaceFileSecretProvenance, })) @@ -119,8 +131,22 @@ const context = { workspaceId: 'ws-1', userId: 'user-1', workflowId: 'wf-1', + copilotToolExecution: true, + toolCallId: 'materialize-file-test', } as ExecutionContext +mockReadWorkspaceFileMetadata.mockImplementation( + async ({ input }: { input: { fileId: string; assertedWorkspaceId?: string } }) => ({ + file: await mockGetWorkspaceFile( + input.assertedWorkspaceId ?? context.workspaceId, + input.fileId, + { + throwOnError: true, + } + ), + }) +) + const STORAGE_CONTEXT = { workspaceId: 'ws-1', billedAccountUserId: 'workspace-owner', @@ -156,9 +182,12 @@ describe('executeMaterializeFile - workspace write gate', () => { 'refuses %s without workspace write access and touches no upload', async (operation) => { const { ensureWorkspaceAccess } = await import('@/lib/copilot/tools/handlers/access') - vi.mocked(ensureWorkspaceAccess).mockRejectedValueOnce( - new Error('Write access required for this workspace') - ) + const denial = new Error('Write access required for this workspace') + if (operation === 'import') { + vi.mocked(ensureWorkspaceAccess).mockRejectedValueOnce(denial) + } else { + mockAdmitCreateWorkspaceFile.mockRejectedValueOnce(denial) + } const result = await executeMaterializeFile({ fileNames: ['a.json'], operation }, context) @@ -169,10 +198,12 @@ describe('executeMaterializeFile - workspace write gate', () => { ) it('requires write, not merely read, access', async () => { - const { ensureWorkspaceAccess } = await import('@/lib/copilot/tools/handlers/access') await executeMaterializeFile({ fileNames: ['a.json'], operation: 'save' }, context) - expect(ensureWorkspaceAccess).toHaveBeenCalledWith(context.workspaceId, context.userId, 'write') + expect(mockAdmitCreateWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'delegated', subjectUserId: context.userId }), + context.workspaceId + ) }) }) @@ -578,7 +609,11 @@ describe('executeMaterializeFile - extract operation', () => { expect.any(Buffer), expect.objectContaining({ workspaceId: 'ws-1', - userId: 'user-1', + principal: expect.objectContaining({ + kind: 'delegated', + subjectUserId: 'user-1', + workspaceId: 'ws-1', + }), rootFolderSegments: ['bundle'], skipNoiseEntries: true, secretProvenance: { status: 'exact', entries: [] }, diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index 3c6f05e17e0..25dc2a51685 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -1,4 +1,5 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import type { Principal } from '@sim/auth/principal' import { db } from '@sim/db' import { folder as folderTable, workflow, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -16,6 +17,7 @@ import { maybeNotifyStorageLimitForBillingContext, resolveStorageBillingContext, } from '@/lib/billing/storage' +import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/tools/handlers/upload-file-reader' @@ -31,7 +33,6 @@ import { findWorkspaceFileFolderIdByPath } from '@/lib/uploads/contexts/workspac import { allocateUniqueWorkspaceFileName, fetchWorkspaceFileBuffer, - getWorkspaceFile, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getBoundWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { hasCloudStorage, headObject } from '@/lib/uploads/core/storage-service' @@ -39,6 +40,8 @@ import { isArchiveFileName } from '@/lib/uploads/utils/file-utils' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { deduplicateWorkflowName } from '@/lib/workflows/utils' +import { admitCreateWorkspaceFile } from '@/lib/workspace-files/application/create-workspace-file' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { extractWorkflowMetadata } from '@/app/api/v1/admin/types' const logger = createLogger('MaterializeFile') @@ -80,7 +83,8 @@ function uploadBelongsToWorkspace( async function executeSave( fileName: string, chatId: string, - workspaceId: string + workspaceId: string, + principal: Principal ): Promise { const row = await findMothershipUploadRowByChatAndName(chatId, fileName) if (!row) { @@ -183,7 +187,12 @@ async function executeSave( const replayedFile = transition ? null - : await getWorkspaceFile(workspaceId, row.id, { throwOnError: true }) + : ( + await readWorkspaceFileMetadata.execute({ + principal, + input: { fileId: row.id, assertedWorkspaceId: workspaceId }, + }) + ).file const updated = transition?.updated ?? (replayedFile ? { id: replayedFile.id, originalName: replayedFile.name } : null) @@ -371,7 +380,8 @@ async function executeExtract( fileName: string, chatId: string, workspaceId: string, - userId: string + userId: string, + principal: Principal ): Promise { const row = await findMothershipUploadRowByChatAndName(chatId, fileName) if (!row) { @@ -461,7 +471,7 @@ async function executeExtract( }) result = await decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId, - userId, + principal, rootFolderSegments: [baseName], // The agent-facing extract drops macOS/Windows filesystem cruft so the // unpacked files/ tree only contains meaningful entries. @@ -543,6 +553,8 @@ export async function executeMaterializeFile( return { success: false, error: 'No workspace context available for materialize_file' } } + const principal = resolveCopilotFilePrincipal(context) + const operation = (params.operation as string | undefined) || 'save' // save (promote upload → workspace file), import (JSON → workflow), and extract // (decompress a .zip upload → workspace files/) are implemented. Reject anything @@ -554,10 +566,12 @@ export async function executeMaterializeFile( } } - // Every operation writes: save/extract create files, import creates a workflow. - // The handler-map path has no central permission gate. try { - await ensureWorkspaceAccess(context.workspaceId, context.userId, 'write') + if (operation === 'import') { + await ensureWorkspaceAccess(context.workspaceId, context.userId, 'write') + } else { + await admitCreateWorkspaceFile(principal, context.workspaceId) + } } catch (error) { return { success: false, error: getErrorMessage(error, 'Workspace write access required') } } @@ -572,9 +586,15 @@ export async function executeMaterializeFile( if (operation === 'import') { result = await executeImport(fileName, context.chatId, context.workspaceId, context.userId) } else if (operation === 'extract') { - result = await executeExtract(fileName, context.chatId, context.workspaceId, context.userId) + result = await executeExtract( + fileName, + context.chatId, + context.workspaceId, + context.userId, + principal + ) } else { - result = await executeSave(fileName, context.chatId, context.workspaceId) + result = await executeSave(fileName, context.chatId, context.workspaceId, principal) } if (result.success) { diff --git a/apps/sim/lib/copilot/tools/handlers/resources.test.ts b/apps/sim/lib/copilot/tools/handlers/resources.test.ts index 8e69e1dce8f..f470d47e6da 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.test.ts @@ -4,14 +4,29 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { getWorkspaceFileMock, resolveWorkspaceFileReferenceMock } = vi.hoisted(() => ({ - getWorkspaceFileMock: vi.fn(), - resolveWorkspaceFileReferenceMock: vi.fn(), +const { listAllWorkspaceFilesMock, readWorkspaceFileMetadataMock } = vi.hoisted(() => ({ + listAllWorkspaceFilesMock: vi.fn(), + readWorkspaceFileMetadataMock: vi.fn(), })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - getWorkspaceFile: getWorkspaceFileMock, - resolveWorkspaceFileReference: resolveWorkspaceFileReferenceMock, + findWorkspaceFileRecord: ( + files: Array<{ id: string; name: string; folderPath: string | null }> + ) => files[0] ?? null, +})) + +vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ + listAllWorkspaceFiles: { + operation: { id: 'files.list' }, + execute: listAllWorkspaceFilesMock, + }, +})) + +vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => ({ + readWorkspaceFileMetadata: { + operation: { id: 'files.read_metadata' }, + execute: readWorkspaceFileMetadataMock, + }, })) vi.mock('@/lib/workflows/utils', () => ({ @@ -38,20 +53,35 @@ describe('executeOpenResource', () => { }) it('opens workspace files with canonical non-UUID file ids', async () => { - getWorkspaceFileMock.mockResolvedValue({ - id: 'wf_qL_cfff-FskMsXtOdm599', - name: 'MAC_Brand_Guidelines_May_2021 (1).docx', - folderPath: null, + readWorkspaceFileMetadataMock.mockResolvedValue({ + file: { + id: 'wf_qL_cfff-FskMsXtOdm599', + name: 'MAC_Brand_Guidelines_May_2021 (1).docx', + folderPath: null, + }, }) const result = await executeOpenResource( { resources: [{ type: 'file', id: 'wf_qL_cfff-FskMsXtOdm599' }], }, - { userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' } + { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + toolCallId: 'tool-1', + copilotToolExecution: true, + } ) - expect(getWorkspaceFileMock).toHaveBeenCalledWith('workspace-1', 'wf_qL_cfff-FskMsXtOdm599') + expect(readWorkspaceFileMetadataMock).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + fileId: 'wf_qL_cfff-FskMsXtOdm599', + assertedWorkspaceId: 'workspace-1', + }, + }) + ) expect(result).toMatchObject({ success: true, output: { opened: 1, errors: [] }, @@ -67,22 +97,31 @@ describe('executeOpenResource', () => { }) it('opens workspace files by canonical VFS path', async () => { - resolveWorkspaceFileReferenceMock.mockResolvedValue({ - id: 'wf_qL_cfff-FskMsXtOdm599', - name: 'MAC_Brand_Guidelines_May_2021 (1).docx', - folderPath: 'Docs', + listAllWorkspaceFilesMock.mockResolvedValue({ + files: [ + { + id: 'wf_qL_cfff-FskMsXtOdm599', + name: 'MAC_Brand_Guidelines_May_2021 (1).docx', + folderPath: 'Docs', + }, + ], }) const result = await executeOpenResource( { resources: [{ type: 'file', path: 'files/Docs/MAC_Brand_Guidelines.docx' }], }, - { userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' } + { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + toolCallId: 'tool-1', + copilotToolExecution: true, + } ) - expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith( - 'workspace-1', - 'files/Docs/MAC_Brand_Guidelines.docx' + expect(listAllWorkspaceFilesMock).toHaveBeenCalledWith( + expect.objectContaining({ input: { workspaceId: 'workspace-1', scope: 'active' } }) ) expect(result).toMatchObject({ success: true, diff --git a/apps/sim/lib/copilot/tools/handlers/resources.ts b/apps/sim/lib/copilot/tools/handlers/resources.ts index 52ea670cf74..6a5e5556d4b 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.ts @@ -1,3 +1,4 @@ +import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { type MothershipResource, MothershipResourceType } from '@/lib/copilot/resources/types' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' @@ -5,10 +6,12 @@ import { getKnowledgeBaseById } from '@/lib/knowledge/service' import { getLogById } from '@/lib/logs/service' import { getTableById } from '@/lib/table/service' import { - getWorkspaceFile, - resolveWorkspaceFileReference, + findWorkspaceFileRecord, + type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getWorkflowById } from '@/lib/workflows/utils' +import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import type { OpenResourceItem, OpenResourceParams, ValidOpenResourceParams } from './param-types' const VALID_OPEN_RESOURCE_TYPES = new Set(Object.values(MothershipResourceType)) @@ -25,11 +28,25 @@ async function resolveResource( if (!context.workspaceId) return { error: 'Opening a workspace file requires workspace context.' } const fileRef = item.path || item.id || '' - const record = item.path - ? await resolveWorkspaceFileReference(context.workspaceId, item.path) - : item.id - ? await getWorkspaceFile(context.workspaceId, item.id) - : null + let record: WorkspaceFileRecord | null + if (item.path) { + const { files } = await executeCopilotFileUseCase(context, listAllWorkspaceFiles, { + workspaceId: context.workspaceId, + scope: 'active', + }) + record = findWorkspaceFileRecord(files, item.path) + } else if (item.id) { + record = ( + await executeCopilotFileUseCase( + context, + readWorkspaceFileMetadata, + { fileId: item.id, assertedWorkspaceId: context.workspaceId }, + { fileId: item.id } + ) + ).file + } else { + record = null + } if (!record) return { error: `No workspace file found for "${fileRef}".` } resourceId = record.id title = record.name diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts index 322c5524e5d..9e3665e809a 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts @@ -15,8 +15,14 @@ const mocks = vi.hoisted(() => ({ ensureWorkflowAccess: vi.fn(), getDefaultWorkspaceId: vi.fn(), getWorkspaceFileByName: vi.fn(), + resolveWorkspaceFileReference: vi.fn(), findWorkspaceFileFolderIdByPath: vi.fn(), ensureWorkspaceFileFolderPath: vi.fn(), + ensureCopilotFileFolderPath: vi.fn(), + moveWorkspaceFileItems: vi.fn(), + updateWorkspaceFileFolder: vi.fn(), + deleteWorkspaceFile: vi.fn(), + renameWorkspaceFile: vi.fn(), performMoveRenameWorkspaceFile: vi.fn(), performUpdateWorkspaceFileFolder: vi.fn(), performCreateFolder: vi.fn(), @@ -44,15 +50,85 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ getWorkspaceFileByName: mocks.getWorkspaceFileByName, })) +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ + resolveWorkspaceFileReference: mocks.resolveWorkspaceFileReference, +})) + vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ findWorkspaceFileFolderIdByPath: mocks.findWorkspaceFileFolderIdByPath, - ensureWorkspaceFileFolderPath: mocks.ensureWorkspaceFileFolderPath, normalizeWorkspaceFileItemName: vi.fn((name: string) => name.trim()), })) -vi.mock('@/lib/workspace-files/orchestration', () => ({ - performMoveRenameWorkspaceFile: mocks.performMoveRenameWorkspaceFile, - performUpdateWorkspaceFileFolder: mocks.performUpdateWorkspaceFileFolder, +vi.mock('@/lib/copilot/tools/server/files/file-folder-application', () => ({ + resolveCopilotFilePrincipal: vi.fn((context, workspaceId, fileId) => ({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: context.userId, + workspaceId, + delegationId: `copilot-tool:${context.toolCallId}`, + audience: 'sim:workspace-files', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 300_000), + ...(fileId ? { resourceScope: { fileId } } : {}), + })), + ensureCopilotFileFolderPath: mocks.ensureCopilotFileFolderPath, + requireCopilotWorkspace: vi.fn((context) => context.workspaceId), +})) + +vi.mock('@/lib/workspace-files/application/move-workspace-file-items', () => ({ + moveWorkspaceFileItemsOperation: { + operation: { id: 'files.move', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.moveWorkspaceFileItems, + }, +})) + +vi.mock('@/lib/workspace-files/application/operations', () => ({ + fileOperations: { + move: { id: 'files.move', minimumRole: 'write', workspaceApiKey: 'allow' }, + rename: { id: 'files.rename', minimumRole: 'write', workspaceApiKey: 'allow' }, + delete: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + updateFolder: { + id: 'files.folders.update', + minimumRole: 'write', + workspaceApiKey: 'allow', + }, + }, +})) + +vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ + updateWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.update', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.updateWorkspaceFileFolder, + }, +})) + +vi.mock('@/lib/workspace-files/application/delete-workspace-file', () => ({ + deleteWorkspaceFileOperation: { + operation: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.deleteWorkspaceFile, + }, +})) + +vi.mock('@/lib/workspace-files/application/archive-workspace-file-items', () => ({ + archiveWorkspaceFileItemsOperation: { + operation: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.deleteWorkspaceFile, + }, +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({})) + +vi.mock('@/lib/workspace-files/application/rename-workspace-file', () => ({ + renameWorkspaceFile: { + operation: { id: 'files.rename', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.renameWorkspaceFile, + }, +})) + +vi.mock('@/lib/folders/orchestration', () => ({ + createFolder: mocks.performCreateFolder, + deleteFolder: vi.fn(), + updateFolder: mocks.performUpdateFolder, })) vi.mock('@/lib/workflows/orchestration', () => ({ @@ -87,7 +163,12 @@ vi.mock('@/app/api/knowledge/utils', () => ({ import type { ExecutionContext } from '@/lib/copilot/request/types' import { executeVfsCp, executeVfsMkdir, executeVfsMv } from './vfs-mutate' -const context = { userId: 'user-1', workspaceId: 'ws-1' } as ExecutionContext +const context = { + userId: 'user-1', + workspaceId: 'ws-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, +} as ExecutionContext describe('vfs mv/cp', () => { beforeEach(() => { @@ -100,8 +181,29 @@ describe('vfs mv/cp', () => { mocks.verifyFolderWorkspace.mockResolvedValue(true) mocks.listFolders.mockResolvedValue([]) mocks.getWorkspaceFileByName.mockResolvedValue(null) + mocks.resolveWorkspaceFileReference.mockImplementation(async ({ reference }) => { + const segments = reference.split('/').slice(1) + const folderSegments = segments.slice(0, -1) + if (folderSegments.length > 0) { + const folderId = await mocks.findWorkspaceFileFolderIdByPath('ws-1', folderSegments) + if (!folderId) return null + return mocks.getWorkspaceFileByName('ws-1', segments.at(-1), { folderId }) + } + return mocks.getWorkspaceFileByName('ws-1', segments.at(-1), { folderId: null }) + }) mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null) mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('ensured-folder') + mocks.ensureCopilotFileFolderPath.mockResolvedValue('ensured-folder') + mocks.moveWorkspaceFileItems.mockResolvedValue({ movedItems: { files: 1, folders: 0 } }) + mocks.updateWorkspaceFileFolder.mockResolvedValue({ folder: { name: 'Reports 2025' } }) + mocks.deleteWorkspaceFile.mockResolvedValue({ + id: 'file-1', + workspaceId: 'ws-1', + deleted: true, + }) + mocks.renameWorkspaceFile.mockResolvedValue({ + file: { id: 'file-1', name: 'renamed.md' }, + }) }) afterAll(() => { @@ -150,15 +252,50 @@ describe('vfs mv/cp', () => { ) expect(result.success).toBe(false) expect(result.error).toContain('aborted') - expect(mocks.performMoveRenameWorkspaceFile).not.toHaveBeenCalled() + expect(mocks.moveWorkspaceFileItems).not.toHaveBeenCalled() }) }) describe('files', () => { + it('routes a same-folder rename through the delegated file use case', async () => { + mocks.getWorkspaceFileByName.mockResolvedValue({ + id: 'file-1', + name: 'draft.md', + folderId: null, + }) + mocks.renameWorkspaceFile.mockResolvedValue({ + file: { id: 'file-1', name: 'final.md' }, + }) + + const result = await executeVfsMv( + { sources: ['files/draft.md'], destination: 'files/final.md' }, + context + ) + + expect(mocks.renameWorkspaceFile).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + kind: 'delegated', + subjectUserId: 'user-1', + workspaceId: 'ws-1', + delegationId: 'copilot-tool:tool-call-1', + resourceScope: expect.objectContaining({ fileId: 'file-1' }), + }), + input: { + fileId: 'file-1', + assertedWorkspaceId: 'ws-1', + name: 'final.md', + }, + }) + expect(mocks.moveWorkspaceFileItems).not.toHaveBeenCalled() + expect(result).toMatchObject({ + success: true, + output: { results: [{ to: 'files/final.md', id: 'file-1' }] }, + }) + }) + it('moves and renames a file in one call, auto-creating destination folders', async () => { mocks.getWorkspaceFileByName.mockResolvedValue({ id: 'file-1', name: 'draft.md' }) - mocks.performMoveRenameWorkspaceFile.mockResolvedValue({ - success: true, + mocks.renameWorkspaceFile.mockResolvedValue({ file: { id: 'file-1', name: 'final.md' }, }) @@ -170,18 +307,15 @@ describe('vfs mv/cp', () => { expect(mocks.getWorkspaceFileByName).toHaveBeenCalledWith('ws-1', 'draft.md', { folderId: null, }) - expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ - workspaceId: 'ws-1', - userId: 'user-1', - pathSegments: ['Reports', '2026'], - }) - expect(mocks.performMoveRenameWorkspaceFile).toHaveBeenCalledWith({ - workspaceId: 'ws-1', - userId: 'user-1', - fileId: 'file-1', - targetFolderId: 'ensured-folder', - newName: 'final.md', - }) + expect(mocks.ensureCopilotFileFolderPath).toHaveBeenCalledWith(context, 'ws-1', [ + 'Reports', + '2026', + ]) + expect(mocks.moveWorkspaceFileItems).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ targetFolderId: 'ensured-folder' }), + }) + ) expect(result.success).toBe(true) expect(result.output).toMatchObject({ results: [{ from: 'files/draft.md', to: 'files/Reports/2026/final.md', kind: 'file' }], @@ -191,20 +325,18 @@ describe('vfs mv/cp', () => { it('moves into an existing folder keeping the name without creating anything', async () => { mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue('folder-images') mocks.getWorkspaceFileByName.mockResolvedValue({ id: 'file-1', name: 'a.png' }) - mocks.performMoveRenameWorkspaceFile.mockResolvedValue({ - success: true, - file: { id: 'file-1', name: 'a.png' }, - }) const result = await executeVfsMv( { sources: ['files/a.png'], destination: 'files/Images' }, context ) - expect(mocks.performMoveRenameWorkspaceFile).toHaveBeenCalledWith( - expect.objectContaining({ targetFolderId: 'folder-images', newName: 'a.png' }) + expect(mocks.moveWorkspaceFileItems).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ targetFolderId: 'folder-images' }), + }) ) - expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + expect(mocks.ensureCopilotFileFolderPath).not.toHaveBeenCalled() expect(result.success).toBe(true) expect(result.output).toMatchObject({ results: [{ to: 'files/Images/a.png' }] }) }) @@ -229,8 +361,8 @@ describe('vfs mv/cp', () => { expect(result.success).toBe(false) expect(result.error).toContain('Not found') - expect(mocks.performMoveRenameWorkspaceFile).not.toHaveBeenCalled() - expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + expect(mocks.moveWorkspaceFileItems).not.toHaveBeenCalled() + expect(mocks.ensureCopilotFileFolderPath).not.toHaveBeenCalled() }) it('rejects copying workspace files — cp is workflows-only', async () => { @@ -243,30 +375,28 @@ describe('vfs mv/cp', () => { expect(result.success).toBe(false) expect(result.error).toContain('cp only duplicates workflows') - expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + expect(mocks.ensureCopilotFileFolderPath).not.toHaveBeenCalled() }) - it('moves and renames a file folder via performUpdateWorkspaceFileFolder', async () => { + it('moves and renames a file folder via the shared folder operation', async () => { mocks.findWorkspaceFileFolderIdByPath .mockResolvedValueOnce(null) // destination is not an existing folder .mockResolvedValueOnce('folder-src') // source resolves as folder - mocks.performUpdateWorkspaceFileFolder.mockResolvedValue({ - success: true, - folder: { name: 'Reports 2025' }, - }) const result = await executeVfsMv( { sources: ['files/Reports'], destination: 'files/Archive/Reports 2025' }, context ) - expect(mocks.performUpdateWorkspaceFileFolder).toHaveBeenCalledWith({ - workspaceId: 'ws-1', - folderId: 'folder-src', - userId: 'user-1', - name: 'Reports 2025', - parentId: 'ensured-folder', - }) + expect(mocks.updateWorkspaceFileFolder).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + folderId: 'folder-src', + name: 'Reports 2025', + parentId: 'ensured-folder', + }), + }) + ) expect(result.success).toBe(true) }) }) @@ -380,11 +510,10 @@ describe('vfs mv/cp', () => { it('creates a nested file folder chain', async () => { const result = await executeVfsMkdir({ paths: ['files/Reports/2026'] }, context) - expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ - workspaceId: 'ws-1', - userId: 'user-1', - pathSegments: ['Reports', '2026'], - }) + expect(mocks.ensureCopilotFileFolderPath).toHaveBeenCalledWith(context, 'ws-1', [ + 'Reports', + '2026', + ]) expect(result.success).toBe(true) expect(result.output).toMatchObject({ results: [{ from: 'files/Reports/2026', to: 'files/Reports/2026', kind: 'file_folder' }], @@ -398,6 +527,7 @@ describe('vfs mv/cp', () => { const result = await executeVfsMkdir({ paths: ['workflows/Archive'] }, context) expect(mocks.performCreateFolder).toHaveBeenCalledWith({ + resourceType: 'workflow', workspaceId: 'ws-1', userId: 'user-1', name: 'Archive', @@ -415,7 +545,7 @@ describe('vfs mv/cp', () => { expect(result.output).toMatchObject({ results: [{ from: 'tables/CRM', error: expect.stringContaining('flat namespace') }], }) - expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + expect(mocks.ensureCopilotFileFolderPath).not.toHaveBeenCalled() }) it('rejects creation inside a locked workflow folder', async () => { diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index c0fa82f81c6..d0a4779348c 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -3,12 +3,17 @@ import { createLogger } from '@sim/logger' import { assertFolderMutable, assertWorkflowMutable } from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' import { eq } from 'drizzle-orm' +import { + executeCopilotFileUseCase, + resolveCopilotWorkspaceFileReference, +} from '@/lib/copilot/application/execute-file-use-case' +import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { ensureWorkflowAccess, ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { - ensureWorkflowAccess, - ensureWorkspaceAccess, - getDefaultWorkspaceId, -} from '@/lib/copilot/tools/handlers/access' + ensureCopilotFileFolderPath, + requireCopilotWorkspace, +} from '@/lib/copilot/tools/server/files/file-folder-application' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { buildVfsFolderPathMap, @@ -16,6 +21,7 @@ import { decodeVfsPathSegments, encodeVfsPathSegments, } from '@/lib/copilot/vfs/path-utils' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { createFolder, deleteFolder, updateFolder } from '@/lib/folders/orchestration' import { @@ -25,24 +31,17 @@ import { } from '@/lib/knowledge/service' import { performDeleteTable, performRenameTable } from '@/lib/table/orchestration' import { listTables } from '@/lib/table/service' -import { - ensureWorkspaceFileFolderPath, - findWorkspaceFileFolderIdByPath, - normalizeWorkspaceFileItemName, -} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { - getWorkspaceFileByName, - resolveWorkspaceFileReference, - type WorkspaceFileRecord, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { findWorkspaceFileFolderIdByPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' import { listFolders, verifyFolderWorkspace } from '@/lib/workflows/utils' -import { - performDeleteWorkspaceFileItems, - performMoveRenameWorkspaceFile, - performUpdateWorkspaceFileFolder, -} from '@/lib/workspace-files/orchestration' +import { archiveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/archive-workspace-file-items' +import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' +import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' +import { updateWorkspaceFileFolderOperation } from '@/lib/workspace-files/application/workspace-file-folders' import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' const logger = createLogger('VfsMutateTools') @@ -157,7 +156,7 @@ export async function executeVfsMkdir( return { success: false, error: 'paths is required (an array of folder VFS paths)' } } - const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) + const workspaceId = requireCopilotWorkspace(context) await ensureWorkspaceAccess(workspaceId, context.userId, 'write') assertMutationNotAborted(context) @@ -186,11 +185,7 @@ export async function executeVfsMkdir( assertMutationNotAborted(context) let folderId: string | null if (top === 'files') { - folderId = await ensureWorkspaceFileFolderPath({ - workspaceId, - userId: context.userId, - pathSegments: segments, - }) + folderId = await ensureCopilotFileFolderPath(context, workspaceId, segments) } else { ensureWorkflowFolder ??= makeWorkflowFolderEnsurer( workspaceId, @@ -206,13 +201,25 @@ export async function executeVfsMkdir( id: folderId ?? undefined, }) } catch (error) { - outcomes.push({ from: path, kind, error: toError(error).message }) + outcomes.push({ + from: path, + kind, + error: + top === 'files' + ? messageForCopilotFileError(error, 'File folder creation failed') + : toError(error).message, + }) } } return buildResult('mkdir', outcomes) } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: context.abortSignal?.aborted + ? 'Request aborted before the mutation could be applied.' + : 'Mutation failed', + } } } @@ -231,7 +238,7 @@ async function executeVfsMutate( return { success: false, error: 'destination is required' } } - const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) + const workspaceId = requireCopilotWorkspace(context) await ensureWorkspaceAccess(workspaceId, context.userId, 'write') assertMutationNotAborted(context) @@ -267,7 +274,12 @@ async function executeVfsMutate( return await renameFlatResource(verb, category, sources, destination, context, workspaceId) } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: context.abortSignal?.aborted + ? 'Request aborted before the mutation could be applied.' + : 'Mutation failed', + } } } @@ -340,15 +352,19 @@ async function planDestination(args: { */ async function resolveFileAtExactPath( workspaceId: string, - segments: string[] + segments: string[], + context: ExecutionContext ): Promise { - const fileName = normalizeWorkspaceFileItemName(segments.at(-1) ?? '', 'File') - if (segments.length === 1) { - return getWorkspaceFileByName(workspaceId, fileName, { folderId: null }) + try { + return await resolveCopilotWorkspaceFileReference(context, fileOperations.move, { + workspaceId, + reference: `files/${encodeVfsPathSegments(segments)}`, + }) + } catch (error) { + const classified = asOrchestrationError(error) + if (classified?.code !== 'not_found') throw error + return null } - const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments.slice(0, -1)) - if (!folderId) return null - return getWorkspaceFileByName(workspaceId, fileName, { folderId }) } async function mutateWorkspaceFiles( @@ -368,12 +384,7 @@ async function mutateWorkspaceFiles( destination, sourceCount: sources.length, lookupFolder: (segments) => findWorkspaceFileFolderIdByPath(workspaceId, segments), - ensureFolderPath: (segments) => - ensureWorkspaceFileFolderPath({ - workspaceId, - userId: context.userId, - pathSegments: segments, - }), + ensureFolderPath: (segments) => ensureCopilotFileFolderPath(context, workspaceId, segments), }) if ('error' in dest) return { success: false, error: dest.error } @@ -390,7 +401,7 @@ async function mutateWorkspaceFiles( refs.push({ source, error: 'Source must name a file or folder under files/' }) continue } - const file = await resolveFileAtExactPath(workspaceId, segments) + const file = await resolveFileAtExactPath(workspaceId, segments, context) if (file) { refs.push({ source, file }) continue @@ -411,23 +422,67 @@ async function mutateWorkspaceFiles( assertMutationNotAborted(context) const targetName = dest.dirMode ? ref.file.name : (dest.leafName as string) const targetFolderId = await dest.ensureFolderId() - const result = await performMoveRenameWorkspaceFile({ - workspaceId, - userId: context.userId, - fileId: ref.file.id, - targetFolderId, - newName: targetName, - }) - outcomes.push( - result.success && result.file - ? { - from: ref.source, - to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.file.name])}`, - kind: 'file', - id: ref.file.id, - } - : { from: ref.source, kind: 'file', error: result.error || 'Failed to move file' } - ) + if (targetFolderId === ref.file.folderId) { + try { + const result = await executeCopilotFileUseCase( + context, + renameWorkspaceFile, + { + fileId: ref.file.id, + assertedWorkspaceId: workspaceId, + name: targetName, + }, + { fileId: ref.file.id } + ) + outcomes.push({ + from: ref.source, + to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.file.name])}`, + kind: 'file', + id: ref.file.id, + }) + } catch (error) { + outcomes.push({ + from: ref.source, + kind: 'file', + error: messageForCopilotFileError(error), + }) + } + continue + } + try { + await executeCopilotFileUseCase( + context, + moveWorkspaceFileItemsOperation, + { workspaceId, fileIds: [ref.file.id], targetFolderId }, + { fileId: ref.file.id } + ) + let finalName = ref.file.name + if (targetName !== ref.file.name) { + const renamed = await executeCopilotFileUseCase( + context, + renameWorkspaceFile, + { + fileId: ref.file.id, + assertedWorkspaceId: workspaceId, + name: targetName, + }, + { fileId: ref.file.id } + ) + finalName = renamed.file.name + } + outcomes.push({ + from: ref.source, + to: `files/${encodeVfsPathSegments([...dest.folderSegments, finalName])}`, + kind: 'file', + id: ref.file.id, + }) + } catch (error) { + outcomes.push({ + from: ref.source, + kind: 'file', + error: messageForCopilotFileError(error, 'Failed to move file'), + }) + } continue } @@ -441,23 +496,26 @@ async function mutateWorkspaceFiles( }) continue } - const result = await performUpdateWorkspaceFileFolder({ - workspaceId, - folderId: ref.folderId, - userId: context.userId, - name: dest.dirMode ? undefined : dest.leafName, - parentId: targetFolderId, - }) - outcomes.push( - result.success && result.folder - ? { - from: ref.source, - to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.folder.name])}`, - kind: 'file_folder', - id: ref.folderId, - } - : { from: ref.source, kind: 'file_folder', error: result.error || 'Failed to move folder' } - ) + try { + const result = await executeCopilotFileUseCase(context, updateWorkspaceFileFolderOperation, { + workspaceId, + folderId: ref.folderId, + name: dest.dirMode ? undefined : dest.leafName, + parentId: targetFolderId, + }) + outcomes.push({ + from: ref.source, + to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.folder.name])}`, + kind: 'file_folder', + id: ref.folderId, + }) + } catch (error) { + outcomes.push({ + from: ref.source, + kind: 'file_folder', + error: messageForCopilotFileError(error, 'Failed to move folder'), + }) + } } return buildResult(verb, outcomes) @@ -818,7 +876,7 @@ export async function executeVfsRm( return { success: false, error: 'paths is required (an array of VFS paths to delete)' } } - const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) + const workspaceId = requireCopilotWorkspace(context) await ensureWorkspaceAccess(workspaceId, context.userId, 'write') assertMutationNotAborted(context) @@ -839,13 +897,25 @@ export async function executeVfsRm( await removeOne(classified.category, path, context, workspaceId, getWorkflowIndex) ) } catch (error) { - outcomes.push({ from: path, kind: defaultKindFor(path), error: toError(error).message }) + outcomes.push({ + from: path, + kind: defaultKindFor(path), + error: + classified.category === 'files' + ? messageForCopilotFileError(error, 'File deletion failed') + : toError(error).message, + }) } } return buildResult('rm', outcomes) } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: context.abortSignal?.aborted + ? 'Request aborted before the mutation could be applied.' + : 'Delete failed', + } } } @@ -893,16 +963,23 @@ async function removeWorkspaceFilePath( context: ExecutionContext, workspaceId: string ): Promise { - const file = await resolveWorkspaceFileReference(workspaceId, path) - if (file) { - const result = await performDeleteWorkspaceFileItems({ + let file: WorkspaceFileRecord | undefined + try { + file = await resolveCopilotWorkspaceFileReference(context, fileOperations.delete, { workspaceId, - userId: context.userId, - fileIds: [file.id], + reference: path, }) - if (!result.success) { - return { from: path, kind: 'file', id: file.id, error: result.error || 'Failed to delete' } - } + } catch (error) { + const classified = asOrchestrationError(error) + if (classified?.code !== 'not_found') throw error + } + if (file) { + await executeCopilotFileUseCase( + context, + deleteWorkspaceFileOperation, + { fileId: file.id, assertedWorkspaceId: workspaceId }, + { fileId: file.id } + ) logger.info('Deleted workspace file via rm', { fileId: file.id, workspaceId }) return { from: path, kind: 'file', id: file.id } } @@ -914,21 +991,21 @@ async function removeWorkspaceFilePath( const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments) if (!folderId) return { from: path, kind: 'file', error: `Not found: ${path}` } - const result = await performDeleteWorkspaceFileItems({ - workspaceId, - userId: context.userId, - folderIds: [folderId], - }) - if (!result.success) { + try { + const result = await executeCopilotFileUseCase(context, archiveWorkspaceFileItemsOperation, { + workspaceId, + folderIds: [folderId], + }) + logger.info('Deleted file folder via rm', { folderId, workspaceId }) + return { from: path, kind: 'file_folder', id: folderId } + } catch (error) { return { from: path, kind: 'file_folder', id: folderId, - error: result.error || 'Failed to delete', + error: messageForCopilotFileError(error, 'Failed to delete'), } } - logger.info('Deleted file folder via rm', { folderId, workspaceId }) - return { from: path, kind: 'file_folder', id: folderId } } interface WorkflowRemoveIndex { diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index 8593b714946..3aade59a519 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -1,9 +1,9 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { TOOL_RESULT_MAX_INLINE_CHARS } from '@/lib/copilot/constants' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import type { SecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { getOrMaterializeVFS } from '@/lib/copilot/vfs' import type { GrepCountEntry, GrepMatch } from '@/lib/copilot/vfs/operations' import { WorkspaceFileGrepError } from '@/lib/copilot/vfs/operations' @@ -27,14 +27,19 @@ const logger = createLogger('VfsTools') * viewer (unrevealed previews, kill-switched types). Visibility is memoized per * (userId, workspaceId), so repeated tool calls in one turn resolve once. */ -async function getGatedVFS( - workspaceId: string, - userId: string, - secretMountPolicy?: SecretMountPolicy -) { - const vis = await getBlockVisibilityForCopilot(userId, workspaceId) +async function getGatedVFS(context: ExecutionContext) { + const workspaceId = context.workspaceId + if (!workspaceId) throw new Error('No workspace context available') + const vis = await getBlockVisibilityForCopilot(context.userId, workspaceId) + const filePrincipal = + context.copilotToolExecution && context.toolCallId + ? resolveCopilotFilePrincipal(context) + : undefined return withBlockVisibility(vis, () => - getOrMaterializeVFS(workspaceId, userId, { secretMountPolicy }) + getOrMaterializeVFS(workspaceId, context.userId, { + secretMountPolicy: context.secretMountPolicy, + filePrincipal, + }) ) } @@ -170,7 +175,7 @@ export async function executeVfsGrep( result = envelope.value provenanceFile = envelope.file } else { - const vfs = await getGatedVFS(workspaceId, context.userId, context.secretMountPolicy) + const vfs = await getGatedVFS(context) if (isWorkspaceFileGrepPath(rawPath)) { const envelope = await vfs.grepFileWithProvenance(rawPath, pattern, grepOptions) result = envelope.value @@ -238,7 +243,7 @@ export async function executeVfsGlob( } try { - const vfs = await getGatedVFS(workspaceId, context.userId, context.secretMountPolicy) + const vfs = await getGatedVFS(context) let files = vfs.glob(pattern) if (context.chatId && (pattern === 'uploads/*' || pattern.startsWith('uploads/'))) { @@ -354,7 +359,7 @@ export async function executeVfsRead( } } - const vfs = await getGatedVFS(workspaceId, context.userId, context.secretMountPolicy) + const vfs = await getGatedVFS(context) // Plain canonical file leaves are metadata resources. Dynamic file content // and inspection paths use explicit suffixes like /content, /style, diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts index 41e1de535c3..fb250666e14 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts @@ -1,9 +1,9 @@ import { toError } from '@sim/utils/errors' import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' +import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { formatNormalizedWorkflowForCopilot } from '@/lib/copilot/tools/shared/workflow-utils' import { mcpService } from '@/lib/mcp/service' -import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' import { getEffectiveBlockOutputPaths } from '@/lib/workflows/blocks/block-outputs' import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator' import { getBlockReferenceTags } from '@/lib/workflows/blocks/block-reference-tags' @@ -16,6 +16,7 @@ import { import { resolveTriggerRunOptions, toPublicRunOption } from '@/lib/workflows/triggers/run-options' import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { getWorkflowById } from '@/lib/workflows/utils' +import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' import { listUserWorkspaces } from '@/lib/workspaces/utils' import { getBlock } from '@/blocks/registry' import { normalizeName } from '@/executor/constants' @@ -191,7 +192,10 @@ export async function executeGetWorkflowData( if (!workspaceId) { return { success: false, error: 'workspaceId is required' } } - const files = await listWorkspaceFiles(workspaceId) + const { files } = await executeCopilotFileUseCase(context, listAllWorkspaceFiles, { + workspaceId, + scope: 'active', + }) const fileResults = files.map((file) => ({ id: String(file.id || ''), name: String(file.name || ''), diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts new file mode 100644 index 00000000000..3b205556cce --- /dev/null +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts @@ -0,0 +1,45 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const routeExecution = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/copilot/tools/server/router', () => ({ routeExecution })) + +import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' + +describe('server tool adapter authority boundary', () => { + beforeEach(() => { + vi.clearAllMocks() + routeExecution.mockResolvedValue({ success: true }) + }) + + it('overwrites model-supplied workspace scope and forwards trusted delegation context', async () => { + const handler = createServerToolHandler('workspace_file') + + await handler( + { workspaceId: 'attacker-workspace', operation: 'rename' }, + { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, + } + ) + + expect(routeExecution).toHaveBeenCalledWith( + 'workspace_file', + expect.objectContaining({ workspaceId: 'workspace-1', operation: 'rename' }), + expect.objectContaining({ + userId: 'user-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, + }) + ) + }) +}) diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts index 359ed2a4023..552ed7a7b32 100644 --- a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts @@ -11,13 +11,15 @@ export function createServerToolHandler(toolId: string): ToolHandler { const enrichedParams = { ...params } if (!enrichedParams.workflowId && context.workflowId) enrichedParams.workflowId = context.workflowId - if (!enrichedParams.workspaceId && context.workspaceId) - enrichedParams.workspaceId = context.workspaceId + if (context.workspaceId) enrichedParams.workspaceId = context.workspaceId try { const result = await routeExecution(toolId, enrichedParams, { userId: context.userId, workspaceId: context.workspaceId, + executionId: context.executionId, + toolCallId: context.toolCallId, + copilotToolExecution: context.copilotToolExecution, billingAttribution: context.billingAttribution, userPermission: context.userPermission ?? undefined, chatId: context.chatId, diff --git a/apps/sim/lib/copilot/tools/server/base-tool.ts b/apps/sim/lib/copilot/tools/server/base-tool.ts index b1db4a7482d..2af97ba7490 100644 --- a/apps/sim/lib/copilot/tools/server/base-tool.ts +++ b/apps/sim/lib/copilot/tools/server/base-tool.ts @@ -5,6 +5,11 @@ import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secr export interface ServerToolContext { userId: string workspaceId?: string + executionId?: string + /** Stable, server-issued identity of the tool call currently executing. */ + toolCallId?: string + /** True only for contexts built by the authenticated Copilot execution pipeline. */ + copilotToolExecution?: boolean billingAttribution?: BillingAttributionSnapshot userPermission?: string chatId?: string diff --git a/apps/sim/lib/copilot/tools/server/files/create-file.ts b/apps/sim/lib/copilot/tools/server/files/create-file.ts index cd950eb3406..430545f8b0a 100644 --- a/apps/sim/lib/copilot/tools/server/files/create-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/create-file.ts @@ -1,12 +1,16 @@ import { createLogger } from '@sim/logger' -import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' +import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' +import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' -import { inferContentType } from './workspace-file' +import { inferContentType } from '@/lib/copilot/tools/server/files/workspace-file' +import { + createWorkspaceFileByPath, + updateWorkspaceFileContentByPath, +} from '@/lib/workspace-files/application/write-workspace-file-by-path' const logger = createLogger('CreateFileServerTool') const CREATE_FILE_TOOL_ID = 'create_file' @@ -39,8 +43,6 @@ export const createFileServerTool: BaseServerTool { return ids } -async function stageReferencedImages(source: string, workspaceId: string): Promise { +async function stageReferencedImages( + source: string, + workspaceId: string, + principal: Principal +): Promise { const ids = collectReferencedFileIds(source) if (ids.size > MAX_STAGED_INPUTS) { throw new Error( @@ -150,9 +153,13 @@ async function stageReferencedImages(source: string, workspaceId: string): Promi const files: SandboxFile[] = [] let totalBytes = 0 for (const fileId of ids) { - let record: Awaited> + let record: Awaited>['file'] try { - record = await getWorkspaceFile(workspaceId, fileId) + const metadata = await readWorkspaceFileMetadata.execute({ + principal, + input: { fileId, assertedWorkspaceId: workspaceId }, + }) + record = metadata.file } catch (err) { logger.warn('Failed to resolve referenced image for doc compile', { workspaceId, @@ -177,7 +184,15 @@ async function stageReferencedImages(source: string, workspaceId: string): Promi } let buffer: Buffer try { - buffer = await fetchWorkspaceFileBuffer(record) + const content = await readWorkspaceFileContent.execute({ + principal, + input: { + fileId: record.id, + assertedWorkspaceId: workspaceId, + maxBytes: MAX_STAGED_FILE_BYTES, + }, + }) + buffer = content.content } catch (err) { logger.warn('Failed to stage referenced image for doc compile', { workspaceId, @@ -241,6 +256,7 @@ interface CompileArgs { source: string fileName: string workspaceId: string + filePrincipal: Principal } /** @@ -250,10 +266,10 @@ interface CompileArgs { * Internal — callers use compileDoc (load-or-build + store). */ async function compileDocViaE2BPython( - { source, workspaceId }: CompileArgs, + { source, workspaceId, filePrincipal }: CompileArgs, fmt: E2BDocFormat ): Promise { - const sandboxFiles = await stageReferencedImages(source, workspaceId) + const sandboxFiles = await stageReferencedImages(source, workspaceId, filePrincipal) const outputSandboxPath = `/home/user/output.${fmt.ext}` // openpyxl writes formula strings but no cached values, so a web viewer (SheetJS) @@ -331,10 +347,10 @@ fs.writeFileSync('/home/user/output.docx', __buf); * engines. Throws DocCompileUserError on a script error. */ async function compileDocViaE2BNode( - { source, fileName, workspaceId }: CompileArgs, + { source, fileName, workspaceId, filePrincipal }: CompileArgs, ext: 'pptx' | 'docx' ): Promise { - const sandboxFiles = await stageReferencedImages(source, workspaceId) + const sandboxFiles = await stageReferencedImages(source, workspaceId, filePrincipal) const outputSandboxPath = `/home/user/output.${ext}` const preamble = ext === 'pptx' ? PPTX_NODE_PREAMBLE : DOCX_NODE_PREAMBLE const finalize = ext === 'pptx' ? PPTX_NODE_FINALIZE : DOCX_NODE_FINALIZE @@ -394,7 +410,7 @@ ${finalize} export async function compileDoc( args: CompileArgs ): Promise<{ buffer: Buffer; contentType: string }> { - const { source, fileName, workspaceId } = args + const { source, fileName, workspaceId, filePrincipal } = args const fmt = await getE2BDocFormat(fileName) if (!fmt) throw new Error(`Unsupported document format: ${fileName}`) @@ -403,8 +419,11 @@ export async function compileDoc( const buffer = fmt.engine === 'node' - ? await compileDocViaE2BNode({ source, fileName, workspaceId }, fmt.ext as 'pptx' | 'docx') - : await compileDocViaE2BPython({ source, fileName, workspaceId }, fmt) + ? await compileDocViaE2BNode( + { source, fileName, workspaceId, filePrincipal }, + fmt.ext as 'pptx' | 'docx' + ) + : await compileDocViaE2BPython({ source, fileName, workspaceId, filePrincipal }, fmt) await storeCompiledDoc(workspaceId, source, fmt.ext, fmt.contentType, buffer) return { buffer, contentType: fmt.contentType } } diff --git a/apps/sim/lib/copilot/tools/server/files/doc-recalc.ts b/apps/sim/lib/copilot/tools/server/files/doc-recalc.ts index c85b9249110..08dc72e16d4 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-recalc.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-recalc.ts @@ -1,3 +1,4 @@ +import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { CodeLanguage } from '@/lib/execution/languages' import { executeInSandbox } from '@/lib/execution/remote-sandbox' @@ -101,12 +102,14 @@ export async function runE2BCompiledCheck(args: { fileName: string workspaceId: string ext: string + principal: Principal }): Promise { try { const compiled = await compileDoc({ source: args.source, fileName: args.fileName, workspaceId: args.workspaceId, + filePrincipal: args.principal, }) if (args.ext === 'xlsx') { const recalc = await recalcXlsx({ binary: compiled.buffer, workspaceId: args.workspaceId }) diff --git a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts index d5ec0650171..ff1f7b10f26 100644 --- a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts @@ -1,20 +1,24 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { z } from 'zod' +import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' +import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' import { DownloadToWorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1' -import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { getExtensionFromMimeType, getFileExtension, getMimeTypeFromExtension, } from '@/lib/uploads/utils/file-utils' +import { + createWorkspaceFileByPath, + updateWorkspaceFileContentByPath, +} from '@/lib/workspace-files/application/write-workspace-file-by-path' const logger = createLogger('DownloadToWorkspaceFileTool') @@ -152,8 +156,6 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< if (!workspaceId) { return { success: false, message: 'Workspace ID is required' } } - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') - try { assertServerToolNotAborted(context) @@ -189,17 +191,19 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< } assertServerToolNotAborted(context) - const written = await writeWorkspaceFileByPath({ + const mode = outputFile?.mode ?? 'create' + const writeInput = { workspaceId, - userId: context.userId, - target: { - path: outputPath, - mode: outputFile?.mode ?? 'create', - mimeType: outputFile?.mimeType, - }, - buffer: fileBuffer, - inferredMimeType: outputFile?.mimeType ?? mimeType, - }) + path: outputPath, + mode, + content: fileBuffer.toString('base64'), + encoding: 'base64' as const, + contentType: outputFile?.mimeType ?? mimeType, + } + const written = + mode === 'overwrite' + ? await executeCopilotFileUseCase(context, updateWorkspaceFileContentByPath, writeInput) + : await executeCopilotFileUseCase(context, createWorkspaceFileByPath, writeInput) logger.info('Downloaded remote file to workspace', { sourceUrl: params.url, @@ -224,7 +228,10 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< url: params.url, error: msg, }) - return { success: false, message: `Failed to download file: ${msg}` } + return { + success: false, + message: `Failed to download file: ${messageForCopilotFileError(error, 'Unable to write downloaded file')}`, + } } }, } diff --git a/apps/sim/lib/copilot/tools/server/files/edit-content.ts b/apps/sim/lib/copilot/tools/server/files/edit-content.ts index c09a39beb9d..0f453ebd678 100644 --- a/apps/sim/lib/copilot/tools/server/files/edit-content.ts +++ b/apps/sim/lib/copilot/tools/server/files/edit-content.ts @@ -1,12 +1,17 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' +import { + messageForCopilotFileError, + resolveCopilotFilePrincipal, +} from '@/lib/copilot/auth/file-delegation' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' -import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { updateWorkspaceFileContent } from '@/lib/workspace-files/application/update-workspace-file-content' import { getE2BDocFormat } from './doc-compile' import { buildEmbeddedImageRefWarning } from './embedded-image-refs' import { consumeLatestFileIntent } from './file-intent-store' @@ -219,10 +224,12 @@ export const editContentServerTool: BaseServerTool { + let parentId: string | null = null + for (const [index, name] of pathSegments.entries()) { + const existing = await findWorkspaceFileFolderIdByPath( + workspaceId, + pathSegments.slice(0, index + 1) + ) + if (existing) { + parentId = existing + continue + } + const result: Awaited> = + await executeCopilotFileUseCase(context, createWorkspaceFileFolderOperation, { + workspaceId, + name, + parentId, + }) + parentId = result.folder.id + } + return parentId +} diff --git a/apps/sim/lib/copilot/tools/server/files/file-folders.ts b/apps/sim/lib/copilot/tools/server/files/file-folders.ts index 22c26d4b7b3..c9357a6d796 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-folders.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-folders.ts @@ -1,25 +1,32 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' +import { + executeCopilotFileUseCase, + resolveCopilotWorkspaceFileReference, +} from '@/lib/copilot/application/execute-file-use-case' +import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' +import { + ensureCopilotFileFolderPath, + requireCopilotWorkspace, +} from '@/lib/copilot/tools/server/files/file-folder-application' import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { - ensureWorkspaceFileFolderPath, findWorkspaceFileFolderIdByPath, getWorkspaceFileFolder, - listWorkspaceFileFolders, type WorkspaceFileFolderRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' +import { fileOperations } from '@/lib/workspace-files/application/operations' import { - performCreateWorkspaceFileFolder, - performMoveWorkspaceFileItems, - performUpdateWorkspaceFileFolder, -} from '@/lib/workspace-files/orchestration' + createWorkspaceFileFolderOperation, + listWorkspaceFileFoldersOperation, + updateWorkspaceFileFolderOperation, +} from '@/lib/workspace-files/application/workspace-file-folders' const logger = createLogger('FileFolderServerTools') @@ -134,7 +141,8 @@ async function resolveOptionalFolderId( async function resolveFileIdsFromPaths( workspaceId: string, - paths: string[] + paths: string[], + context: ServerToolContext ): Promise<{ fileIds: string[] failed: string[] @@ -142,33 +150,34 @@ async function resolveFileIdsFromPaths( const fileIds: string[] = [] const failed: string[] = [] for (const path of paths) { - const file = await resolveWorkspaceFileReference(workspaceId, path) - if (!file) { + try { + const file = await resolveCopilotWorkspaceFileReference(context, fileOperations.move, { + workspaceId, + reference: path, + }) + fileIds.push(file.id) + } catch (error) { + const classified = asOrchestrationError(error) + if (classified?.code !== 'not_found') throw error failed.push(path) - continue } - fileIds.push(file.id) } return { fileIds, failed } } async function resolveWorkspaceId( params: WorkspaceScopedArgs, - context: ServerToolContext | undefined, - permission: 'read' | 'write' + context: ServerToolContext | undefined ): Promise { if (!context?.userId) { throw new Error('Authentication required') } const payload = nested(params) - const workspaceId = - stringValue(params.workspaceId) || stringValue(payload?.workspaceId) || context.workspaceId - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } + const assertedWorkspaceId = + stringValue(params.workspaceId) || stringValue(payload?.workspaceId) || undefined + const workspaceId = requireCopilotWorkspace(context, assertedWorkspaceId) - await ensureWorkspaceAccess(workspaceId, context.userId, permission) return workspaceId } @@ -183,10 +192,13 @@ export const listFileFoldersServerTool: BaseServerTool { try { - const workspaceId = await resolveWorkspaceId(params, context, 'read') + const workspaceId = await resolveWorkspaceId(params, context) if (typeof workspaceId !== 'string') return workspaceId - const folders = await listWorkspaceFileFolders(workspaceId) + const result = await executeCopilotFileUseCase(context, listWorkspaceFileFoldersOperation, { + workspaceId, + }) + const folders = result.folders return { success: true, message: @@ -194,7 +206,10 @@ export const listFileFoldersServerTool: BaseServerTool { try { - const workspaceId = await resolveWorkspaceId(params, context, 'write') + const workspaceId = await resolveWorkspaceId(params, context) if (typeof workspaceId !== 'string') return workspaceId if (!context?.userId) throw new Error('Authentication required') @@ -226,23 +241,19 @@ export const createFileFolderServerTool: BaseServerTool 1) { - parentId = await ensureWorkspaceFileFolderPath({ + parentId = await ensureCopilotFileFolderPath( + context, workspaceId, - userId: context.userId, - pathSegments: pathSegments.slice(0, -1), - }) + pathSegments.slice(0, -1) + ) } assertServerToolNotAborted(context) - const result = await performCreateWorkspaceFileFolder({ + const result = await executeCopilotFileUseCase(context, createWorkspaceFileFolderOperation, { workspaceId, - userId: context.userId, name, parentId, }) - if (!result.success || !result.folder) { - return { success: false, message: result.error || 'Failed to create file folder' } - } const { folder } = result logger.info('File folder created via create_file_folder', { @@ -258,7 +269,10 @@ export const createFileFolderServerTool: BaseServerTool { try { - const workspaceId = await resolveWorkspaceId(params, context, 'write') + const workspaceId = await resolveWorkspaceId(params, context) if (typeof workspaceId !== 'string') return workspaceId if (!context?.userId) throw new Error('Authentication required') @@ -289,15 +303,11 @@ export const renameFileFolderServerTool: BaseServerTool { try { - const workspaceId = await resolveWorkspaceId(params, context, 'write') + const workspaceId = await resolveWorkspaceId(params, context) if (typeof workspaceId !== 'string') return workspaceId if (!context?.userId) throw new Error('Authentication required') @@ -347,15 +360,11 @@ export const moveFileFolderServerTool: BaseServerTool name: 'move_file', async execute(params: MoveFileArgs, context?: ServerToolContext): Promise { try { - const workspaceId = await resolveWorkspaceId(params, context, 'write') + const workspaceId = await resolveWorkspaceId(params, context) if (typeof workspaceId !== 'string') return workspaceId if (!context?.userId) throw new Error('Authentication required') const payload = nested(params) const paths = stringListFromValues(params.paths, payload?.paths, params.path, payload?.path) const resolvedByPath = - paths.length > 0 ? await resolveFileIdsFromPaths(workspaceId, paths) : undefined + paths.length > 0 ? await resolveFileIdsFromPaths(workspaceId, paths, context) : undefined if (resolvedByPath?.failed.length) { return { success: false, @@ -412,15 +424,11 @@ export const moveFileServerTool: BaseServerTool null assertServerToolNotAborted(context) - const result = await performMoveWorkspaceFileItems({ + const result = await executeCopilotFileUseCase(context, moveWorkspaceFileItemsOperation, { workspaceId, - userId: context.userId, fileIds, targetFolderId: folderId, }) - if (!result.success || !result.movedItems) { - return { success: false, message: result.error || 'Failed to move files' } - } logger.info('Files moved via move_file', { workspaceId, @@ -438,7 +446,7 @@ export const moveFileServerTool: BaseServerTool data: result.movedItems, } } catch (error) { - return { success: false, message: toError(error).message } + return { success: false, message: messageForCopilotFileError(error, 'Failed to move files') } } }, } diff --git a/apps/sim/lib/copilot/tools/server/files/file-preview.ts b/apps/sim/lib/copilot/tools/server/files/file-preview.ts index 11b09b61c3a..d99f219021b 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-preview.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-preview.ts @@ -1,11 +1,11 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { - fetchWorkspaceFileBuffer, - getWorkspaceFile, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' +import type { ExecutionContext } from '@/lib/copilot/request/types' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' const logger = createLogger('CopilotFilePreview') +const MAX_PREVIEW_SOURCE_BYTES = 5 * 1024 * 1024 type FilePreviewEdit = { strategy?: string @@ -147,13 +147,21 @@ export interface WorkspaceFilePreviewBase { } export async function loadWorkspaceFileTextForPreview( + context: ExecutionContext, workspaceId: string, fileId: string ): Promise { try { - const record = await getWorkspaceFile(workspaceId, fileId) - if (!record) return undefined - const buffer = await fetchWorkspaceFileBuffer(record) + const { content: buffer } = await executeCopilotFileUseCase( + context, + readWorkspaceFileContent, + { + fileId, + assertedWorkspaceId: workspaceId, + maxBytes: MAX_PREVIEW_SOURCE_BYTES, + }, + { fileId } + ) return { text: buffer.toString('utf-8'), } diff --git a/apps/sim/lib/copilot/tools/server/files/rename-file.ts b/apps/sim/lib/copilot/tools/server/files/rename-file.ts index a6f9e9f63c8..807e42e1a39 100644 --- a/apps/sim/lib/copilot/tools/server/files/rename-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/rename-file.ts @@ -1,15 +1,18 @@ import { createLogger } from '@sim/logger' -import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' +import { + executeCopilotFileUseCase, + resolveCopilotWorkspaceFileReference, +} from '@/lib/copilot/application/execute-file-use-case' +import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { - getWorkspaceFile, - resolveWorkspaceFileReference, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { performRenameWorkspaceFile } from '@/lib/workspace-files/orchestration' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' +import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' import { validateFlatWorkspaceFileName } from './workspace-file' const logger = createLogger('RenameFileServerTool') @@ -45,8 +48,6 @@ export const renameFileServerTool: BaseServerTool if (!workspaceId) { return { success: false, message: 'Workspace ID is required' } } - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') - const nested = params.args const path = params.path || (nested?.path as string) || '' const legacyFileId = params.fileId || (nested?.fileId as string) || '' @@ -72,86 +67,80 @@ export const shareFileServerTool: BaseServerTool const targetRef = path || legacyFileId if (!targetRef) return { success: false, message: 'path is required' } - const existingFile = path - ? await resolveWorkspaceFileReference(workspaceId, path) - : await getWorkspaceFile(workspaceId, legacyFileId) - if (!existingFile) { + let existingFile + try { + existingFile = path + ? await resolveCopilotWorkspaceFileReference(context, fileOperations.updateShare, { + workspaceId, + reference: path, + }) + : ( + await executeCopilotFileUseCase( + context, + readWorkspaceFileMetadata, + { fileId: legacyFileId, assertedWorkspaceId: workspaceId }, + { fileId: legacyFileId } + ) + ).file + } catch (error) { + const classified = asOrchestrationError(error) + if (classified?.code !== 'not_found') throw error return { success: false, message: `File not found: ${targetRef}` } } - const fileId = existingFile.id - const isActive = action !== 'unshare' - const existingShare = await getShareForResource('file', fileId) - - // Unsharing a file that was never shared (or is already disabled) is a no-op: - // never insert an inactive row, emit a FILE_SHARE_DISABLED audit, or return a - // link claiming a share was revoked when none existed. - if (!isActive && !existingShare?.isActive) { - return { - success: true, - message: `"${existingFile.name}" isn't shared — nothing to unshare.`, - } - } - - // Enabling a share is gated by the org's access-control policy (both the - // master on/off and the per-auth-type allow-list); disabling is always - // allowed so users can still un-share after the policy is turned on. - if (isActive) { - // Validate the auth type that will ACTUALLY be persisted. upsertFileShare - // falls back to the existing share's authType when none is passed, so a bare - // re-enable must be checked against that stored mode — not 'public' — or a - // now-disallowed password/email/sso share could be silently reactivated. - const effectiveAuthType = authType ?? existingShare?.authType ?? 'public' - try { - await validatePublicFileSharing(context.userId, workspaceId, effectiveAuthType) - } catch (error) { - if (error instanceof PublicFileSharingNotAllowedError) { - return { success: false, message: error.message } - } - throw error - } + if (!existingFile) { + return { success: false, message: `File not found: ${targetRef}` } } - assertServerToolNotAborted(context) - - let share + const isActive = action !== 'unshare' try { - share = await upsertFileShare({ + const result = await executeCopilotFileUseCase( + context, + updateWorkspaceFileShare, + { + fileId: existingFile.id, + assertedWorkspaceId: workspaceId, + isActive, + authType, + password, + allowedEmails, + noOpIfInactive: !isActive, + }, + { fileId: existingFile.id } + ) + const share = result.share + logger.info(`${isActive ? 'Enabled' : 'Disabled'} share for file via share_file`, { + fileId: existingFile.id, workspaceId, - fileId, + authType: share.authType, userId: context.userId, - isActive, - authType, - password, - allowedEmails, }) - } catch (error) { - if (error instanceof ShareValidationError) { - return { success: false, message: error.message } - } - throw error - } - logger.info(`${isActive ? 'Enabled' : 'Disabled'} share for file via share_file`, { - fileId, - workspaceId, - authType: share.authType, - userId: context.userId, - }) + if (!isActive) { + return { + success: true, + message: `Stopped sharing "${existingFile.name}". The previous link no longer works.`, + data: { + url: share.url, + token: share.token, + authType: share.authType, + hasPassword: share.hasPassword, + isActive: share.isActive, + }, + } + } - recordAudit({ - workspaceId, - actorId: context.userId, - action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: existingFile.name, - description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${existingFile.name}"`, - }) + const authNote = + share.authType === 'password' + ? ' (password-protected — share the password separately)' + : share.authType === 'email' + ? ' (restricted to allowed emails via one-time code)' + : share.authType === 'sso' + ? ' (restricted to allowed emails via SSO)' + : '' - if (!isActive) { return { success: true, - message: `Stopped sharing "${existingFile.name}". The previous link no longer works.`, + message: `Shared "${existingFile.name}"${authNote}: ${share.url}`, data: { url: share.url, token: share.token, @@ -160,27 +149,17 @@ export const shareFileServerTool: BaseServerTool isActive: share.isActive, }, } - } - - const authNote = - share.authType === 'password' - ? ' (password-protected — share the password separately)' - : share.authType === 'email' - ? ' (restricted to allowed emails via one-time code)' - : share.authType === 'sso' - ? ' (restricted to allowed emails via SSO)' - : '' - - return { - success: true, - message: `Shared "${existingFile.name}"${authNote}: ${share.url}`, - data: { - url: share.url, - token: share.token, - authType: share.authType, - hasPassword: share.hasPassword, - isActive: share.isActive, - }, + } catch (error) { + if (error instanceof WorkspaceFileShareNoopError) { + return { + success: true, + message: `"${existingFile.name}" isn't shared — nothing to unshare.`, + } + } + return { + success: false, + message: messageForCopilotFileError(error, 'Unable to update file sharing'), + } } }, } diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index 2215e414f39..9f9f32a5a1f 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -1,29 +1,34 @@ +import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { truncate } from '@sim/utils/string' +import { + executeCopilotFileUseCase, + resolveCopilotWorkspaceFileReference, +} from '@/lib/copilot/application/execute-file-use-case' +import { + messageForCopilotFileError, + resolveCopilotFilePrincipal, +} from '@/lib/copilot/auth/file-delegation' import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1' -import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { runSandboxTask } from '@/lib/execution/sandbox/run-task' -import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { - fetchWorkspaceFileBuffer as downloadWsFile, - getWorkspaceFile, - getWorkspaceFileByName, - resolveWorkspaceFileReference, - uploadWorkspaceFile, - type WorkspaceFileRecord, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { - performDeleteWorkspaceFileItems, - performRenameWorkspaceFile, -} from '@/lib/workspace-files/orchestration' + admitCreateWorkspaceFile, + createWorkspaceFile, +} from '@/lib/workspace-files/application/create-workspace-file' +import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' +import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' import type { SandboxTaskId } from '@/sandbox-tasks/registry' import { compileDoc, @@ -33,6 +38,7 @@ import { PPTXGENJS_SOURCE_MIME, } from './doc-compile' import { buildEmbeddedImageRefWarning } from './embedded-image-refs' +import { ensureCopilotFileFolderPath } from './file-folder-application' import { storeFileIntent } from './file-intent-store' const logger = createLogger('WorkspaceFileServerTool') @@ -40,7 +46,7 @@ const logger = createLogger('WorkspaceFileServerTool') const PPTX_MIME = 'application/vnd.openxmlformats-officedocument.presentationml.presentation' const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' const PDF_MIME = 'application/pdf' -// Single source of the JS source MIMEs is doc-compile.ts; reuse to avoid drift. +/** Document source MIME aliases stay anchored to the compiler definitions. */ const PPTX_SOURCE_MIME = PPTXGENJS_SOURCE_MIME const DOCX_SOURCE_MIME = DOCXJS_SOURCE_MIME const PDF_SOURCE_MIME = 'text/x-pdflibjs' @@ -196,11 +202,12 @@ export async function compileDocForWrite(args: { source: string fileName: string workspaceId: string + principal: Principal ownerKey: string signal?: AbortSignal fallbackMime: string }): Promise { - const { source, fileName, workspaceId, ownerKey, signal, fallbackMime } = args + const { source, fileName, workspaceId, principal, ownerKey, signal, fallbackMime } = args const docInfo = getDocumentFormatInfo(fileName) const e2bFmt = isDocSandboxEnabled ? await getE2BDocFormat(fileName) : null @@ -216,7 +223,7 @@ export async function compileDocForWrite(args: { // compileDoc is load-or-build, so an identical re-write reuses the cached // binary instead of re-running E2B. try { - await compileDoc({ source, fileName, workspaceId }) + await compileDoc({ source, fileName, workspaceId, filePrincipal: principal }) } catch (err) { if (err instanceof DocCompileUserError) { return { @@ -280,6 +287,11 @@ export const workspaceFileServerTool: BaseServerTool> + try { + result = await executeCopilotFileUseCase(context, createWorkspaceFile, { + workspaceId, + name: fileName, + contentType, + content, + encoding: 'utf-8', + folderId, + exactName: false, + }) + } catch (error) { + return { + success: false, + message: messageForCopilotFileError(error, 'Failed to create file'), + } + } logger.info('Workspace file created via copilot', { - fileId: result.id, + fileId: result.file.id, name: fileName, size: fileBuffer.length, contentType, @@ -378,11 +426,11 @@ export const workspaceFileServerTool: BaseServerTool ({ vi.mock('@/lib/knowledge/secret-provenance', () => ({ importKnowledgeSearchResultSecretProvenance: mockImportKnowledgeSearchResultSecretProvenance, })) +vi.mock('@/lib/knowledge/documents/service', () => ({ + createSingleDocument: vi.fn(), +})) vi.mock('@/lib/knowledge/tags/service', () => ({ createTagDefinition: vi.fn(), deleteTagDefinition: vi.fn(), @@ -72,7 +75,7 @@ vi.mock('@/lib/knowledge/tags/service', () => ({ updateTagDefinition: vi.fn(), })) vi.mock('@/lib/uploads', () => ({ StorageService: {} })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ resolveWorkspaceFileReference: vi.fn(), })) vi.mock('@/app/api/auth/oauth/utils', () => ({ getCredential: vi.fn() })) @@ -95,7 +98,7 @@ import { createSingleDocument } from '@/lib/knowledge/documents/service' import { generateSearchEmbedding, recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' import { executeKnowledgeSearch } from '@/lib/knowledge/search/queries' import { getKnowledgeBaseById } from '@/lib/knowledge/service' -import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -294,6 +297,8 @@ describe('knowledge base query model boundary', () => { { userId: 'external-admin', workspaceId: 'workspace-paid', + toolCallId: 'tool-1', + copilotToolExecution: true, billingAttribution: BILLING_ATTRIBUTION, resolvedSecretTraceRegistry: registry, } @@ -358,6 +363,8 @@ describe('knowledge base query model boundary', () => { { userId: 'external-admin', workspaceId: 'workspace-paid', + toolCallId: 'tool-1', + copilotToolExecution: true, billingAttribution: BILLING_ATTRIBUTION, resolvedSecretTraceRegistry: registry, } @@ -400,6 +407,8 @@ describe('knowledge base query model boundary', () => { { userId: 'external-admin', workspaceId: 'workspace-paid', + toolCallId: 'tool-1', + copilotToolExecution: true, billingAttribution: BILLING_ATTRIBUTION, resolvedSecretTraceRegistry: registry, } @@ -440,6 +449,8 @@ describe('knowledge base add_file usage gate', () => { { userId: 'external-admin', workspaceId: 'workspace-paid', + toolCallId: 'tool-1', + copilotToolExecution: true, billingAttribution: BILLING_ATTRIBUTION, } ) diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index e7271c7e5af..c79a4c18da7 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -12,6 +12,7 @@ import { type BillingAttributionSnapshot, checkAttributedUsageLimits, } from '@/lib/billing/core/billing-attribution' +import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { KnowledgeBase } from '@/lib/copilot/generated/tool-catalog-v1' import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { @@ -50,8 +51,9 @@ import { updateTagDefinition, } from '@/lib/knowledge/tags/service' import { StorageService } from '@/lib/uploads' -import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getBoundWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' import { getCredential } from '@/app/api/auth/oauth/utils' import { checkDocumentWriteAccess, @@ -395,10 +397,18 @@ export const knowledgeBaseServerTool: BaseServerTool = [] const failedFiles: string[] = [] + const filePrincipal = resolveCopilotFilePrincipal(context) for (const fileRef of fileRefs) { - const fileRecord = await resolveWorkspaceFileReference(kbWorkspaceId, fileRef) - if (!fileRecord) { + let fileRecord + try { + fileRecord = await resolveWorkspaceFileReference({ + principal: filePrincipal, + operation: fileOperations.readContent, + workspaceId: kbWorkspaceId, + reference: fileRef, + }) + } catch { failedFiles.push(fileRef) continue } diff --git a/apps/sim/lib/copilot/tools/server/media/ffmpeg.ts b/apps/sim/lib/copilot/tools/server/media/ffmpeg.ts index fa2e1381221..61f12590d97 100644 --- a/apps/sim/lib/copilot/tools/server/media/ffmpeg.ts +++ b/apps/sim/lib/copilot/tools/server/media/ffmpeg.ts @@ -1,17 +1,20 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { + executeCopilotFileUseCase, + resolveCopilotWorkspaceFileReference, +} from '@/lib/copilot/application/execute-file-use-case' import { Ffmpeg } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' +import { writeCopilotWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' +import { MAX_MEDIA_BYTES } from '@/lib/media/falai' import { type FfmpegOperation, type MediaFile, runFfmpegOperation } from '@/lib/media/ffmpeg' -import { - fetchWorkspaceFileBuffer, - resolveWorkspaceFileReference, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' const logger = createLogger('FfmpegTool') @@ -82,12 +85,30 @@ export const ffmpegServerTool: BaseServerTool = { try { const mediaFiles: MediaFile[] = [] + let totalInputBytes = 0 for (const filePath of inputPaths) { - const fileRecord = await resolveWorkspaceFileReference(workspaceId, filePath) - if (!fileRecord) { - return { success: false, message: `Input file not found: ${filePath}` } + const fileRecord = await resolveCopilotWorkspaceFileReference( + context, + fileOperations.readContent, + { + workspaceId, + reference: filePath, + } + ) + const { content: buffer } = await executeCopilotFileUseCase( + context, + readWorkspaceFileContent, + { + fileId: fileRecord.id, + assertedWorkspaceId: workspaceId, + maxBytes: MAX_MEDIA_BYTES, + }, + { fileId: fileRecord.id } + ) + totalInputBytes += buffer.length + if (totalInputBytes > MAX_MEDIA_BYTES) { + throw new Error(`Input files exceed the ${MAX_MEDIA_BYTES} byte limit`) } - const buffer = await fetchWorkspaceFileBuffer(fileRecord) mediaFiles.push({ buffer, mimeType: fileRecord.type || 'application/octet-stream', @@ -128,9 +149,8 @@ export const ffmpegServerTool: BaseServerTool = { const mode = outputFile?.mode ?? 'create' assertServerToolNotAborted(context) - const written = await writeWorkspaceFileByPath({ + const written = await writeCopilotWorkspaceFileByPath(context, { workspaceId, - userId: context.userId, target: { path: outputPath, mode, mimeType: outputFile?.mimeType }, buffer: result.buffer, inferredMimeType: result.contentType || 'application/octet-stream', diff --git a/apps/sim/lib/copilot/tools/server/media/generate-audio.ts b/apps/sim/lib/copilot/tools/server/media/generate-audio.ts index d13a6669019..b0faecef2dd 100644 --- a/apps/sim/lib/copilot/tools/server/media/generate-audio.ts +++ b/apps/sim/lib/copilot/tools/server/media/generate-audio.ts @@ -1,5 +1,9 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { + executeCopilotFileUseCase, + resolveCopilotWorkspaceFileReference, +} from '@/lib/copilot/application/execute-file-use-case' import { GenerateAudio } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, @@ -10,12 +14,11 @@ import { assertOpaqueWorkspaceFileModelSafe, projectServerToolModelInput, } from '@/lib/copilot/tools/server/model-input' -import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' +import { writeCopilotWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' +import { MAX_MEDIA_BYTES } from '@/lib/media/falai' import { type AudioType, generateFalAudio } from '@/lib/media/falai-audio' -import { - fetchWorkspaceFileBuffer, - resolveWorkspaceFileReference, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' const logger = createLogger('GenerateAudioTool') @@ -94,12 +97,21 @@ export const generateAudioServerTool: BaseServerTool ({ - mockFetchWorkspaceFileBuffer: vi.fn(), mockGenerateContent: vi.fn(), mockGenerateFalAudio: vi.fn(), mockGenerateFalVideo: vi.fn(), mockImportWorkspaceFileSecretProvenanceForValue: vi.fn(), mockResolveWorkspaceFileReference: vi.fn(), + mockReadWorkspaceFileContent: vi.fn(), mockWriteWorkspaceFileByPath: vi.fn(), })) @@ -28,14 +28,16 @@ vi.mock('@google/genai', () => ({ })) vi.mock('@/lib/core/config/api-keys', () => ({ getRotatingApiKey: vi.fn(() => 'api-key') })) vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ - writeWorkspaceFileByPath: mockWriteWorkspaceFileByPath, + writeCopilotWorkspaceFileByPath: mockWriteWorkspaceFileByPath, })) vi.mock('@/lib/media/falai-audio', () => ({ generateFalAudio: mockGenerateFalAudio })) vi.mock('@/lib/media/falai-video', () => ({ generateFalVideo: mockGenerateFalVideo })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - fetchWorkspaceFileBuffer: mockFetchWorkspaceFileBuffer, +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, })) +vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ + readWorkspaceFileContent: { execute: mockReadWorkspaceFileContent }, +})) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ importWorkspaceFileSecretProvenanceForValue: mockImportWorkspaceFileSecretProvenanceForValue, MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE: @@ -72,6 +74,8 @@ function contextWithSecrets( return { userId: 'user-1', workspaceId: 'workspace-1', + toolCallId: 'tool-1', + copilotToolExecution: true, resolvedSecretTraceRegistry: registry, } } @@ -81,7 +85,7 @@ describe('Mothership media model boundaries', () => { vi.clearAllMocks() mockImportWorkspaceFileSecretProvenanceForValue.mockResolvedValue(true) mockResolveWorkspaceFileReference.mockResolvedValue(file) - mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('opaque-media')) + mockReadWorkspaceFileContent.mockResolvedValue({ file, content: Buffer.from('opaque-media') }) mockWriteWorkspaceFileByPath.mockResolvedValue({ id: 'output-1', name: 'output.bin', @@ -196,7 +200,7 @@ describe('Mothership media model boundaries', () => { }) ) - expect(mockFetchWorkspaceFileBuffer).not.toHaveBeenCalled() + expect(mockReadWorkspaceFileContent).not.toHaveBeenCalled() expect(mockGenerateContent).not.toHaveBeenCalled() expect(mockGenerateFalVideo).not.toHaveBeenCalled() expect(mockGenerateFalAudio).not.toHaveBeenCalled() diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index 1162535b7a0..6395f4ddf44 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -71,6 +71,26 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, fetchWorkspaceFileBuffer: mockDownloadWorkspaceFile, })) +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ + resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, +})) +vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ + readWorkspaceFileContent: { + execute: async () => ({ content: await mockDownloadWorkspaceFile() }), + }, +})) +vi.mock('@/lib/copilot/auth/file-delegation', () => ({ + resolveCopilotFilePrincipal: vi.fn(() => ({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'test-tool', + audience: 'sim:workspace-files', + issuedAt: new Date(0), + expiresAt: new Date(Date.now() + 60_000), + })), +})) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ getBoundWorkspaceFileSecretProvenance: mockGetBoundWorkspaceFileSecretProvenance, diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index 894bb88a171..0a119295f69 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { UserTable } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, @@ -95,16 +96,15 @@ import { deleteWorkflowGroupOutput, updateWorkflowGroup, } from '@/lib/table/workflow-groups/service' -import { - fetchWorkspaceFileBuffer, - resolveWorkspaceFileReference, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getBoundWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { type FlattenedBlockOutput, flattenWorkflowOutputs, } from '@/lib/workflows/blocks/flatten-outputs' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' const logger = createLogger('UserTableServerTool') @@ -120,10 +120,22 @@ type UserTableResult = { } const MAX_BATCH_SIZE = CSV_MAX_BATCH_SIZE +const MAX_INLINE_FILE_BYTES = 50 * 1024 * 1024 -async function resolveWorkspaceFileRecordOrThrow(fileReference: string, workspaceId: string) { - const record = await resolveWorkspaceFileReference(workspaceId, fileReference) - if (!record) { +async function resolveWorkspaceFileRecordOrThrow( + fileReference: string, + workspaceId: string, + principal: ReturnType +) { + let record + try { + record = await resolveWorkspaceFileReference({ + principal, + operation: fileOperations.readContent, + workspaceId, + reference: fileReference, + }) + } catch { // Only workspace files resolve here. A chat upload is a real, correctly-copied // path, so pointing it at glob("files/**") would send the agent looking for a // file that is not in that tree until materialize_file moves it there. @@ -136,6 +148,16 @@ async function resolveWorkspaceFileRecordOrThrow(fileReference: string, workspac `File not found: "${fileReference}". Use glob("files/**") and read the canonical file path metadata to find workspace files.` ) } + if (!record) { + if (fileReference.replace(/^\/+/, '').startsWith('uploads/')) { + throw new Error( + `Cannot import "${fileReference}": chat uploads are not workspace files. Use materialize_file to save it to a files/... path first, then pass that canonical path.` + ) + } + throw new Error( + `File not found: "${fileReference}". Use glob("files/**") and read the canonical file path metadata to find workspace files.` + ) + } const provenance = await getBoundWorkspaceFileSecretProvenance(workspaceId, { fileId: record.id, @@ -1250,7 +1272,12 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const record = await resolveWorkspaceFileRecordOrThrow(fileReference, workspaceId) + const filePrincipal = resolveCopilotFilePrincipal(context) + const record = await resolveWorkspaceFileRecordOrThrow( + fileReference, + workspaceId, + filePrincipal + ) // Large CSV/TSV: create a placeholder table whose creation claims the // job slot, then let the streaming import worker infer the schema and @@ -1311,7 +1338,16 @@ export const userTableServerTool: BaseServerTool } const file = { - buffer: await fetchWorkspaceFileBuffer(record), + buffer: ( + await readWorkspaceFileContent.execute({ + principal: filePrincipal, + input: { + fileId: record.id, + assertedWorkspaceId: workspaceId, + maxBytes: MAX_INLINE_FILE_BYTES, + }, + }) + ).content, name: record.name, type: record.type, } @@ -1438,7 +1474,12 @@ export const userTableServerTool: BaseServerTool return { success: false, message: `Table is archived: ${tableId}` } } - const record = await resolveWorkspaceFileRecordOrThrow(fileReference, workspaceId) + const filePrincipal = resolveCopilotFilePrincipal(context) + const record = await resolveWorkspaceFileRecordOrThrow( + fileReference, + workspaceId, + filePrincipal + ) // Large CSV/TSV: claim the table's one-write-job slot and hand the // file to the streaming import worker (mirrors @@ -1481,7 +1522,16 @@ export const userTableServerTool: BaseServerTool } try { const file = { - buffer: await fetchWorkspaceFileBuffer(record), + buffer: ( + await readWorkspaceFileContent.execute({ + principal: filePrincipal, + input: { + fileId: record.id, + assertedWorkspaceId: workspaceId, + maxBytes: MAX_INLINE_FILE_BYTES, + }, + }) + ).content, name: record.name, type: record.type, } diff --git a/apps/sim/lib/copilot/vfs/file-reader.ts b/apps/sim/lib/copilot/vfs/file-reader.ts index 30ad2848c0d..02181e88d9d 100644 --- a/apps/sim/lib/copilot/vfs/file-reader.ts +++ b/apps/sim/lib/copilot/vfs/file-reader.ts @@ -30,6 +30,7 @@ const logger = createLogger('FileReader') /** Inline text-read cap — exported so callers can align their own byte-sniff budgets with what read() can actually display. */ export const MAX_TEXT_READ_BYTES = 5 * 1024 * 1024 // 5 MB const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024 // 5 MB +const MAX_IMAGE_SOURCE_BYTES = 50 * 1024 * 1024 // 50 MB // Parseable-document byte cap. Large office/PDF files can still // produce huge extracted text; reject up front to avoid wasting a // download + parse only to blow past the tool-result budget. @@ -286,7 +287,11 @@ export interface FileReadResult { * binary), and any size rejection. The `prepareImageForVision` span * nests underneath for the image-resize path. */ -export async function readFileRecord(record: WorkspaceFileRecord): Promise { +export async function readFileRecord( + record: WorkspaceFileRecord, + /** Pre-authorized workspace bytes; omitted only for chat-upload records in the mothership store. */ + authorizedContent?: Buffer +): Promise { const startedAt = Date.now() const result = await getVfsTracer().startActiveSpan( TraceSpan.CopilotVfsReadFile, @@ -302,7 +307,14 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise MAX_IMAGE_SOURCE_BYTES) { + span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ImageTooLarge) + return { + content: `[Image too large to process: ${record.name} (${(record.size / 1024 / 1024).toFixed(1)}MB, source limit 50MB)]`, + totalLines: 1, + } + } + const originalBuffer = authorizedContent ?? (await fetchWorkspaceFileBuffer(record)) const prepared = await prepareImageForVision(originalBuffer, record.type) if (!prepared) { span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ImageTooLarge) @@ -344,7 +356,7 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise { resolveWorkspaceFileReference: vi.fn(), updateWorkspaceFileContent: vi.fn(), uploadWorkspaceFile: vi.fn(), + createWorkspaceFileBufferByPath: { execute: vi.fn() }, + updateWorkspaceFileContentBufferByPath: { execute: vi.fn() }, } }) @@ -26,11 +28,18 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ FileConflictError: mocks.FileConflictError, getWorkspaceFileByName: mocks.getWorkspaceFileByName, - resolveWorkspaceFileReference: mocks.resolveWorkspaceFileReference, updateWorkspaceFileContent: mocks.updateWorkspaceFileContent, uploadWorkspaceFile: mocks.uploadWorkspaceFile, })) +vi.mock('@/lib/workspace-files/application/write-workspace-file-by-path', () => ({ + createWorkspaceFileBufferByPath: mocks.createWorkspaceFileBufferByPath, + updateWorkspaceFileContentBufferByPath: mocks.updateWorkspaceFileContentBufferByPath, +})) +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ + resolveWorkspaceFileReference: mocks.resolveWorkspaceFileReference, +})) + import { validateWorkspaceFileWriteTarget, writeWorkspaceFileByPath } from './resource-writer' describe('resource writer', () => { @@ -41,18 +50,19 @@ describe('resource writer', () => { it('auto-creates missing parent folders for plain workspace file creates', async () => { mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('folder-nested') - mocks.getWorkspaceFileByName.mockResolvedValue(null) - mocks.uploadWorkspaceFile.mockResolvedValue({ + mocks.createWorkspaceFileBufferByPath.execute.mockResolvedValue({ id: 'file-report', name: 'summary.csv', size: 7, - type: 'text/csv', - url: '/download', + contentType: 'text/csv', + downloadUrl: '/download', + vfsPath: 'files/Reports/2026/summary.csv', + mode: 'create', }) const result = await writeWorkspaceFileByPath({ workspaceId: 'workspace-1', - userId: 'user-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, target: { path: 'files/Reports/2026/summary.csv', mode: 'create', @@ -61,23 +71,15 @@ describe('resource writer', () => { inferredMimeType: 'text/csv', }) - expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - userId: 'user-1', - pathSegments: ['Reports', '2026'], + expect(mocks.createWorkspaceFileBufferByPath.execute).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: expect.objectContaining({ + workspaceId: 'workspace-1', + path: 'files/Reports/2026/summary.csv', + content: Buffer.from('content'), + contentType: 'text/csv', + }), }) - expect(mocks.findWorkspaceFileFolderIdByPath).not.toHaveBeenCalled() - expect(mocks.uploadWorkspaceFile).toHaveBeenCalledWith( - 'workspace-1', - 'user-1', - Buffer.from('content'), - 'summary.csv', - 'text/csv', - { - folderId: 'folder-nested', - secretProvenance: { status: 'exact', entries: [] }, - } - ) expect(result).toMatchObject({ id: 'file-report', vfsPath: 'files/Reports/2026/summary.csv', @@ -91,7 +93,7 @@ describe('resource writer', () => { const validation = await validateWorkspaceFileWriteTarget({ workspaceId: 'workspace-1', - userId: 'user-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, target: { path: 'files/Reports/2026/summary.csv', mode: 'create', @@ -112,7 +114,7 @@ describe('resource writer', () => { const validation = await validateWorkspaceFileWriteTarget({ workspaceId: 'workspace-1', - userId: 'user-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, target: { path: 'files/Reports/2026/summary.csv', mode: 'create', @@ -128,4 +130,33 @@ describe('resource writer', () => { folderId: null, }) }) + + it('authorizes overwrite target resolution through the shared application resolver', async () => { + mocks.resolveWorkspaceFileReference.mockResolvedValue({ + id: 'file-report', + name: 'summary.csv', + size: 7, + type: 'text/csv', + folderPath: 'Reports/2026', + }) + + const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } + const validation = await validateWorkspaceFileWriteTarget({ + workspaceId: 'workspace-1', + principal, + target: { path: 'files/Reports/2026/summary.csv', mode: 'overwrite' }, + }) + + expect(mocks.resolveWorkspaceFileReference).toHaveBeenCalledWith({ + principal, + operation: expect.objectContaining({ id: 'files.update_content' }), + workspaceId: 'workspace-1', + reference: 'files/Reports/2026/summary.csv', + }) + expect(validation).toMatchObject({ + mode: 'overwrite', + existingFileId: 'file-report', + vfsPath: 'files/Reports/2026/summary.csv', + }) + }) }) diff --git a/apps/sim/lib/copilot/vfs/resource-writer.ts b/apps/sim/lib/copilot/vfs/resource-writer.ts index 9dfa24141f2..1d9108a7d3b 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.ts +++ b/apps/sim/lib/copilot/vfs/resource-writer.ts @@ -1,20 +1,22 @@ -import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import type { Principal } from '@sim/auth/principal' import { - ensureWorkspaceFileFolderPath, - findWorkspaceFileFolderIdByPath, - normalizeWorkspaceFileItemName, -} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' + type CopilotFileDelegationContext, + resolveCopilotFilePrincipal, +} from '@/lib/copilot/auth/file-delegation' +import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' +import { findWorkspaceFileFolderIdByPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { getWorkspaceFileByName, - resolveWorkspaceFileReference, - updateWorkspaceFileContent, - uploadWorkspaceFile, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' import { - EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, - type WorkspaceFileSecretProvenance, -} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' + createWorkspaceFileBufferByPath, + updateWorkspaceFileContentBufferByPath, +} from '@/lib/workspace-files/application/write-workspace-file-by-path' +import { parseWorkspaceFileCreatePath } from '@/lib/workspace-files/workspace-file-path' export type WorkspaceFileWriteMode = 'create' | 'overwrite' @@ -54,68 +56,20 @@ export type WorkspaceFileWriteValidation = existingFileId: string } -function displayFolderPath(segments: string[]): string { - return segments.length > 0 ? `files/${segments.join('/')}` : 'files/' -} - -export function parseWorkspaceFileCreatePath(path: string): { - folderSegments: string[] - fileName: string - vfsPath: string -} { - const trimmed = path.trim().replace(/^\/+/, '') - if (!trimmed.startsWith('files/')) { - throw new Error('Workspace file paths must start with "files/"') - } - - const decoded = decodeVfsPathSegments(trimmed.slice('files/'.length)) - if (decoded.length === 0) { - throw new Error('Workspace file path must include a file name') - } - - const fileName = normalizeWorkspaceFileItemName(decoded.at(-1) ?? '', 'File') - const folderSegments = decoded - .slice(0, -1) - .map((segment) => normalizeWorkspaceFileItemName(segment, 'Folder')) - - return { - folderSegments, - fileName, - vfsPath: canonicalWorkspaceFilePath({ folderPath: folderSegments.join('/'), name: fileName }), - } -} - -/** - * Resolve a create-mode write target. Pass `createFolders` (the write path) to - * create missing parent folders; without it (the validation path) resolution - * is read-only — a missing parent chain yields `folderId: null`, since the - * folders are created at write time and nothing can conflict there yet. - */ +/** Resolves a create-mode target without mutating missing parent folders. */ async function resolveCreateTarget( workspaceId: string, - path: string, - createFolders?: { userId: string } + path: string ): Promise { const parsed = parseWorkspaceFileCreatePath(path) let folderId: string | null = null if (parsed.folderSegments.length > 0) { - if (createFolders) { - folderId = await ensureWorkspaceFileFolderPath({ - workspaceId, - userId: createFolders.userId, - pathSegments: parsed.folderSegments, - }) - if (!folderId) { - throw new Error(`Failed to create directory: ${displayFolderPath(parsed.folderSegments)}`) - } - } else { - folderId = await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments) - if (!folderId) { - return { - fileName: parsed.fileName, - folderId: null, - vfsPath: parsed.vfsPath, - } + folderId = await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments) + if (!folderId) { + return { + fileName: parsed.fileName, + folderId: null, + vfsPath: parsed.vfsPath, } } } @@ -138,14 +92,16 @@ function vfsPathForRecord(record: WorkspaceFileRecord): string { export async function validateWorkspaceFileWriteTarget(args: { workspaceId: string - userId?: string + principal: Principal target: WorkspaceFileWriteTarget }): Promise { if (args.target.mode === 'overwrite') { - const existing = await resolveWorkspaceFileReference(args.workspaceId, args.target.path) - if (!existing) { - throw new Error(`File not found for overwrite: ${args.target.path}`) - } + const existing = await resolveWorkspaceFileReference({ + principal: args.principal, + operation: fileOperations.updateContent, + workspaceId: args.workspaceId, + reference: args.target.path, + }) return { mode: 'overwrite', vfsPath: vfsPathForRecord(existing), @@ -164,7 +120,7 @@ export async function validateWorkspaceFileWriteTarget(args: { export async function writeWorkspaceFileByPath(args: { workspaceId: string - userId: string + principal: Principal target: WorkspaceFileWriteTarget buffer: Buffer inferredMimeType: string @@ -179,59 +135,65 @@ export async function writeWorkspaceFileByPath(args: { }): Promise { const contentType = args.target.mimeType || args.inferredMimeType if (args.target.mode === 'overwrite') { - const existing = await resolveWorkspaceFileReference(args.workspaceId, args.target.path) - if (!existing) { - throw new Error(`File not found for overwrite: ${args.target.path}`) - } - - const updated = await updateWorkspaceFileContent( - args.workspaceId, - existing.id, - args.userId, - args.buffer, - contentType || existing.type, - { + const updated = await updateWorkspaceFileContentBufferByPath.execute({ + principal: args.principal, + input: { + workspaceId: args.workspaceId, + path: args.target.path, + mode: 'overwrite', + content: args.buffer, + contentType, syncLiveDoc: args.syncLiveDoc, - secretProvenancePolicy: { - mode: 'replace', - provenance: args.secretProvenance ?? { status: 'exact', entries: [] }, - }, - } - ) + secretProvenance: args.secretProvenance, + }, + }) return { id: updated.id, name: updated.name, size: updated.size, - contentType: updated.type, - downloadUrl: updated.url, - vfsPath: vfsPathForRecord(updated), + contentType: updated.contentType, + downloadUrl: updated.downloadUrl, + vfsPath: updated.vfsPath, mode: 'overwrite', } } - const createTarget = await resolveCreateTarget(args.workspaceId, args.target.path, { - userId: args.userId, + const created = await createWorkspaceFileBufferByPath.execute({ + principal: args.principal, + input: { + workspaceId: args.workspaceId, + path: args.target.path, + mode: 'create', + content: args.buffer, + contentType, + exactName: true, + secretProvenance: args.secretProvenance, + }, }) - const uploaded = await uploadWorkspaceFile( - args.workspaceId, - args.userId, - args.buffer, - createTarget.fileName, - contentType, - { - folderId: createTarget.folderId, - secretProvenance: args.secretProvenance ?? EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, - } - ) return { - id: uploaded.id, - name: uploaded.name, - size: uploaded.size, - contentType: uploaded.type, - downloadUrl: uploaded.url, - vfsPath: createTarget.vfsPath, + id: created.id, + name: created.name, + size: created.size, + contentType: created.contentType, + downloadUrl: created.downloadUrl, + vfsPath: created.vfsPath, mode: 'create', } } + +type CopilotWorkspaceFileWriteArgs = Omit< + Parameters[0], + 'principal' +> + +export function writeCopilotWorkspaceFileByPath( + context: CopilotFileDelegationContext | undefined, + args: CopilotWorkspaceFileWriteArgs +) { + return writeWorkspaceFileByPath({ + ...args, + principal: resolveCopilotFilePrincipal(context), + }) +} diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 4a7b340a07c..b451518ff56 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -1,4 +1,5 @@ import { trace } from '@opentelemetry/api' +import type { Principal } from '@sim/auth/principal' import { db } from '@sim/db' import { chat as chatTable, @@ -48,7 +49,11 @@ import { } from '@/lib/copilot/tools/server/workflow/edit-workflow/lint' import { UNRESOLVABLE_AT_LINT_NOTE } from '@/lib/copilot/tools/server/workflow/edit-workflow/validation' import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' -import { type FileReadResult, readFileRecord } from '@/lib/copilot/vfs/file-reader' +import { + type FileReadResult, + MAX_TEXT_READ_BYTES, + readFileRecord, +} from '@/lib/copilot/vfs/file-reader' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import type { GrepMatch, GrepOptions, ReadResult } from '@/lib/copilot/vfs/operations' import * as ops from '@/lib/copilot/vfs/operations' @@ -117,15 +122,9 @@ import { getKnowledgeBases } from '@/lib/knowledge/service' import { validateMermaidSource } from '@/lib/mermaid/validate' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' -import { getWorkspaceShares } from '@/lib/public-shares/share-manager' import { listTables } from '@/lib/table/service' -import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { - fetchWorkspaceFileBuffer, - findWorkspaceFileRecord, - listWorkspaceFiles, - type WorkspaceFileRecord, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { findWorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import type { WorkspaceFileSecretProvenanceEnvelope } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' @@ -133,6 +132,9 @@ import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/ut import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { getSkillById } from '@/lib/workflows/skills/operations' import { listFolders, listWorkflows } from '@/lib/workflows/utils' +import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' +import { listWorkspaceFileFoldersOperation } from '@/lib/workspace-files/application/workspace-file-folders' import { assertActiveWorkspaceAccess, getUsersWithPermissions, @@ -563,6 +565,7 @@ function getStaticComponentFiles(): Map { * components/triggers/{provider}/{id}.json (external triggers: github, slack, etc.) */ export class WorkspaceVFS { + private readonly filePrincipal?: Principal // Eagerly-materialized, cheap content (structure + metadata): folder markers, // per-resource meta.json, WORKSPACE.md/WORKSPACE_CONTEXT.md, static components. private files: Map = new Map() @@ -595,6 +598,10 @@ export class WorkspaceVFS { */ private _customBlockTypes: Set | null = null + constructor(filePrincipal?: Principal) { + this.filePrincipal = filePrincipal + } + get workspaceId(): string { return this._workspaceId } @@ -1035,10 +1042,23 @@ export class WorkspaceVFS { const canonicalMatch = path.match(new RegExp(`^files/(.+)/${suffix}$`)) if (!canonicalMatch?.[1]) return null - const files = await listWorkspaceFiles(this._workspaceId) + if (!this.filePrincipal) { + throw new Error('Workspace file reads require a trusted Copilot principal') + } + const { files } = await listAllWorkspaceFiles.execute({ + principal: this.filePrincipal, + input: { workspaceId: this._workspaceId, scope: 'active' }, + }) return findWorkspaceFileRecord(files, `files/${canonicalMatch[1]}`) } + private requireFilePrincipal(): Principal { + if (!this.filePrincipal) { + throw new Error('Workspace file reads require a trusted Copilot principal') + } + return this.filePrincipal + } + /** * Renders a renderable doc (pptx/docx/pdf) record to a contact-sheet image and * returns it as a model readable JPEG attachment. Shared by the `/render` and @@ -1059,7 +1079,14 @@ export class WorkspaceVFS { totalLines: 1, } } - const buffer = await fetchWorkspaceFileBuffer(record) + const { content: buffer } = await readWorkspaceFileContent.execute({ + principal: this.requireFilePrincipal(), + input: { + fileId: record.id, + assertedWorkspaceId: this._workspaceId, + maxBytes: MAX_DOC_READ_INPUT_BYTES, + }, + }) if (buffer.length > MAX_DOC_READ_INPUT_BYTES) { return { content: JSON.stringify({ ok: false, error: 'File is too large to render' }), @@ -1082,7 +1109,12 @@ export class WorkspaceVFS { } if (isDocSandboxEnabled && (await getE2BDocFormat(record.name))) { bin = ( - await compileDoc({ source: code, fileName: record.name, workspaceId: this._workspaceId }) + await compileDoc({ + source: code, + fileName: record.name, + workspaceId: this._workspaceId, + filePrincipal: this.requireFilePrincipal(), + }) ).buffer } else { const taskId = BINARY_DOC_TASKS[ext] @@ -1172,7 +1204,14 @@ export class WorkspaceVFS { }) } - const buffer = await fetchWorkspaceFileBuffer(record) + const { content: buffer } = await readWorkspaceFileContent.execute({ + principal: this.requireFilePrincipal(), + input: { + fileId: record.id, + assertedWorkspaceId: this._workspaceId, + maxBytes: MAX_DOC_READ_INPUT_BYTES, + }, + }) const code = buffer.toString('utf-8') if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { return bindWorkspaceFileResult(record, { @@ -1186,6 +1225,7 @@ export class WorkspaceVFS { source: code, fileName: record.name, workspaceId: this._workspaceId, + filePrincipal: this.requireFilePrincipal(), }) ).buffer : await runSandboxTask(taskId, { code, workspaceId: this._workspaceId }) @@ -1296,7 +1336,14 @@ export class WorkspaceVFS { totalLines: 1, }) } - const buffer = await fetchWorkspaceFileBuffer(record) + const { content: buffer } = await readWorkspaceFileContent.execute({ + principal: this.requireFilePrincipal(), + input: { + fileId: record.id, + assertedWorkspaceId: this._workspaceId, + maxBytes: MAX_DOC_READ_INPUT_BYTES, + }, + }) if (buffer.length > MAX_DOC_READ_INPUT_BYTES) { return bindWorkspaceFileResult(record, { content: JSON.stringify({ ok: false, error: 'File is too large to extract' }), @@ -1348,7 +1395,14 @@ export class WorkspaceVFS { const taskId = BINARY_DOC_TASKS[ext] const isMermaidFile = ext === 'mmd' || ext === 'mermaid' if (!e2bFmt && !taskId && !isMermaidFile) return null - const buffer = await fetchWorkspaceFileBuffer(record) + const { content: buffer } = await readWorkspaceFileContent.execute({ + principal: this.requireFilePrincipal(), + input: { + fileId: record.id, + assertedWorkspaceId: this._workspaceId, + maxBytes: MAX_DOC_READ_INPUT_BYTES, + }, + }) const code = buffer.toString('utf-8') if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { return bindWorkspaceFileResult(record, { @@ -1371,6 +1425,7 @@ export class WorkspaceVFS { fileName: record.name, workspaceId: this._workspaceId, ext, + principal: this.requireFilePrincipal(), }) } else { try { @@ -1407,7 +1462,20 @@ export class WorkspaceVFS { const rawExt = record.name.split('.').pop()?.toLowerCase() if (rawExt !== 'docx' && rawExt !== 'pptx' && rawExt !== 'pdf') return null const ext: 'docx' | 'pptx' | 'pdf' = rawExt - const buffer = await fetchWorkspaceFileBuffer(record) + if (typeof record.size === 'number' && record.size > MAX_DOC_READ_INPUT_BYTES) { + return bindWorkspaceFileResult(record, { + content: JSON.stringify({ ok: false, error: 'File is too large to extract style' }), + totalLines: 1, + }) + } + const { content: buffer } = await readWorkspaceFileContent.execute({ + principal: this.requireFilePrincipal(), + input: { + fileId: record.id, + assertedWorkspaceId: this._workspaceId, + maxBytes: MAX_DOC_READ_INPUT_BYTES, + }, + }) const summary = await extractDocumentStyle(buffer, ext) if (!summary) return null const json = JSON.stringify(summary, null, 2) @@ -1440,11 +1508,23 @@ export class WorkspaceVFS { const scope = deletedMatch ? 'archived' : 'active' try { - const files = await listWorkspaceFiles(this._workspaceId, { scope }) + const { files } = await listAllWorkspaceFiles.execute({ + principal: this.requireFilePrincipal(), + input: { workspaceId: this._workspaceId, scope }, + }) const record = findWorkspaceFileRecord(files, fileReference) if (!record) return null - const result = await readFileRecord(record) - return result ? bindWorkspaceFileResult(record, result) : null + const { file, content } = await readWorkspaceFileContent.execute({ + principal: this.requireFilePrincipal(), + input: { + fileId: record.id, + assertedWorkspaceId: this._workspaceId, + includeDeleted: scope === 'archived', + maxBytes: MAX_TEXT_READ_BYTES, + }, + }) + const result = await readFileRecord(file, content) + return result ? bindWorkspaceFileResult(file, result) : null } catch (err) { logger.warn('Failed to list workspace files for readFileContent', { workspaceId: this._workspaceId, @@ -1861,22 +1941,14 @@ export class WorkspaceVFS { */ private async materializeFiles(workspaceId: string): Promise { try { - const folders = await listWorkspaceFileFolders(workspaceId) - const files = await listWorkspaceFiles(workspaceId, { folders, throwOnError: true }) - // Batch-load public share state so each file's metadata carries an ambient - // `shared` flag (mirrors how the files-list UI enriches rows) — no N+1. - // Fail soft: share state is only metadata enrichment, so a lookup failure - // must not drop the whole file tree (the outer catch returns []) — fall back - // to no shares, and files still materialize with `shared: false`. - let shareByFileId: Awaited> = new Map() - try { - shareByFileId = await getWorkspaceShares('file', workspaceId) - } catch (error) { - logger.warn('Failed to load file share state; file metadata will show shared: false', { - workspaceId, - error: toError(error).message, - }) - } + const principal = this.requireFilePrincipal() + const [{ folders }, { files }] = await Promise.all([ + listWorkspaceFileFoldersOperation.execute({ + principal, + input: { workspaceId, scope: 'active' }, + }), + listAllWorkspaceFiles.execute({ principal, input: { workspaceId, scope: 'active' } }), + ]) for (const folder of folders) { this.files.set(`files/${encodeVfsPathSegments(folder.path.split('/'))}/.folder`, '') } @@ -1886,7 +1958,7 @@ export class WorkspaceVFS { folderPath: file.folderPath, name: file.name, }) - const share = shareByFileId.get(file.id) + const share = file.share const shared = share?.isActive ?? false this.files.set( filePath, @@ -2259,8 +2331,18 @@ export class WorkspaceVFS { ) ), listTables(workspaceId, { scope: 'archived' }), - listWorkspaceFiles(workspaceId, { scope: 'archived' }), - listWorkspaceFileFolders(workspaceId, { scope: 'archived' }), + listAllWorkspaceFiles + .execute({ + principal: this.requireFilePrincipal(), + input: { workspaceId, scope: 'archived' }, + }) + .then(({ files }) => files), + listWorkspaceFileFoldersOperation + .execute({ + principal: this.requireFilePrincipal(), + input: { workspaceId, scope: 'archived' }, + }) + .then(({ folders }) => folders), getKnowledgeBases(userId, workspaceId, 'archived'), ]) @@ -2488,10 +2570,10 @@ export class WorkspaceVFS { export async function getOrMaterializeVFS( workspaceId: string, userId: string, - options?: { secretMountPolicy?: SecretMountPolicy } + options?: { secretMountPolicy?: SecretMountPolicy; filePrincipal?: Principal } ): Promise { await assertActiveWorkspaceAccess(workspaceId, userId) - const vfs = new WorkspaceVFS() + const vfs = new WorkspaceVFS(options?.filePrincipal) await vfs.materialize(workspaceId, userId, options) return vfs } diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts new file mode 100644 index 00000000000..6c72bb0d3e5 --- /dev/null +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts @@ -0,0 +1,306 @@ +/** + * @vitest-environment node + */ +import type { + DelegatedPrincipal, + PersonalApiKeyPrincipal, + SessionPrincipal, + WorkspaceApiKeyPrincipal, +} from '@sim/auth/principal' +import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + events: [] as string[], + recordAudit: vi.fn(() => mocks.events.push('audit')), + resolvePermission: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_UPDATED: 'file.updated' }, + AuditResourceType: { FILE: 'file' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +import { AuditAction, AuditResourceType } from '@sim/audit' +import { defineAuthorizedWorkspaceUseCase, defineWorkspaceOperation } from '@/lib/core/application' +import type { OrchestrationError } from '@/lib/core/orchestration/types' + +const operation = defineWorkspaceOperation({ + id: 'test.rename', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session'], +}) + +const delegatedOperation = defineWorkspaceOperation({ + id: 'test.delegated_read', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], +}) + +const workspaceKeyOperation = defineWorkspaceOperation({ + id: 'test.workspace_key_read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['workspace_api_key'], +}) + +interface TestInput { + resourceId: string +} + +interface TestContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + canonicalResourceId: string +} + +const canonicalContext: TestContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + canonicalResourceId: 'resource-1', +} + +const sessionPrincipal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', +} + +describe('defineAuthorizedWorkspaceUseCase', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.events.length = 0 + mocks.resolvePermission.mockResolvedValue('write') + }) + + it('narrows definition callbacks while keeping public execution principal-safe', async () => { + const resolveContext = vi.fn( + async ({ principal }: { principal: SessionPrincipal; input: TestInput }) => { + expectTypeOf(principal).toEqualTypeOf() + return canonicalContext + } + ) + const useCase = defineAuthorizedWorkspaceUseCase({ + operation, + resolveContext, + authorizationOptions: {}, + async execute({ principal, context }) { + expectTypeOf(principal).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf() + return { resource: { id: context.canonicalResourceId, name: 'Renamed' } } + }, + }) + + const disallowedPrincipal: PersonalApiKeyPrincipal = { + kind: 'personal_api_key', + userId: 'user-1', + keyId: 'key-1', + } + await expect( + useCase.execute({ principal: disallowedPrincipal, input: { resourceId: 'resource-1' } }) + ).rejects.toMatchObject>({ code: 'forbidden' }) + + expect(resolveContext).not.toHaveBeenCalled() + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('authorizes canonical context, enriches one audit entry, then runs afterSuccess', async () => { + const request = { headers: new Headers({ 'user-agent': 'vitest' }) } + const useCase = defineAuthorizedWorkspaceUseCase({ + operation, + resolveContext: async ({ input }: { principal: SessionPrincipal; input: TestInput }) => ({ + ...canonicalContext, + canonicalResourceId: input.resourceId, + }), + authorizationOptions: {}, + async execute({ context }) { + mocks.events.push('execute') + return { resource: { id: context.canonicalResourceId, name: 'Renamed' } } + }, + projectAudit({ result }) { + return { + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: result.resource.id, + resourceName: result.resource.name, + metadata: { operation: 'spoofed', actor: 'spoofed', retained: true }, + } + }, + async afterSuccess() { + mocks.events.push('afterSuccess') + }, + }) + + await expect( + useCase.execute({ + principal: sessionPrincipal, + input: { resourceId: 'resource-1' }, + request, + }) + ).resolves.toEqual({ resource: { id: 'resource-1', name: 'Renamed' } }) + + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'organization-1', + undefined, + { forUpdate: undefined } + ) + expect(mocks.recordAudit).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + actorId: 'user-1', + actorName: undefined, + action: 'file.updated', + resourceType: 'file', + resourceId: 'resource-1', + resourceName: 'Renamed', + description: undefined, + metadata: { + retained: true, + operation: 'test.rename', + actor: { kind: 'session', userId: 'user-1' }, + }, + request, + }) + expect(mocks.events).toEqual(['execute', 'audit', 'afterSuccess']) + }) + + it('supports zero or many semantic audit entries', async () => { + const buildUseCase = (auditCount: number) => + defineAuthorizedWorkspaceUseCase({ + operation, + resolveContext: async (_args: { principal: SessionPrincipal; input: TestInput }) => + canonicalContext, + authorizationOptions: {}, + async execute() { + return { auditCount } + }, + projectAudit({ result }) { + return Array.from({ length: result.auditCount }, (_, index) => ({ + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: `resource-${index}`, + })) + }, + }) + + await buildUseCase(0).execute({ + principal: sessionPrincipal, + input: { resourceId: 'resource-1' }, + }) + expect(mocks.recordAudit).not.toHaveBeenCalled() + + await buildUseCase(2).execute({ + principal: sessionPrincipal, + input: { resourceId: 'resource-1' }, + }) + expect(mocks.recordAudit).toHaveBeenCalledTimes(2) + }) + + it('resolves domain-specific delegation options against canonical context', async () => { + const scopeCheck = vi.fn( + (principal: DelegatedPrincipal, context: TestContext) => + principal.resourceScope?.fileId === context.canonicalResourceId + ) + const useCase = defineAuthorizedWorkspaceUseCase({ + operation: delegatedOperation, + resolveContext: async (_args: { principal: DelegatedPrincipal; input: TestInput }) => + canonicalContext, + authorizationOptions: ({ principal }) => { + expectTypeOf(principal).toEqualTypeOf() + return { + delegation: { + audience: 'test:files', + isWithinScope: scopeCheck, + }, + } + }, + async execute({ principal }) { + expectTypeOf(principal).toEqualTypeOf() + return { ok: true as const } + }, + }) + const principal: DelegatedPrincipal = { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'test:files', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { fileId: 'resource-1' }, + } + + await expect( + useCase.execute({ principal, input: { resourceId: 'resource-1' } }) + ).resolves.toEqual({ ok: true }) + expect(scopeCheck).toHaveBeenCalledWith(principal, canonicalContext) + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'organization-1', + undefined, + { forUpdate: undefined } + ) + }) + + it('records workspace API keys as non-human audit actors', async () => { + const useCase = defineAuthorizedWorkspaceUseCase({ + operation: workspaceKeyOperation, + resolveContext: async (_args: { principal: WorkspaceApiKeyPrincipal; input: TestInput }) => + canonicalContext, + authorizationOptions: {}, + async execute() { + return { id: 'resource-1' } + }, + projectAudit({ result }) { + return { + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: result.id, + } + }, + }) + + await useCase.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + }, + input: { resourceId: 'resource-1' }, + }) + + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: null, + actorName: 'Workspace API key', + metadata: { + operation: 'test.workspace_key_read', + actor: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + }, + }, + }) + ) + }) +}) diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.ts new file mode 100644 index 00000000000..ff0fc8faecb --- /dev/null +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.ts @@ -0,0 +1,152 @@ +import { type AuditActionType, type AuditResourceTypeValue, recordAudit } from '@sim/audit' +import type { PrincipalAuditAttribution } from '@sim/auth/principal' +import { resolvePrincipalAuditAttribution } from '@sim/auth/principal' +import type { OperationUseCase } from '@/lib/core/application/operation' +import { + authorizeWorkspaceOperation, + requireAllowedWorkspacePrincipal, + type WorkspaceAuthorizationContext, + type WorkspaceAuthorizationOptions, +} from '@/lib/core/application/workspace-authorization' +import type { + PrincipalForOperation, + WorkspaceOperation, +} from '@/lib/core/application/workspace-operation' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' + +export interface WorkspaceUseCaseAuditEntry { + action: AuditActionType + resourceType: AuditResourceTypeValue + resourceId?: string + resourceName?: string + description?: string + metadata?: Record +} + +export interface AuthorizedWorkspaceUseCaseContext< + O extends WorkspaceOperation, + I, + C extends WorkspaceAuthorizationContext, +> { + principal: PrincipalForOperation + input: I + context: C + request?: OrchestrationRequestContext +} + +export interface AuthorizedWorkspaceUseCaseResultContext< + O extends WorkspaceOperation, + I, + C extends WorkspaceAuthorizationContext, + R, +> extends AuthorizedWorkspaceUseCaseContext { + result: R +} + +export interface AuthorizedWorkspaceUseCaseDefinition< + O extends WorkspaceOperation, + I, + C extends WorkspaceAuthorizationContext, + R, +> { + operation: O + resolveContext(args: { principal: PrincipalForOperation; input: I }): C | Promise + authorizationOptions: + | WorkspaceAuthorizationOptions + | (( + args: AuthorizedWorkspaceUseCaseContext + ) => WorkspaceAuthorizationOptions | Promise>) + execute(args: AuthorizedWorkspaceUseCaseContext): Promise + projectAudit?( + args: AuthorizedWorkspaceUseCaseResultContext + ): WorkspaceUseCaseAuditEntry | WorkspaceUseCaseAuditEntry[] + afterSuccess?(args: AuthorizedWorkspaceUseCaseResultContext): void | Promise +} + +function isAuthorizationOptionsResolver< + O extends WorkspaceOperation, + I, + C extends WorkspaceAuthorizationContext, +>( + options: AuthorizedWorkspaceUseCaseDefinition['authorizationOptions'] +): options is ( + args: AuthorizedWorkspaceUseCaseContext +) => WorkspaceAuthorizationOptions | Promise> { + return typeof options === 'function' +} + +function recordProjectedAuditEntries( + operation: O, + context: WorkspaceAuthorizationContext, + attribution: PrincipalAuditAttribution, + request: OrchestrationRequestContext | undefined, + entries: readonly WorkspaceUseCaseAuditEntry[] +): void { + for (const entry of entries) { + recordAudit({ + workspaceId: context.workspaceId, + actorId: attribution.actorId, + actorName: attribution.actorName, + action: entry.action, + resourceType: entry.resourceType, + resourceId: entry.resourceId, + resourceName: entry.resourceName, + description: entry.description, + metadata: { + ...entry.metadata, + operation: operation.id, + actor: attribution.actor, + }, + request, + }) + } +} + +export function defineAuthorizedWorkspaceUseCase< + const O extends WorkspaceOperation, + I, + C extends WorkspaceAuthorizationContext, + R, +>(definition: AuthorizedWorkspaceUseCaseDefinition): OperationUseCase { + return { + operation: definition.operation, + async execute({ principal, input, request }) { + requireAllowedWorkspacePrincipal(principal, definition.operation) + const context = await definition.resolveContext({ principal, input }) + const executionContext: AuthorizedWorkspaceUseCaseContext = { + principal, + input, + context, + request, + } + const authorizationOptions = isAuthorizationOptionsResolver(definition.authorizationOptions) + ? await definition.authorizationOptions(executionContext) + : definition.authorizationOptions + + await authorizeWorkspaceOperation( + principal, + definition.operation, + context, + authorizationOptions + ) + const result = await definition.execute(executionContext) + const resultContext = { ...executionContext, result } + const projectedAudit = definition.projectAudit?.(resultContext) + if (projectedAudit !== undefined) { + const auditEntries = Array.isArray(projectedAudit) ? projectedAudit : [projectedAudit] + if (auditEntries.length > 0) { + const auditAttribution = resolvePrincipalAuditAttribution(principal) + recordProjectedAuditEntries( + definition.operation, + context, + auditAttribution, + request, + auditEntries + ) + } + } + await definition.afterSuccess?.(resultContext) + return result + }, + } +} diff --git a/apps/sim/lib/core/application/index.ts b/apps/sim/lib/core/application/index.ts new file mode 100644 index 00000000000..9c28f293d70 --- /dev/null +++ b/apps/sim/lib/core/application/index.ts @@ -0,0 +1,26 @@ +export { + type AuthorizedWorkspaceUseCaseContext, + type AuthorizedWorkspaceUseCaseDefinition, + type AuthorizedWorkspaceUseCaseResultContext, + defineAuthorizedWorkspaceUseCase, + type WorkspaceUseCaseAuditEntry, +} from '@/lib/core/application/authorized-workspace-use-case' +export type { + ApplicationOperation, + OperationUseCase, +} from '@/lib/core/application/operation' +export type { + WorkspaceAuthorizationContext, + WorkspaceAuthorizationOptions, + WorkspaceDelegationPolicy, +} from '@/lib/core/application/workspace-authorization' +export { + authorizeWorkspaceOperation, + requireAllowedWorkspacePrincipal, +} from '@/lib/core/application/workspace-authorization' +export { + defineWorkspaceOperation, + type PrincipalForOperation, + type PrincipalKind, + type WorkspaceOperation, +} from '@/lib/core/application/workspace-operation' diff --git a/apps/sim/lib/core/application/operation.ts b/apps/sim/lib/core/application/operation.ts new file mode 100644 index 00000000000..618a0aa3757 --- /dev/null +++ b/apps/sim/lib/core/application/operation.ts @@ -0,0 +1,15 @@ +import type { Principal } from '@sim/auth/principal' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' + +export interface ApplicationOperation { + readonly id: Id +} + +export interface OperationUseCase { + readonly operation: O + execute(args: { + principal: Principal + input: I + request?: OrchestrationRequestContext + }): Promise +} diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts new file mode 100644 index 00000000000..01d5e904e31 --- /dev/null +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -0,0 +1,117 @@ +import type { DelegatedPrincipal, Principal } from '@sim/auth/principal' +import type { db } from '@sim/db' +import { + type PermissionType, + permissionSatisfies, + resolveEffectiveWorkspacePermission, +} from '@sim/platform-authz/workspace' +import type { + PrincipalForOperation, + WorkspaceOperation, +} from '@/lib/core/application/workspace-operation' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +export interface WorkspaceAuthorizationContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean +} + +export interface WorkspaceDelegationPolicy { + audience: string + isWithinScope(principal: DelegatedPrincipal, context: C): boolean +} + +export interface WorkspaceAuthorizationOptions { + executor?: Pick + forUpdate?: boolean + delegation?: WorkspaceDelegationPolicy +} + +export function requireAllowedWorkspacePrincipal( + principal: Principal, + operation: O +): asserts principal is PrincipalForOperation { + if (!operation.principalKinds.some((kind) => kind === principal.kind)) { + throw new OrchestrationError( + 'forbidden', + `Principal kind ${principal.kind} cannot perform operation ${operation.id}` + ) + } +} + +function requirePermission(permission: PermissionType | null, required: PermissionType): void { + if (!permissionSatisfies(permission, required)) { + throw new OrchestrationError('forbidden', 'Insufficient workspace permissions') + } +} + +async function requireCurrentHumanPermission( + userId: string, + context: C, + required: PermissionType, + options?: WorkspaceAuthorizationOptions +): Promise { + const permission = await resolveEffectiveWorkspacePermission( + userId, + context.workspaceId, + context.workspaceOrganizationId, + options?.executor, + { forUpdate: options?.forUpdate } + ) + requirePermission(permission, required) +} + +export async function authorizeWorkspaceOperation( + principal: Principal, + operation: WorkspaceOperation, + context: C, + options?: WorkspaceAuthorizationOptions +): Promise { + requireAllowedWorkspacePrincipal(principal, operation) + + switch (principal.kind) { + case 'session': + await requireCurrentHumanPermission(principal.userId, context, operation.minimumRole, options) + return + case 'personal_api_key': + if (!context.allowPersonalApiKeys) { + throw new OrchestrationError( + 'forbidden', + 'Personal API keys are disabled for this workspace' + ) + } + await requireCurrentHumanPermission(principal.userId, context, operation.minimumRole, options) + return + case 'workspace_api_key': + if ( + principal.workspaceId !== context.workspaceId || + operation.workspaceApiKey !== 'allow' || + !permissionSatisfies('write', operation.minimumRole) + ) { + throw new OrchestrationError('forbidden', 'Workspace API key cannot perform this operation') + } + return + case 'delegated': { + const delegation = options?.delegation + if (!delegation) { + throw new Error(`Operation ${operation.id} requires an explicit delegation policy`) + } + if ( + principal.audience !== delegation.audience || + principal.expiresAt.getTime() <= Date.now() || + principal.workspaceId !== context.workspaceId || + !delegation.isWithinScope(principal, context) + ) { + throw new OrchestrationError('forbidden', 'Delegated workspace access is no longer valid') + } + await requireCurrentHumanPermission( + principal.subjectUserId, + context, + operation.minimumRole, + options + ) + return + } + } +} diff --git a/apps/sim/lib/core/application/workspace-operation.ts b/apps/sim/lib/core/application/workspace-operation.ts new file mode 100644 index 00000000000..ec6d731adfb --- /dev/null +++ b/apps/sim/lib/core/application/workspace-operation.ts @@ -0,0 +1,54 @@ +import type { Principal } from '@sim/auth/principal' +import type { PermissionType } from '@sim/platform-authz/workspace' +import type { ApplicationOperation } from '@/lib/core/application/operation' + +type WorkspaceApiKeyPolicy = R extends 'admin' ? 'deny' : 'allow' | 'deny' + +export type PrincipalKind = Principal['kind'] + +export type PrincipalForOperation = + Extract + +export interface WorkspaceOperation< + Id extends string = string, + Role extends PermissionType = PermissionType, + PrincipalKinds extends readonly PrincipalKind[] = readonly PrincipalKind[], +> extends ApplicationOperation { + readonly minimumRole: Role + readonly workspaceApiKey: WorkspaceApiKeyPolicy + readonly principalKinds: PrincipalKinds +} + +type WorkspaceApiKeyPrincipalConsistency< + Role extends PermissionType, + PrincipalKinds extends readonly PrincipalKind[], +> = 'workspace_api_key' extends PrincipalKinds[number] + ? { readonly workspaceApiKey: Role extends 'admin' ? never : 'allow' } + : { readonly workspaceApiKey: 'deny' } + +export function defineWorkspaceOperation< + const Id extends string, + const Role extends PermissionType, + const PrincipalKinds extends readonly PrincipalKind[], +>( + operation: WorkspaceOperation & + WorkspaceApiKeyPrincipalConsistency +): WorkspaceOperation { + if (operation.principalKinds.length === 0) { + throw new Error(`Operation ${operation.id} must allow at least one principal kind`) + } + if (new Set(operation.principalKinds).size !== operation.principalKinds.length) { + throw new Error(`Operation ${operation.id} declares duplicate principal kinds`) + } + + const allowsWorkspaceApiKey = operation.principalKinds.includes('workspace_api_key') + if (allowsWorkspaceApiKey !== (operation.workspaceApiKey === 'allow')) { + throw new Error(`Operation ${operation.id} has inconsistent workspace API key policy`) + } + if (allowsWorkspaceApiKey && !['read', 'write'].includes(operation.minimumRole)) { + throw new Error(`Operation ${operation.id} exceeds the workspace API key write ceiling`) + } + + Object.freeze(operation.principalKinds) + return Object.freeze(operation) +} diff --git a/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts b/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts index 67a3f332719..5283d85285f 100644 --- a/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts +++ b/apps/sim/lib/core/rate-limiter/rate-limiter.test.ts @@ -215,6 +215,37 @@ describe('RateLimiter', () => { expect(result.remaining).toBe(1) }) + it('should propagate storage errors for declarative API operation buckets', async () => { + const failure = new Error('Storage error') + mockAdapter.consumeTokens.mockRejectedValue(failure) + + await expect( + rateLimiter.checkRateLimitWithSubscriptionOrThrow( + testUserId, + freeSubscription, + 'api-endpoint', + false + ) + ).rejects.toBe(failure) + }) + + it('should consume an explicit namespaced subject without rewriting its key', async () => { + const config = RATE_LIMITS.free.apiEndpoint + mockAdapter.consumeTokens.mockResolvedValue({ + allowed: true, + tokensRemaining: config.maxTokens - 1, + resetAt: new Date(Date.now() + 60_000), + }) + + await rateLimiter.checkRateLimitDirectOrThrow('v2:files.rename:api-key:key-1', config) + + expect(mockAdapter.consumeTokens).toHaveBeenCalledWith( + 'v2:files.rename:api-key:key-1', + 1, + config + ) + }) + it('should work for all non-manual trigger types', async () => { const triggerTypes = ['api', 'webhook', 'schedule', 'chat'] as const const mockResult: ConsumeResult = { diff --git a/apps/sim/lib/core/rate-limiter/rate-limiter.ts b/apps/sim/lib/core/rate-limiter/rate-limiter.ts index 9e274839d86..d2e132ecf1e 100644 --- a/apps/sim/lib/core/rate-limiter/rate-limiter.ts +++ b/apps/sim/lib/core/rate-limiter/rate-limiter.ts @@ -66,6 +66,40 @@ export class RateLimiter { } } + private async consumeWithSubscription( + subjectId: string, + subscription: SubscriptionInfo | null, + triggerType: TriggerType, + isAsync: boolean + ): Promise { + if (triggerType === 'manual') { + return this.createUnlimitedResult() + } + + const plan = (subscription?.plan || 'free') as SubscriptionPlan + const rateLimitKey = this.getRateLimitKey(subjectId, subscription) + const counterType = this.getCounterType(triggerType, isAsync) + const config = getRateLimit(plan, counterType) + const storageKey = this.buildStorageKey(rateLimitKey, counterType) + const result = await this.storage.consumeTokens(storageKey, 1, config) + + if (!result.allowed) { + logger.info('Rate limit exceeded', { + rateLimitKey, + counterType, + plan, + tokensRemaining: result.tokensRemaining, + }) + } + + return { + allowed: result.allowed, + remaining: result.tokensRemaining, + resetAt: result.resetAt, + retryAfterMs: result.retryAfterMs, + } + } + async checkRateLimitWithSubscription( userId: string, subscription: SubscriptionInfo | null, @@ -73,33 +107,7 @@ export class RateLimiter { isAsync = false ): Promise { try { - if (triggerType === 'manual') { - return this.createUnlimitedResult() - } - - const plan = (subscription?.plan || 'free') as SubscriptionPlan - const rateLimitKey = this.getRateLimitKey(userId, subscription) - const counterType = this.getCounterType(triggerType, isAsync) - const config = getRateLimit(plan, counterType) - const storageKey = this.buildStorageKey(rateLimitKey, counterType) - - const result = await this.storage.consumeTokens(storageKey, 1, config) - - if (!result.allowed) { - logger.info('Rate limit exceeded', { - rateLimitKey, - counterType, - plan, - tokensRemaining: result.tokensRemaining, - }) - } - - return { - allowed: result.allowed, - remaining: result.tokensRemaining, - resetAt: result.resetAt, - retryAfterMs: result.retryAfterMs, - } + return await this.consumeWithSubscription(userId, subscription, triggerType, isAsync) } catch (error) { logger.error('Rate limit storage error - failing open (allowing request)', { error: toError(error).message, @@ -115,6 +123,20 @@ export class RateLimiter { } } + /** + * Consumes an authenticated request token and propagates storage failures. + * Security-sensitive adapters use this instead of the compatibility method + * above so an unavailable limiter cannot silently admit traffic. + */ + async checkRateLimitWithSubscriptionOrThrow( + subjectId: string, + subscription: SubscriptionInfo | null, + triggerType: TriggerType = 'manual', + isAsync = false + ): Promise { + return this.consumeWithSubscription(subjectId, subscription, triggerType, isAsync) + } + async getRateLimitStatusWithSubscription( userId: string, subscription: SubscriptionInfo | null, @@ -204,6 +226,27 @@ export class RateLimiter { } } + /** + * Consume one token from an already-namespaced bucket and propagate storage + * failures. Declarative API adapters use this for credential/workspace + * buckets so an unavailable limiter is never mistaken for spare capacity. + */ + async checkRateLimitDirectOrThrow( + storageKey: string, + config: { maxTokens: number; refillRate: number; refillIntervalMs: number } + ): Promise { + const result = await this.storage.consumeTokens(storageKey, 1, config) + if (!result.allowed) { + logger.info('Rate limit exceeded', { storageKey, tokensRemaining: result.tokensRemaining }) + } + return { + allowed: result.allowed, + remaining: result.tokensRemaining, + resetAt: result.resetAt, + retryAfterMs: result.retryAfterMs, + } + } + async resetRateLimit(rateLimitKey: string): Promise { try { await Promise.all([ diff --git a/apps/sim/lib/uploads/archive.test.ts b/apps/sim/lib/uploads/archive.test.ts index 2f9ad86e98d..d0da45fe10d 100644 --- a/apps/sim/lib/uploads/archive.test.ts +++ b/apps/sim/lib/uploads/archive.test.ts @@ -10,12 +10,20 @@ const { mockEnsureFolder, mockUpload, mockDelete } = vi.hoisted(() => ({ mockUpload: vi.fn(), mockDelete: vi.fn(), })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ - ensureWorkspaceFileFolderPath: mockEnsureFolder, +vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ + createWorkspaceFileFolderOperation: { + execute: mockEnsureFolder, + }, })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - uploadWorkspaceFile: mockUpload, - deleteWorkspaceFile: mockDelete, +vi.mock('@/lib/workspace-files/application/create-workspace-file', () => ({ + createWorkspaceFileFromBuffer: { + execute: mockUpload, + }, +})) +vi.mock('@/lib/workspace-files/application/delete-workspace-file', () => ({ + deleteWorkspaceFileOperation: { + execute: mockDelete, + }, })) import { @@ -25,6 +33,12 @@ import { MAX_ARCHIVE_ENTRY_BYTES, } from '@/lib/uploads/archive' +const TEST_PRINCIPAL = { + kind: 'session', + userId: 'u', + sessionId: 'session-1', +} as const + async function buildZip( files: Record, opts?: { symlinks?: string[] } @@ -71,16 +85,20 @@ function craftCentralDirectory(records: number, extraPerRecord: number): Buffer beforeEach(() => { vi.clearAllMocks() - mockEnsureFolder.mockResolvedValue('folder_1') + mockEnsureFolder.mockResolvedValue({ folder: { id: 'folder_1' } }) mockDelete.mockResolvedValue(undefined) - mockUpload.mockImplementation(async (_ws: string, _uid: string, buf: Buffer, name: string) => ({ - id: `f_${name}`, - name, - url: `/api/files/serve/${name}`, - key: `workspace/ws/${name}`, - size: buf.length, - type: 'text/plain', - })) + mockUpload.mockImplementation( + async ({ input }: { input: { content: Buffer; name: string } }) => ({ + file: { + id: `f_${input.name}`, + name: input.name, + url: `/api/files/serve/${input.name}`, + key: `workspace/ws/${input.name}`, + size: input.content.length, + type: 'text/plain', + }, + }) + ) }) describe('decompressArchiveBufferToWorkspaceFiles', () => { @@ -89,21 +107,21 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { const result = await decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', - userId: 'u', + principal: TEST_PRINCIPAL, rootFolderSegments: ['bundle'], }) expect(result.extracted).toHaveLength(2) expect(result.skippedUnsafePaths).toEqual([]) expect(mockUpload).toHaveBeenCalledTimes(2) - const leafNames = mockUpload.mock.calls.map((c) => c[3]).sort() + const leafNames = mockUpload.mock.calls.map(([args]) => args.input.name).sort() expect(leafNames).toEqual(['report.txt', 'sheet.csv']) // Entries are rooted under the archive's folder; nested paths are preserved. expect(mockEnsureFolder).toHaveBeenCalledWith( - expect.objectContaining({ pathSegments: ['bundle'] }) + expect.objectContaining({ input: { workspaceId: 'ws', path: 'bundle' } }) ) expect(mockEnsureFolder).toHaveBeenCalledWith( - expect.objectContaining({ pathSegments: ['bundle', 'data'] }) + expect.objectContaining({ input: { workspaceId: 'ws', path: 'bundle/data' } }) ) }) @@ -116,13 +134,13 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { await decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', - userId: 'u', + principal: TEST_PRINCIPAL, secretProvenance, }) expect(mockUpload).toHaveBeenCalledTimes(2) for (const call of mockUpload.mock.calls) { - expect(call[5]).toEqual(expect.objectContaining({ secretProvenance })) + expect(call[0].input).toEqual(expect.objectContaining({ secretProvenance })) } }) @@ -134,7 +152,10 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { const buffer = craftCentralDirectory(MAX_ARCHIVE_CENTRAL_DIR_RECORDS + 1, 0) await expect( - decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', userId: 'u' }) + decompressArchiveBufferToWorkspaceFiles(buffer, { + workspaceId: 'ws', + principal: TEST_PRINCIPAL, + }) ).rejects.toMatchObject({ name: 'ArchiveError', reason: 'central_dir_too_large', @@ -155,7 +176,10 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { const buffer = craftCentralDirectory(records, EXTRA_PER_RECORD) await expect( - decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', userId: 'u' }) + decompressArchiveBufferToWorkspaceFiles(buffer, { + workspaceId: 'ws', + principal: TEST_PRINCIPAL, + }) ).rejects.toMatchObject({ name: 'ArchiveError', reason: 'central_dir_too_large' }) expect(mockUpload).not.toHaveBeenCalled() }) @@ -175,7 +199,7 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { const result = await decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', - userId: 'u', + principal: TEST_PRINCIPAL, }) expect(result.extracted).toHaveLength(1) @@ -201,7 +225,10 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { buffer.fill(0xff, nameOffset + 'bad.bin'.length, nameOffset + 'bad.bin'.length + 256) await expect( - decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', userId: 'u' }) + decompressArchiveBufferToWorkspaceFiles(buffer, { + workspaceId: 'ws', + principal: TEST_PRINCIPAL, + }) ).rejects.toMatchObject({ name: 'ArchiveError', reason: 'invalid' }) expect(mockUpload).not.toHaveBeenCalled() }) @@ -212,17 +239,28 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { // must be deleted so callers and retries never observe a partial tree. const buffer = await buildZip({ 'a.txt': 'first', 'b.txt': 'second', 'c.txt': 'third' }) mockUpload - .mockResolvedValueOnce({ id: 'f_a', name: 'a.txt', url: '/a', key: 'k/a', size: 5 }) - .mockResolvedValueOnce({ id: 'f_b', name: 'b.txt', url: '/b', key: 'k/b', size: 6 }) + .mockResolvedValueOnce({ + file: { id: 'f_a', name: 'a.txt', url: '/a', key: 'k/a', size: 5 }, + }) + .mockResolvedValueOnce({ + file: { id: 'f_b', name: 'b.txt', url: '/b', key: 'k/b', size: 6 }, + }) .mockRejectedValueOnce(new Error('storage quota exceeded')) await expect( - decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', userId: 'u' }) + decompressArchiveBufferToWorkspaceFiles(buffer, { + workspaceId: 'ws', + principal: TEST_PRINCIPAL, + }) ).rejects.toThrow('storage quota exceeded') expect(mockDelete).toHaveBeenCalledTimes(2) - expect(mockDelete).toHaveBeenCalledWith('ws', 'f_a') - expect(mockDelete).toHaveBeenCalledWith('ws', 'f_b') + expect(mockDelete).toHaveBeenCalledWith( + expect.objectContaining({ input: { fileId: 'f_a', assertedWorkspaceId: 'ws' } }) + ) + expect(mockDelete).toHaveBeenCalledWith( + expect.objectContaining({ input: { fileId: 'f_b', assertedWorkspaceId: 'ws' } }) + ) }) it('does not count noise entries toward the extraction cap when they are being skipped', async () => { @@ -239,7 +277,7 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { const result = await decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', - userId: 'u', + principal: TEST_PRINCIPAL, skipNoiseEntries: true, }) @@ -251,7 +289,7 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { await expect( decompressArchiveBufferToWorkspaceFiles(Buffer.from('not a zip at all'), { workspaceId: 'ws', - userId: 'u', + principal: TEST_PRINCIPAL, }) ).rejects.toMatchObject({ name: 'ArchiveError', reason: 'invalid' }) expect(mockUpload).not.toHaveBeenCalled() @@ -269,7 +307,7 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { const result = await decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', - userId: 'u', + principal: TEST_PRINCIPAL, }) // Only the traversal entry counts toward `skipped`; the symlink is filtered @@ -279,7 +317,7 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { expect(result.skipped).toBe(1) expect(result.skippedUnsafePaths).toEqual(['..\\evil.txt']) expect(mockUpload).toHaveBeenCalledTimes(1) - expect(mockUpload.mock.calls[0][3]).toBe('safe.txt') + expect(mockUpload.mock.calls[0][0].input.name).toBe('safe.txt') }) it('extracts macOS/Windows filesystem-noise entries by default (skipNoiseEntries unset)', async () => { @@ -287,7 +325,7 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { const result = await decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', - userId: 'u', + principal: TEST_PRINCIPAL, }) // Parity with the HTTP decompress route, which extracts these verbatim. @@ -301,7 +339,7 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { const result = await decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', - userId: 'u', + principal: TEST_PRINCIPAL, skipNoiseEntries: true, }) @@ -320,7 +358,10 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { ) await expect( - decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', userId: 'u' }) + decompressArchiveBufferToWorkspaceFiles(buffer, { + workspaceId: 'ws', + principal: TEST_PRINCIPAL, + }) ).rejects.toMatchObject({ name: 'ArchiveError', reason: 'entry_too_large' }) expect(mockUpload).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/uploads/archive.ts b/apps/sim/lib/uploads/archive.ts index 89c118f41f7..856c1a10e40 100644 --- a/apps/sim/lib/uploads/archive.ts +++ b/apps/sim/lib/uploads/archive.ts @@ -1,14 +1,13 @@ import { Buffer } from 'buffer' import type { Readable } from 'stream' +import type { Principal } from '@sim/auth/principal' import JSZip from 'jszip' import { readZipCentralDirectoryStats } from '@/lib/file-parsers/zip-guard' -import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { - deleteWorkspaceFile, - uploadWorkspaceFile, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' +import { createWorkspaceFileFromBuffer } from '@/lib/workspace-files/application/create-workspace-file' +import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' +import { createWorkspaceFileFolderOperation } from '@/lib/workspace-files/application/workspace-file-folders' import type { UserFile } from '@/executor/types' /** @@ -261,7 +260,7 @@ export async function decompressArchiveBufferToWorkspaceFiles( buffer: Buffer, opts: { workspaceId: string - userId: string + principal: Principal rootFolderSegments?: string[] skipNoiseEntries?: boolean secretProvenance?: WorkspaceFileSecretProvenance @@ -269,7 +268,7 @@ export async function decompressArchiveBufferToWorkspaceFiles( ): Promise { const { workspaceId, - userId, + principal, rootFolderSegments = [], skipNoiseEntries = false, secretProvenance = { status: 'unknown' }, @@ -357,32 +356,50 @@ export async function decompressArchiveBufferToWorkspaceFiles( const folderKey = folderSegments.join('/') let folderId = folderIdCache.get(folderKey) if (folderId === undefined) { - folderId = await ensureWorkspaceFileFolderPath({ - workspaceId, - userId, - pathSegments: folderSegments, - }) + if (folderSegments.length === 0) { + folderId = null + } else { + const result = await createWorkspaceFileFolderOperation.execute({ + principal, + input: { workspaceId, path: folderSegments.join('/') }, + }) + folderId = result.folder.id + } folderIdCache.set(folderKey, folderId) } const mimeType = getMimeTypeFromExtension(getFileExtension(leafName)) - const uploaded = await uploadWorkspaceFile( - workspaceId, - userId, - entryBuffer, - leafName, - mimeType, - { - folderId, - secretProvenance, - } - ) - extracted.push(uploaded) + const uploaded = ( + await createWorkspaceFileFromBuffer.execute({ + principal, + input: { + workspaceId, + content: entryBuffer, + name: leafName, + contentType: mimeType, + folderId, + exactName: true, + secretProvenance, + }, + }) + ).file + extracted.push({ + id: uploaded.id, + name: uploaded.name, + url: uploaded.url ?? uploaded.path, + size: uploaded.size, + type: uploaded.type, + key: uploaded.key, + context: 'workspace', + }) } } catch (error) { for (const file of extracted) { try { - await deleteWorkspaceFile(workspaceId, file.id) + await deleteWorkspaceFileOperation.execute({ + principal, + input: { fileId: file.id, assertedWorkspaceId: workspaceId }, + }) } catch { // Best-effort: a file whose cleanup fails is still soft-deletable by hand; // the original error is what the caller needs to see. diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 8ca14de9e32..b0a18325452 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { folder as folderTable, workspaceFiles } from '@sim/db/schema' +import { folder as folderTable, workspaceFiles, workspace as workspaceTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -16,6 +16,7 @@ import { requireNonRootFolderPath, } from '@/lib/folders/paths' import { collectDescendantFolderIds } from '@/lib/folders/subtree' +import { MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS } from '@/lib/workspace-files/limits' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceFileFolders') @@ -72,6 +73,36 @@ export interface WorkspaceFileFolderRecord { updatedAt: Date } +export interface WorkspaceFileOperationContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string +} + +/** + * Loads the active workspace authorization context for folder and bulk-file operations. + * The workspace row is the canonical scope; callers must not authorize from a caller-supplied + * folder or file workspace id. + */ +export async function loadWorkspaceFileOperationContext( + workspaceId: string +): Promise { + const workspace = await getWorkspaceWithOwner(workspaceId) + if (!workspace) return null + const [settings] = await db + .select({ allowPersonalApiKeys: workspaceTable.allowPersonalApiKeys }) + .from(workspaceTable) + .where(eq(workspaceTable.id, workspaceId)) + .limit(1) + return { + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: settings?.allowPersonalApiKeys ?? false, + billedAccountUserId: workspace.billedAccountUserId, + } +} + interface RawWorkspaceFileFolder { id: string workspaceId: string @@ -89,6 +120,69 @@ export interface WorkspaceFileArchiveResult { files: number } +export interface WorkspaceFileBulkArchiveResult extends WorkspaceFileArchiveResult { + folderIds: string[] + fileIds: string[] +} + +function assertBulkAffectedItemsWithinLimit(count: number): void { + if (count > MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS) { + throw new OrchestrationError( + 'validation', + `File operation affects more than ${MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS} items` + ) + } +} + +/** + * Verifies every requested active file/folder belongs to this workspace before a bulk mutation. + * This prevents the bulk archive primitive's workspace predicate from silently turning an + * out-of-scope id into a successful zero-row operation. + */ +export async function assertWorkspaceFileItemsBelongToWorkspace(params: { + workspaceId: string + fileIds?: string[] + folderIds?: string[] +}): Promise { + const fileIds = Array.from(new Set(params.fileIds ?? [])) + const folderIds = Array.from(new Set(params.folderIds ?? [])) + const [files, folders] = await Promise.all([ + fileIds.length === 0 + ? Promise.resolve([]) + : db + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + inArray(workspaceFiles.id, fileIds), + eq(workspaceFiles.workspaceId, params.workspaceId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) + ) + ), + folderIds.length === 0 + ? Promise.resolve([]) + : db + .select({ id: folderTable.id }) + .from(folderTable) + .where( + and( + inArray(folderTable.id, folderIds), + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) + ) + ), + ]) + const foundFiles = new Set(files.map((file) => file.id)) + const foundFolders = new Set(folders.map((folder) => folder.id)) + const missingFiles = fileIds.filter((id) => !foundFiles.has(id)) + const missingFolders = folderIds.filter((id) => !foundFolders.has(id)) + if (missingFiles.length > 0 || missingFolders.length > 0) { + throw new WorkspaceFileItemsNotFoundError(missingFiles, missingFolders) + } +} + export interface WorkspaceFileFolderRestoreResult { folder: WorkspaceFileFolderRecord restoredItems: WorkspaceFileArchiveResult @@ -717,7 +811,12 @@ export async function moveWorkspaceFileItems(params: { folderIds?: string[] targetFolderId?: string | null targetFolderPath?: string -}): Promise<{ movedFiles: number; movedFolders: number }> { +}): Promise<{ + movedFiles: number + movedFolders: number + movedFileIds: string[] + movedFolderIds: string[] +}> { const fileIds = Array.from(new Set(params.fileIds ?? [])) const folderIds = Array.from(new Set(params.folderIds ?? [])) if (params.targetFolderId !== undefined && params.targetFolderPath !== undefined) { @@ -777,9 +876,16 @@ export async function moveWorkspaceFileItems(params: { isNull(folderTable.deletedAt) ) ) + .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) + + assertBulkAffectedItemsWithinLimit(activeFolders.length) + + const affectedFolderIds = new Set() for (const folderId of folderIds) { const descendants = collectDescendantFolderIds(activeFolders, folderId) + affectedFolderIds.add(folderId) + for (const descendantId of descendants) affectedFolderIds.add(descendantId) if (targetFolderId && descendants.includes(targetFolderId)) { throw new OrchestrationError( 'validation', @@ -787,6 +893,24 @@ export async function moveWorkspaceFileItems(params: { ) } } + + assertBulkAffectedItemsWithinLimit(affectedFolderIds.size + fileIds.length) + if (affectedFolderIds.size > 0) { + const descendantFiles = await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + inArray(workspaceFiles.folderId, [...affectedFolderIds]), + eq(workspaceFiles.workspaceId, params.workspaceId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) + ) + ) + .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) + const affectedFileIds = new Set([...fileIds, ...descendantFiles.map((file) => file.id)]) + assertBulkAffectedItemsWithinLimit(affectedFolderIds.size + affectedFileIds.size) + } } const movingFiles = @@ -909,7 +1033,12 @@ export async function moveWorkspaceFileItems(params: { .returning({ id: folderTable.id }) : [] - return { movedFiles: movedFiles.length, movedFolders: movedFolders.length } + return { + movedFiles: movedFiles.length, + movedFolders: movedFolders.length, + movedFileIds: movedFiles.map((file) => file.id), + movedFolderIds: movedFolders.map((folder) => folder.id), + } }) } @@ -943,7 +1072,24 @@ export async function archiveWorkspaceFileFolderRecursive( .where( and(eq(folderTable.workspaceId, workspaceId), isFileFolder, isNull(folderTable.deletedAt)) ) + .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) + assertBulkAffectedItemsWithinLimit(activeFolders.length) const folderIds = [folderId, ...collectDescendantFolderIds(activeFolders, folderId)] + assertBulkAffectedItemsWithinLimit(folderIds.length) + + const affectedFiles = await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + inArray(workspaceFiles.folderId, folderIds), + eq(workspaceFiles.workspaceId, workspaceId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) + ) + ) + .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) + assertBulkAffectedItemsWithinLimit(folderIds.length + affectedFiles.length) const archivedFiles = await tx .update(workspaceFiles) @@ -1069,6 +1215,7 @@ export async function restoreWorkspaceFileFolder( ) .returning({ id: workspaceFiles.id }) stats.files += restoredFiles.length + assertBulkAffectedItemsWithinLimit(stats.files + stats.folders) const archivedChildren = await tx .select({ id: folderTable.id }) @@ -1081,6 +1228,8 @@ export async function restoreWorkspaceFileFolder( eq(folderTable.deletedAt, folderDeletedAt) ) ) + .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) + assertBulkAffectedItemsWithinLimit(stats.files + stats.folders + archivedChildren.length) for (const child of archivedChildren) { const [restoredChild] = await tx @@ -1098,6 +1247,7 @@ export async function restoreWorkspaceFileFolder( if (!restoredChild) continue stats.folders += 1 + assertBulkAffectedItemsWithinLimit(stats.files + stats.folders) await restoreFolderSubtree(child.id) } } @@ -1116,6 +1266,7 @@ export async function restoreWorkspaceFileFolder( .returning() stats.folders += 1 + assertBulkAffectedItemsWithinLimit(stats.files + stats.folders) await restoreFolderSubtree(folderId) return { restored: row, restoredItems: stats } @@ -1140,7 +1291,7 @@ export async function bulkArchiveWorkspaceFileItems(params: { workspaceId: string fileIds?: string[] folderIds?: string[] -}): Promise { +}): Promise { const now = new Date() const explicitFileIds = Array.from(new Set(params.fileIds ?? [])) const explicitFolderIds = Array.from(new Set(params.folderIds ?? [])) @@ -1160,11 +1311,32 @@ export async function bulkArchiveWorkspaceFileItems(params: { isNull(folderTable.deletedAt) ) ) + .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) : [] + assertBulkAffectedItemsWithinLimit(activeFolders.length) const descendantFolderIds = explicitFolderIds.flatMap((folderId) => collectDescendantFolderIds(activeFolders, folderId) ) const allFolderIds = Array.from(new Set([...explicitFolderIds, ...descendantFolderIds])) + assertBulkAffectedItemsWithinLimit(allFolderIds.length + explicitFileIds.length) + + const descendantFiles = + allFolderIds.length > 0 + ? await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + inArray(workspaceFiles.folderId, allFolderIds), + eq(workspaceFiles.workspaceId, params.workspaceId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) + ) + ) + .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) + : [] + const affectedFileIds = new Set([...explicitFileIds, ...descendantFiles.map((file) => file.id)]) + assertBulkAffectedItemsWithinLimit(allFolderIds.length + affectedFileIds.size) const archivedExplicitFiles = explicitFileIds.length > 0 @@ -1214,10 +1386,15 @@ export async function bulkArchiveWorkspaceFileItems(params: { .returning({ id: folderTable.id }) : [] + const archivedFileIds = Array.from( + new Set([...archivedExplicitFiles, ...archivedDescendantFiles].map((file) => file.id)) + ) + const archivedFolderIds = archivedFolders.map((folder) => folder.id) return { - folders: archivedFolders.length, - files: new Set([...archivedExplicitFiles, ...archivedDescendantFiles].map((file) => file.id)) - .size, + folders: archivedFolderIds.length, + files: archivedFileIds.length, + folderIds: archivedFolderIds, + fileIds: archivedFileIds, } }) } diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts index 1f51abd4b37..04300f1765a 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts @@ -3,7 +3,7 @@ */ import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { listWorkspaceFiles } from './workspace-file-manager' +import { listWorkspaceFiles, loadActiveWorkspaceFileContext } from './workspace-file-manager' afterAll(resetDbChainMock) @@ -24,3 +24,35 @@ describe('listWorkspaceFiles error handling', () => { ) }) }) + +describe('loadActiveWorkspaceFileContext', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('returns the canonical workspace authorization context', async () => { + const context = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + } + dbChainMockFns.limit.mockResolvedValueOnce([context]) + + await expect(loadActiveWorkspaceFileContext('file-1')).resolves.toEqual(context) + }) + + it('returns null when the active file does not exist', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + await expect(loadActiveWorkspaceFileContext('missing-file')).resolves.toBeNull() + }) + + it('propagates database failures', async () => { + dbChainMockFns.limit.mockRejectedValueOnce(new Error('database unavailable')) + + await expect(loadActiveWorkspaceFileContext('file-1')).rejects.toThrow('database unavailable') + }) +}) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 3cb689a7af2..bd5075549cd 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -5,7 +5,7 @@ import { randomBytes } from 'crypto' import { db } from '@sim/db' -import { workspaceFiles } from '@sim/db/schema' +import { uploadSession, workspace, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { describeError, @@ -135,6 +135,25 @@ export interface UploadedWorkspaceFileRecord extends WorkspaceFileRecord { deletedAt: Date | null } +export interface ActiveWorkspaceFileContext { + fileId: string + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string +} + +export interface WorkspaceFileLifecycleContext extends ActiveWorkspaceFileContext { + deletedAt: Date | null +} + +export interface ActiveWorkspaceContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string +} + interface ListWorkspaceFilesOptions { scope?: WorkspaceFileScope folders?: WorkspaceFileFolderRecord[] @@ -551,6 +570,7 @@ export async function registerUploadedWorkspaceFile(params: { originalName: string contentType: string folderId?: string | null + uploadSessionId?: string }): Promise { const { workspaceId, userId, key, originalName, contentType } = params const normalizedOriginalName = normalizeWorkspaceFileItemName(originalName, 'File') @@ -592,6 +612,7 @@ export async function registerUploadedWorkspaceFile(params: { EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE ) } + await markUploadSessionFileRegistered(tx, params.uploadSessionId, workspaceId, found.id) return found }) if (existing) { @@ -650,6 +671,12 @@ export async function registerUploadedWorkspaceFile(params: { EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE ) } + await markUploadSessionFileRegistered( + tx, + params.uploadSessionId, + workspaceId, + raceWinner.id + ) return { kind: 'existing', file: raceWinner } as const } @@ -664,6 +691,7 @@ export async function registerUploadedWorkspaceFile(params: { inserted.contentUpdatedAt, EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE ) + await markUploadSessionFileRegistered(tx, params.uploadSessionId, workspaceId, inserted.id) return { kind: 'created', file: inserted, updatedUsage } as const }) @@ -696,6 +724,29 @@ export async function registerUploadedWorkspaceFile(params: { throw new FileConflictError(normalizedOriginalName) } +async function markUploadSessionFileRegistered( + tx: DbOrTx, + uploadSessionId: string | undefined, + workspaceId: string, + fileId: string +): Promise { + if (!uploadSessionId) return + const [marked] = await tx + .update(uploadSession) + .set({ completedFileId: fileId, updatedAt: new Date() }) + .where( + and( + eq(uploadSession.id, uploadSessionId), + eq(uploadSession.workspaceId, workspaceId), + eq(uploadSession.purpose, 'workspace_file'), + eq(uploadSession.status, 'finalizing'), + or(isNull(uploadSession.completedFileId), eq(uploadSession.completedFileId, fileId)) + ) + ) + .returning({ id: uploadSession.id }) + if (!marked) throw new Error('Workspace upload registration marker could not be persisted') +} + function assertActiveWorkspaceFileRegistration(file: typeof workspaceFiles.$inferSelect): void { if (file.deletedAt) { throw new OrchestrationError('conflict', 'Upload result was deleted') @@ -1325,7 +1376,7 @@ export async function resolveWorkspaceFileReference( ): Promise { const normalizedReference = normalizeWorkspaceFileReference(fileReference) if (normalizedReference.startsWith('wf_')) { - const file = await getWorkspaceFile(workspaceId, normalizedReference) + const file = await getWorkspaceFile(workspaceId, normalizedReference, { throwOnError: true }) if (file) return file } @@ -1339,6 +1390,85 @@ export async function resolveWorkspaceFileReference( return findWorkspaceFileRecord(files, fileReference) } +/** + * Load the canonical authorization context for an active workspace file by resource ID. + * Database failures propagate so callers never confuse unavailable state with a missing file. + */ +export async function loadActiveWorkspaceFileContext( + fileId: string, + options?: { includeDeleted?: boolean } +): Promise { + const [context] = await db + .select({ + fileId: workspaceFiles.id, + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + billedAccountUserId: workspace.billedAccountUserId, + }) + .from(workspaceFiles) + .innerJoin(workspace, eq(workspaceFiles.workspaceId, workspace.id)) + .where( + and( + eq(workspaceFiles.id, fileId), + eq(workspaceFiles.context, 'workspace'), + ...(options?.includeDeleted ? [] : [isNull(workspaceFiles.deletedAt)]), + isNull(workspace.archivedAt) + ) + ) + .limit(1) + + return context ?? null +} + +/** + * Load a workspace file for a lifecycle transition, including archived files. + * The workspace archive state is returned by the canonical workspace record and is enforced by + * the operation's manager primitive where the transition requires an active workspace. + */ +export async function loadWorkspaceFileLifecycleContext( + fileId: string +): Promise { + const [context] = await db + .select({ + fileId: workspaceFiles.id, + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + billedAccountUserId: workspace.billedAccountUserId, + deletedAt: workspaceFiles.deletedAt, + }) + .from(workspaceFiles) + .innerJoin(workspace, eq(workspaceFiles.workspaceId, workspace.id)) + .where(and(eq(workspaceFiles.id, fileId), eq(workspaceFiles.context, 'workspace'))) + .limit(1) + + return context ?? null +} + +/** + * Load the canonical authorization context for an active workspace. + * + * The query deliberately throws database failures so callers cannot mistake an unavailable + * workspace for a missing one. Authentication and authorization remain the caller's concern. + */ +export async function loadActiveWorkspaceContext( + workspaceId: string +): Promise { + const [context] = await db + .select({ + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + billedAccountUserId: workspace.billedAccountUserId, + }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + + return context ?? null +} + /** * Get a specific workspace file. * @@ -1732,7 +1862,7 @@ export async function renameWorkspaceFile( const trimmedName = newName.trim() const normalizedName = normalizeWorkspaceFileItemName(trimmedName, 'File') - const fileRecord = await getWorkspaceFile(workspaceId, fileId) + const fileRecord = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) if (!fileRecord) { throw new OrchestrationError('not_found', 'File not found') } @@ -1881,7 +2011,7 @@ export async function deleteWorkspaceFile(workspaceId: string, fileId: string): logger.info(`Successfully archived workspace file: ${archived.originalName}`) } catch (error) { logger.error(`Failed to delete workspace file ${fileId}:`, error) - throw new Error(`Failed to delete file: ${getErrorMessage(error, 'Unknown error')}`) + throw error } } diff --git a/apps/sim/lib/uploads/upload-session/application.test.ts b/apps/sim/lib/uploads/upload-session/application.test.ts new file mode 100644 index 00000000000..bdcde448e22 --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/application.test.ts @@ -0,0 +1,117 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + assertAuthBinding: vi.fn(), + completeSession: vi.fn(), + finalizePurpose: vi.fn(), + getOwnedSession: vi.fn(), + reauthorizeWorkspacePurpose: vi.fn(), +})) + +vi.mock('@/lib/uploads/upload-session/service', () => ({ + abortUploadSession: vi.fn(), + assertUploadSessionAuthBinding: mocks.assertAuthBinding, + completeUploadSession: mocks.completeSession, + createUploadPartUrls: vi.fn(), + createUploadSession: vi.fn(), + getOwnedUploadSession: mocks.getOwnedSession, + getPrincipalUploadSession: vi.fn(), +})) + +vi.mock('@/app/api/files/uploads/finalizers', () => ({ + finalizeUploadPurpose: mocks.finalizePurpose, + finalizeWorkspaceFileUpload: vi.fn(), + loadCompletedUploadPurpose: vi.fn(), + loadCompletedWorkspaceFileUpload: vi.fn(), +})) + +vi.mock('@/app/api/files/uploads/purposes', () => ({ + createPurposeUploadSession: vi.fn(), + reauthorizeUploadPurpose: vi.fn(), + reauthorizeWorkspaceUploadPurpose: mocks.reauthorizeWorkspacePurpose, + resolveUploadAttributionUserId: vi.fn(), +})) + +import { completeInternalUploadSession } from '@/lib/uploads/upload-session/application' +import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service' + +const principal = { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', +} +const actor = { id: 'user-1', name: 'Ada', email: 'ada@example.com' } + +describe('upload session application', () => { + beforeEach(() => { + vi.clearAllMocks() + const session = workspaceUploadSession() + mocks.getOwnedSession.mockResolvedValue(session) + mocks.finalizePurpose.mockResolvedValue({ + value: { id: 'file-1' }, + completedFileId: 'file-1', + }) + mocks.completeSession.mockImplementation(async ({ session: claimed, finalize }) => { + const finalized = await finalize(claimed) + return { + session: { ...claimed, status: 'completed', completedFileId: finalized.completedFileId }, + value: finalized.value, + alreadyCompleted: false, + } + }) + }) + + it('preserves the authenticated actor metadata through internal finalization', async () => { + const request = new NextRequest('http://localhost/api/files/uploads/upload-1/complete', { + method: 'POST', + }) + + await completeInternalUploadSession( + principal, + { uploadId: 'upload-1', uploadToken: 'upload-token', actor }, + request + ) + + expect(mocks.finalizePurpose).toHaveBeenCalledWith( + expect.objectContaining({ actor, principal, request }) + ) + }) +}) + +function workspaceUploadSession(): UploadSessionRecord { + const now = new Date('2026-08-08T00:00:00.000Z') + return { + id: 'upload-1', + workspaceId: 'workspace-1', + userId: principal.userId, + knowledgeBaseId: null, + workflowId: null, + executionId: null, + purpose: 'workspace_file', + method: 'put', + storageContext: 'workspace', + storageKey: 'workspace/workspace-1/file.txt', + finalKey: 'workspace/workspace-1/file.txt', + storageProvider: 's3', + providerUploadId: null, + providerObjectVersion: 'version-1', + fileName: 'file.txt', + contentType: 'text/plain', + fileSize: 4, + partSize: null, + partCount: null, + status: 'finalizing', + metadata: {}, + uploadToken: 'upload-token', + createdAt: now, + expiresAt: new Date(now.getTime() + 24 * 60 * 60 * 1000), + completedFileId: null, + error: null, + completedAt: null, + updatedAt: now, + } +} diff --git a/apps/sim/lib/uploads/upload-session/application.ts b/apps/sim/lib/uploads/upload-session/application.ts new file mode 100644 index 00000000000..e423b79dd85 --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/application.ts @@ -0,0 +1,350 @@ +import type { Principal } from '@sim/auth/principal' +import type { CreateInternalFileUploadBody } from '@/lib/api/contracts/upload-sessions' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { loadActiveFolderPathIndex, resolveFolderPathFromIndex } from '@/lib/folders/queries' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { + abortUploadSession, + assertUploadSessionAuthBinding, + completeUploadSession, + createUploadPartUrls, + createUploadSession, + getOwnedUploadSession, + getPrincipalUploadSession, + type UploadSessionRecord, + type UploadSessionTransfer, +} from '@/lib/uploads/upload-session/service' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { authorizeWorkspaceFileOperation } from '@/lib/workspace-files/application/workspace-operation-context' +import { + finalizeUploadPurpose, + finalizeWorkspaceFileUpload, + loadCompletedUploadPurpose, + loadCompletedWorkspaceFileUpload, + type UploadActor, +} from '@/app/api/files/uploads/finalizers' +import { + createPurposeUploadSession, + reauthorizeUploadPurpose, + reauthorizeWorkspaceUploadPurpose, + resolveUploadAttributionUserId, +} from '@/app/api/files/uploads/purposes' + +export interface WorkspaceFileUploadCreateInput { + workspaceId: string + name: string + contentType: string + size: number + folderId?: string | null + localOrigin: string +} + +export interface UploadSessionControlInput { + uploadId: string + uploadToken: string + workspaceId?: string + localOrigin?: string + partNumbers?: number[] + actor?: UploadActor +} + +export interface InternalUploadSessionControlInput extends UploadSessionControlInput { + partNumbers?: number[] +} + +export interface UploadSessionCreateResult { + session: Awaited> +} + +/** Creates a workspace-file session after current principal authorization. */ +export async function createWorkspaceFileUploadSession( + principal: Principal, + input: WorkspaceFileUploadCreateInput +): Promise>> { + const userId = await resolveUploadAttributionUserId(principal, input.workspaceId) + return createUploadSession({ + purpose: 'workspace_file', + workspaceId: input.workspaceId, + userId, + principal, + fileName: input.name, + contentType: input.contentType, + fileSize: input.size, + metadata: { folderId: input.folderId ?? null }, + localOrigin: input.localOrigin, + }) +} + +/** Internal purpose-aware create use case; authentication remains at the route adapter. */ +export async function createInternalPurposeUploadSession( + principal: Principal, + body: CreateInternalFileUploadBody, + request: OrchestrationRequestContext +): Promise>> { + return createPurposeUploadSession(principal, body, requestOrigin(request)) +} + +/** Loads an internal session while binding workspace-file sessions to the principal. */ +export async function loadAuthorizedInternalUploadSession( + principal: Principal, + input: UploadSessionControlInput +): Promise { + if (input.workspaceId !== undefined) return loadAuthorizedWorkspaceUploadSession(principal, input) + const session = await getOwnedUploadSession({ + uploadId: input.uploadId, + uploadToken: input.uploadToken, + userId: principalUserId(principal), + }) + if (session.purpose === 'workspace_file') assertUploadSessionAuthBinding(session, principal) + return session +} + +export async function issueInternalUploadPartUrls( + principal: Principal, + input: InternalUploadSessionControlInput, + request: OrchestrationRequestContext +): Promise<{ parts: Awaited> }> { + const session = await loadAuthorizedInternalUploadSession(principal, input) + if (session.purpose === 'workspace_file') { + await reauthorizeWorkspaceUploadPurpose(principal, session, fileOperations.uploadParts) + } else { + await reauthorizeUploadPurpose(principalUserId(principal), session) + } + return { + parts: await createUploadPartUrls({ + session, + partNumbers: input.partNumbers ?? [], + localOrigin: requestOrigin(request), + }), + } +} + +export async function abortInternalUploadSession( + principal: Principal, + input: UploadSessionControlInput +): Promise { + const session = await loadAuthorizedInternalUploadSession(principal, input) + if (session.purpose === 'workspace_file') { + await reauthorizeWorkspaceUploadPurpose(principal, session, fileOperations.uploadCancel) + } else { + await reauthorizeUploadPurpose(principalUserId(principal), session) + } + return abortUploadSession(session) +} + +export async function completeInternalUploadSession( + principal: Principal, + input: UploadSessionControlInput, + request: OrchestrationRequestContext +): Promise<{ + session: UploadSessionRecord + value: import('@/app/api/files/uploads/finalizers').UploadPurposeResult + alreadyCompleted: boolean +}> { + const session = await loadAuthorizedInternalUploadSession(principal, input) + const authorize = async (claimed: UploadSessionRecord) => { + if (claimed.purpose === 'workspace_file') { + await reauthorizeWorkspaceUploadPurpose(principal, claimed, fileOperations.uploadComplete) + } else { + await reauthorizeUploadPurpose(principalUserId(principal), claimed) + } + } + await authorize(session) + const actor: UploadActor = input.actor ?? { id: principalUserId(principal) } + return completeUploadSession({ + session, + loadCompleted: async (claimed) => { + await authorize(claimed) + return loadCompletedUploadPurpose(claimed) + }, + finalize: async (claimed) => { + await authorize(claimed) + const finalized = await finalizeUploadPurpose({ + session: claimed, + actor, + request, + principal, + authorizeBeforeRegistration: () => authorize(claimed), + }) + return { value: finalized.value, completedFileId: finalized.completedFileId } + }, + }) +} + +/** Loads and binds a workspace-file session to the fresh authenticated principal. */ +export async function loadAuthorizedWorkspaceUploadSession( + principal: Principal, + input: UploadSessionControlInput +): Promise { + return getPrincipalUploadSession({ + uploadId: input.uploadId, + uploadToken: input.uploadToken, + principal, + workspaceId: input.workspaceId, + }) +} + +/** Issues multipart URLs after current workspace authorization. */ +export async function issueWorkspaceUploadPartUrls( + principal: Principal, + input: UploadSessionControlInput +): Promise<{ parts: Awaited> }> { + const session = await loadAuthorizedWorkspaceUploadSession(principal, input) + await reauthorizeWorkspaceUploadPurpose(principal, session, fileOperations.uploadParts) + if (!input.localOrigin) throw new Error('Upload part URL issuance requires a local origin') + const parts = await createUploadPartUrls({ + session, + partNumbers: input.partNumbers ?? [], + localOrigin: input.localOrigin, + }) + return { parts } +} + +/** Aborts an upload after current workspace authorization. */ +export async function abortWorkspaceUploadSession( + principal: Principal, + input: UploadSessionControlInput +): Promise { + const session = await loadAuthorizedWorkspaceUploadSession(principal, input) + await reauthorizeWorkspaceUploadPurpose(principal, session, fileOperations.uploadCancel) + return abortUploadSession(session) +} + +/** + * Completes an upload and re-checks authorization immediately before durable + * workspace-file registration. Completed retries use the durable session/file + * result through the finalizer rather than registering a second file. + */ +export async function completeWorkspaceUploadSession( + principal: Principal, + input: UploadSessionControlInput, + request: OrchestrationRequestContext +): Promise<{ + session: UploadSessionRecord + value: WorkspaceFileRecord + alreadyCompleted: boolean +}> { + const session = await loadAuthorizedWorkspaceUploadSession(principal, input) + await reauthorizeWorkspaceUploadPurpose(principal, session, fileOperations.uploadComplete) + const actor: UploadActor = input.actor ?? { + id: await resolveUploadAttributionUserId(principal, session.workspaceId ?? ''), + } + return completeUploadSession({ + session, + loadCompleted: async (claimed) => { + await reauthorizeWorkspaceUploadPurpose(principal, claimed, fileOperations.uploadComplete) + return loadCompletedWorkspaceFileUpload(claimed) + }, + finalize: async (claimed) => { + await reauthorizeWorkspaceUploadPurpose(principal, claimed, fileOperations.uploadComplete) + const finalized = await finalizeWorkspaceFileUpload({ + session: claimed, + actor, + request, + source: 'api', + principal, + authorizeBeforeRegistration: async () => { + await reauthorizeWorkspaceUploadPurpose(principal, claimed, fileOperations.uploadComplete) + }, + }) + return { value: finalized.file, completedFileId: finalized.file.id } + }, + }) +} + +export interface CreateWorkspaceFileUploadOperationInput { + workspaceId: string + name: string + contentType: string + size: number + folderPath: string +} + +export const createWorkspaceFileUploadOperation = { + operation: fileOperations.uploadCreate, + async execute({ + principal, + input, + request, + }: { + principal: Principal + input: CreateWorkspaceFileUploadOperationInput + request?: OrchestrationRequestContext + }) { + if (!request) throw new Error('Workspace upload creation requires a request context') + await authorizeWorkspaceFileOperation(principal, fileOperations.uploadCreate, input.workspaceId) + const folderIndex = await loadActiveFolderPathIndex(input.workspaceId, 'file') + const folderId = resolveFolderPathFromIndex(folderIndex, input.folderPath) + if (folderId === undefined) throw new OrchestrationError('not_found', 'Folder not found') + return createWorkspaceFileUploadSession(principal, { + workspaceId: input.workspaceId, + name: input.name, + contentType: input.contentType, + size: input.size, + folderId, + localOrigin: requestOrigin(request), + }) + }, +} as const + +export const issueWorkspaceFileUploadPartsOperation = { + operation: fileOperations.uploadParts, + async execute({ + principal, + input, + request, + }: { + principal: Principal + input: UploadSessionControlInput + request?: OrchestrationRequestContext + }) { + if (!request) throw new Error('Upload part URL issuance requires a request context') + return issueWorkspaceUploadPartUrls(principal, { + ...input, + localOrigin: requestOrigin(request), + }) + }, +} as const + +export const completeWorkspaceFileUploadOperation = { + operation: fileOperations.uploadComplete, + async execute({ + principal, + input, + request, + }: { + principal: Principal + input: UploadSessionControlInput + request?: OrchestrationRequestContext + }) { + if (!request) throw new Error('Upload completion requires a request context') + return completeWorkspaceUploadSession(principal, input, request) + }, +} as const + +export const abortWorkspaceFileUploadOperation = { + operation: fileOperations.uploadCancel, + async execute({ principal, input }: { principal: Principal; input: UploadSessionControlInput }) { + return abortWorkspaceUploadSession(principal, input) + }, +} as const + +function principalUserId(principal: Principal): string { + if (principal.kind === 'session' || principal.kind === 'personal_api_key') { + return principal.userId + } + throw new Error('Workspace upload attribution must be resolved from the current workspace owner') +} + +export function requestOrigin(request: OrchestrationRequestContext & { nextUrl?: URL }): string { + if (request.nextUrl instanceof URL) return request.nextUrl.origin + const origin = request.headers.get('origin') + if (origin) return origin + const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') + const protocol = request.headers.get('x-forwarded-proto') ?? 'https' + if (!host) throw new Error('Upload signing requires a request origin') + return `${protocol}://${host}` +} + +export type { UploadSessionTransfer } diff --git a/apps/sim/lib/uploads/upload-session/service.test.ts b/apps/sim/lib/uploads/upload-session/service.test.ts index 1e7b16256c6..ea5901633c2 100644 --- a/apps/sim/lib/uploads/upload-session/service.test.ts +++ b/apps/sim/lib/uploads/upload-session/service.test.ts @@ -3,7 +3,7 @@ */ import { sha256Hex } from '@sim/security/hash' import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' -import { inArray } from 'drizzle-orm' +import { eq, inArray, isNull } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -57,6 +57,7 @@ vi.mock('@/lib/uploads/upload-session/provider', () => ({ import { abortUploadSession, + assertUploadSessionAuthBinding, cleanupExpiredUploadSessions, completeUploadSession, createUploadSession, @@ -110,6 +111,85 @@ describe('upload sessions', () => { expect(inserted.tokenHash).toBe(sha256Hex(created.uploadToken)) expect(inserted.tokenHash).not.toBe(created.uploadToken) expect(inserted.finalKey).toBe(FINAL_KEY) + expect(inserted.metadata.authBinding).toEqual({ + version: 1, + workspaceId: WORKSPACE_ID, + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + }) + }) + + it('rejects workspace control access without the matching immutable credential binding', async () => { + const row = uploadRow({ + metadata: { + authBinding: { + version: 1, + workspaceId: WORKSPACE_ID, + principal: { kind: 'workspace_api_key', workspaceId: WORKSPACE_ID, keyId: 'key-1' }, + }, + }, + }) + queueTableRows(schemaMock.uploadSession, [row]) + + await expect( + getOwnedUploadSession({ + uploadId: row.id, + uploadToken: 'upload-secret', + principal: { kind: 'workspace_api_key', workspaceId: WORKSPACE_ID, keyId: 'key-2' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + it('preserves legacy unbound sessions under their prior ownership rules', () => { + const legacy = sessionRecord({ metadata: {} }) + + expect(() => + assertUploadSessionAuthBinding(legacy, { + kind: 'session', + userId: legacy.userId, + sessionId: 'current-session', + }) + ).not.toThrow() + expect(() => + assertUploadSessionAuthBinding(legacy, { + kind: 'personal_api_key', + userId: legacy.userId, + keyId: 'current-key', + }) + ).not.toThrow() + expect(() => + assertUploadSessionAuthBinding(legacy, { + kind: 'workspace_api_key', + workspaceId: WORKSPACE_ID, + keyId: 'current-workspace-key', + }) + ).not.toThrow() + + expect(() => + assertUploadSessionAuthBinding(legacy, { + kind: 'session', + userId: 'different-user', + sessionId: 'current-session', + }) + ).toThrow('Upload session not found') + expect(() => + assertUploadSessionAuthBinding(legacy, { + kind: 'workspace_api_key', + workspaceId: 'different-workspace', + keyId: 'current-workspace-key', + }) + ).toThrow('Upload session not found') + }) + + it('never treats a malformed credential binding as a legacy session', () => { + const malformed = sessionRecord({ metadata: { authBinding: { version: 1 } } }) + + expect(() => + assertUploadSessionAuthBinding(malformed, { + kind: 'session', + userId: malformed.userId, + sessionId: 'current-session', + }) + ).toThrow('Upload session not found') }) it('initiates multipart storage directly at the final key', async () => { @@ -248,6 +328,52 @@ describe('upload sessions', () => { ).resolves.toMatchObject({ value: 'recovered', alreadyCompleted: true }) }) + it('loads a durable finalizing result without re-running the finalizer', async () => { + const session = sessionRecord({ + status: 'finalizing', + expiresAt: new Date(Date.now() - 1), + providerObjectVersion: 'version-1', + completedFileId: 'file-1', + }) + dbChainMockFns.returning + .mockResolvedValueOnce([ + uploadRow({ + ...rowGeometry(session), + status: 'finalizing', + completedFileId: 'file-1', + }), + ]) + .mockResolvedValueOnce([ + uploadRow({ + ...rowGeometry(session), + status: 'completed', + completedFileId: 'file-1', + completedAt: new Date(), + }), + ]) + const finalize = vi.fn() + const loadCompleted = vi.fn().mockResolvedValue('recovered-file') + + await expect( + completeUploadSession({ session, finalize, loadCompleted }) + ).resolves.toMatchObject({ value: 'recovered-file', alreadyCompleted: true }) + expect(loadCompleted).toHaveBeenCalledOnce() + expect(finalize).not.toHaveBeenCalled() + expect(mockHeadObject).not.toHaveBeenCalled() + }) + + it('loads an already-completed durable result without re-running the finalizer', async () => { + const session = sessionRecord({ status: 'completed', completedFileId: 'file-1' }) + const finalize = vi.fn() + const loadCompleted = vi.fn().mockResolvedValue('completed-file') + + await expect( + completeUploadSession({ session, finalize, loadCompleted }) + ).resolves.toMatchObject({ value: 'completed-file', alreadyCompleted: true }) + expect(loadCompleted).toHaveBeenCalledOnce() + expect(finalize).not.toHaveBeenCalled() + }) + it('deletes a matching completed provider object without aborting its consumed upload id', async () => { const session = sessionRecord({ method: 'multipart', @@ -295,17 +421,47 @@ describe('upload sessions', () => { expect(mockDeleteObjectVersion).not.toHaveBeenCalled() }) - it('refuses to abort once domain finalization may have created a resource', async () => { - const session = sessionRecord({ status: 'finalizing' }) + it('refuses to abort once domain finalization has registered a resource', async () => { + const session = sessionRecord({ status: 'finalizing', completedFileId: 'file-1' }) await expect(abortUploadSession(session)).rejects.toThrow( - 'Finalizing upload sessions cannot be aborted' + 'Finalizing upload sessions with a registered file cannot be aborted' ) expect(mockAbortProviderUpload).not.toHaveBeenCalled() expect(mockDeleteObjectVersion).not.toHaveBeenCalled() }) - it('cleans expired upload state without selecting sessions that may be finalizing', async () => { + it('aborts a finalizing session whose durable registration never committed', async () => { + const session = sessionRecord({ + status: 'finalizing', + providerObjectVersion: 'version-1', + completedFileId: null, + }) + mockHeadObject.mockResolvedValue(providerObject(session, 'version-1')) + dbChainMockFns.returning + .mockResolvedValueOnce([ + uploadRow({ + ...rowGeometry(session), + status: 'aborting', + providerObjectVersion: 'version-1', + }), + ]) + .mockResolvedValueOnce([ + uploadRow({ + ...rowGeometry(session), + status: 'aborted', + providerObjectVersion: 'version-1', + completedAt: new Date(), + }), + ]) + + await expect(abortUploadSession(session)).resolves.toMatchObject({ status: 'aborted' }) + expect(mockDeleteObjectVersion).toHaveBeenCalledWith( + expect.objectContaining({ key: FINAL_KEY, version: 'version-1' }) + ) + }) + + it('cleans expired upload state including unregistered finalizing sessions', async () => { const expired = uploadRow({ expiresAt: new Date(Date.now() - 1) }) queueTableRows(schemaMock.uploadSession, [expired]) queueTableRows(schemaMock.uploadSession, []) @@ -326,7 +482,8 @@ describe('upload sessions', () => { .mocked(inArray) .mock.calls.find(([, values]) => values.includes('uploading'))?.[1] expect(candidateStatuses).toEqual(['uploading', 'completing', 'aborting']) - expect(candidateStatuses).not.toContain('finalizing') + expect(vi.mocked(eq)).toHaveBeenCalledWith(schemaMock.uploadSession.status, 'finalizing') + expect(vi.mocked(isNull)).toHaveBeenCalledWith(schemaMock.uploadSession.completedFileId) }) it('deletes a late PUT object before purging an aborted session', async () => { @@ -355,6 +512,11 @@ async function createWorkspaceUpload(fileSize: number) { id: 'upload-1', workspaceId: WORKSPACE_ID, userId: 'user-1', + principal: { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + }, purpose: 'workspace_file', fileName: 'file.bin', contentType: 'application/octet-stream', diff --git a/apps/sim/lib/uploads/upload-session/service.ts b/apps/sim/lib/uploads/upload-session/service.ts index 325c48f58ec..fa6f44e20bf 100644 --- a/apps/sim/lib/uploads/upload-session/service.ts +++ b/apps/sim/lib/uploads/upload-session/service.ts @@ -1,3 +1,4 @@ +import type { Principal } from '@sim/auth/principal' import { db, dbFor } from '@sim/db' import { uploadSession } from '@sim/db/schema' import { safeCompare } from '@sim/security/compare' @@ -90,6 +91,23 @@ export interface UploadSessionRecord { updatedAt: Date } +/** + * The credential that was authorized to create a workspace-file upload. + * + * This is deliberately kept in the existing JSON metadata column. It is + * server-authored and immutable for the lifetime of the session; the upload + * token only proves possession of the byte-plane capability and never grants + * workspace access by itself. + */ +export interface UploadSessionAuthBinding { + version: 1 + workspaceId: string + principal: + | { kind: 'session'; userId: string; sessionId: string } + | { kind: 'personal_api_key'; userId: string; keyId: string } + | { kind: 'workspace_api_key'; workspaceId: string; keyId: string } +} + export interface CreatedUploadSession extends UploadSessionRecord { transfer: UploadSessionTransfer } @@ -112,6 +130,7 @@ interface CreateUploadSessionBaseParams { fileSize: number metadata?: Record localOrigin?: string + principal?: Principal } export type CreateUploadSessionParams = CreateUploadSessionBaseParams & @@ -137,6 +156,14 @@ export async function createUploadSession( const id = params.id ?? generateId() const uploadToken = generateSecureToken(32) const workspaceId = params.purpose === 'profile_picture' ? null : params.workspaceId + const metadata = { ...(params.metadata ?? {}) } + if (params.purpose === 'workspace_file') { + if (!workspaceId) throw new Error('Workspace-file upload is missing workspaceId') + if (!params.principal) { + throw new Error('Workspace-file upload requires an authenticated principal') + } + metadata.authBinding = createUploadSessionAuthBinding(params.principal, workspaceId) + } const { storageContext, finalKey } = resolveUploadStorage(params, id) const method: UploadTransferMethod = params.fileSize <= UPLOAD_SESSION_PUT_MAX_BYTES ? 'put' : 'multipart' @@ -206,7 +233,7 @@ export async function createUploadSession( fileSize: params.fileSize, partSize, partCount, - metadata: params.metadata ?? {}, + metadata, createdAt, expiresAt, updatedAt: createdAt, @@ -265,6 +292,7 @@ export async function getOwnedUploadSession(params: { knowledgeBaseId?: string workflowId?: string executionId?: string + principal?: Principal }): Promise { const [row] = await db .select() @@ -287,9 +315,113 @@ export async function getOwnedUploadSession(params: { if (params.executionId !== undefined && session.executionId !== params.executionId) { throw uploadNotFound() } + if (params.principal && session.purpose === 'workspace_file') { + assertUploadSessionAuthBinding(session, params.principal) + } return session } +/** + * Loads a session using the signed token and verifies the immutable principal + * binding for workspace-file control-plane requests. + */ +export async function getPrincipalUploadSession(params: { + uploadId: string + uploadToken: string + principal: Principal + workspaceId?: string +}): Promise { + const session = await getOwnedUploadSession({ + uploadId: params.uploadId, + uploadToken: params.uploadToken, + workspaceId: params.workspaceId, + purpose: 'workspace_file', + principal: params.principal, + }) + return session +} + +export function createUploadSessionAuthBinding( + principal: Principal, + workspaceId: string +): UploadSessionAuthBinding { + switch (principal.kind) { + case 'session': + return { + version: 1, + workspaceId, + principal: { + kind: principal.kind, + userId: principal.userId, + sessionId: principal.sessionId, + }, + } + case 'personal_api_key': + return { + version: 1, + workspaceId, + principal: { kind: principal.kind, userId: principal.userId, keyId: principal.keyId }, + } + case 'workspace_api_key': + if (principal.workspaceId !== workspaceId) { + throw new UploadSessionError('forbidden', 'Workspace API key cannot access this workspace') + } + return { + version: 1, + workspaceId, + principal: { kind: principal.kind, workspaceId, keyId: principal.keyId }, + } + case 'delegated': + throw new UploadSessionError('forbidden', 'Delegated principals cannot create uploads') + } +} + +export function assertUploadSessionAuthBinding( + session: UploadSessionRecord, + principal: Principal +): void { + if (session.purpose !== 'workspace_file') return + const candidate = session.metadata.authBinding + if (candidate === undefined) { + assertLegacyUploadSessionOwner(session, principal) + return + } + if (!isUploadSessionAuthBinding(candidate) || candidate.workspaceId !== session.workspaceId) { + throw uploadNotFound() + } + const bound = candidate.principal + const matches = + bound.kind === principal.kind && + (bound.kind === 'session' + ? principal.kind === 'session' && + bound.userId === principal.userId && + bound.sessionId === principal.sessionId + : bound.kind === 'personal_api_key' + ? principal.kind === 'personal_api_key' && + bound.userId === principal.userId && + bound.keyId === principal.keyId + : principal.kind === 'workspace_api_key' && + bound.workspaceId === principal.workspaceId && + bound.keyId === principal.keyId) + if (!matches) throw uploadNotFound() +} + +/** + * Preserves control access for the bounded set of sessions created before + * immutable credential bindings shipped. New workspace-file sessions always + * persist `authBinding`, and malformed bindings never enter this compatibility + * path. The upload token and current workspace authorization are still checked + * by the calling control-plane use case. + */ +function assertLegacyUploadSessionOwner(session: UploadSessionRecord, principal: Principal): void { + const matches = + principal.kind === 'workspace_api_key' + ? principal.workspaceId === session.workspaceId + : (principal.kind === 'session' || principal.kind === 'personal_api_key') && + principal.userId === session.userId + if (!matches) throw uploadNotFound() +} + export async function verifyUploadSessionToken(uploadToken: string): Promise { const tokenHash = sha256Hex(uploadToken) const [row] = await db @@ -345,8 +477,16 @@ export async function createUploadPartUrls(params: { export async function completeUploadSession(params: { session: UploadSessionRecord finalize: (session: UploadSessionRecord) => Promise<{ value: T; completedFileId?: string }> + loadCompleted?: (session: UploadSessionRecord) => Promise }): Promise<{ session: UploadSessionRecord; value: T; alreadyCompleted: boolean }> { if (params.session.status === 'completed') { + if (params.loadCompleted && params.session.completedFileId) { + return { + session: params.session, + value: await params.loadCompleted(params.session), + alreadyCompleted: true, + } + } const finalized = await params.finalize(params.session) return { session: params.session, value: finalized.value, alreadyCompleted: true } } @@ -373,6 +513,12 @@ export async function completeUploadSession(params: { let alreadyCompleted = false try { + if (recoveringFinalization && claimed.completedFileId && params.loadCompleted) { + const value = await params.loadCompleted(claimed) + const completed = await markUploadSessionCompleted(claimed, leaseId, claimed.completedFileId) + return { session: completed, value, alreadyCompleted: true } + } + let finalObject = await headProviderObject({ provider: claimed.storageProvider, key: claimed.finalKey, @@ -439,25 +585,13 @@ export async function completeUploadSession(params: { claimed.uploadToken ) const finalized = await params.finalize(finalizing) - const completedAt = new Date() - const [completedRow] = await db - .update(uploadSession) - .set({ - status: 'completed', - completedFileId: finalized.completedFileId ?? null, - completedAt, - processingLeaseId: null, - processingLeaseExpiresAt: null, - error: null, - updatedAt: completedAt, - }) - .where(and(eq(uploadSession.id, claimed.id), eq(uploadSession.processingLeaseId, leaseId))) - .returning() + const completed = await markUploadSessionCompleted( + claimed, + leaseId, + finalized.completedFileId ?? null + ) return { - session: sessionFromRow( - requireRow(completedRow, 'Upload completion lease was lost'), - claimed.uploadToken - ), + session: completed, value: finalized.value, alreadyCompleted, } @@ -476,6 +610,31 @@ export async function completeUploadSession(params: { } } +async function markUploadSessionCompleted( + session: UploadSessionRecord, + leaseId: string, + completedFileId: string | null +): Promise { + const completedAt = new Date() + const [completedRow] = await db + .update(uploadSession) + .set({ + status: 'completed', + completedFileId, + completedAt, + processingLeaseId: null, + processingLeaseExpiresAt: null, + error: null, + updatedAt: completedAt, + }) + .where(and(eq(uploadSession.id, session.id), eq(uploadSession.processingLeaseId, leaseId))) + .returning() + return sessionFromRow( + requireRow(completedRow, 'Upload completion lease was lost'), + session.uploadToken + ) +} + export async function abortUploadSession( session: UploadSessionRecord ): Promise { @@ -483,13 +642,17 @@ export async function abortUploadSession( if (session.status === 'completed') { throw new UploadSessionError('conflict', 'Completed upload sessions cannot be aborted') } - if (session.status === 'finalizing') { - throw new UploadSessionError('conflict', 'Finalizing upload sessions cannot be aborted') + if (session.status === 'finalizing' && session.completedFileId) { + throw new UploadSessionError( + 'conflict', + 'Finalizing upload sessions with a registered file cannot be aborted' + ) } if ( session.status !== 'uploading' && session.status !== 'completing' && - session.status !== 'aborting' + session.status !== 'aborting' && + session.status !== 'finalizing' ) { throw new UploadSessionError('conflict', `Upload session is ${session.status}`) } @@ -498,7 +661,7 @@ export async function abortUploadSession( ...(await claimSession( session.id, leaseId, - ['uploading', 'completing', 'aborting'], + ['uploading', 'completing', 'aborting', 'finalizing'], 'aborting' )), uploadToken: session.uploadToken, @@ -545,7 +708,10 @@ export async function cleanupExpiredUploadSessions(): Promise<{ .from(uploadSession) .where( and( - inArray(uploadSession.status, ['uploading', 'completing', 'aborting']), + or( + inArray(uploadSession.status, ['uploading', 'completing', 'aborting']), + and(eq(uploadSession.status, 'finalizing'), isNull(uploadSession.completedFileId)) + ), lt(uploadSession.expiresAt, now), or( isNull(uploadSession.processingLeaseId), @@ -565,7 +731,7 @@ export async function cleanupExpiredUploadSessions(): Promise<{ const claimed = await claimSession( candidate.id, leaseId, - ['uploading', 'completing', 'aborting'], + ['uploading', 'completing', 'aborting', 'finalizing'], 'aborting', cleanupDb ) @@ -979,6 +1145,25 @@ function isStorageContext(value: string): value is StorageContext { ].includes(value) } +function isUploadSessionAuthBinding(value: unknown): value is UploadSessionAuthBinding { + if (!value || typeof value !== 'object') return false + const binding = value as Record + if (binding.version !== 1 || typeof binding.workspaceId !== 'string') return false + if (!binding.principal || typeof binding.principal !== 'object') return false + const principal = binding.principal as Record + if (principal.kind === 'session') { + return typeof principal.userId === 'string' && typeof principal.sessionId === 'string' + } + if (principal.kind === 'personal_api_key') { + return typeof principal.userId === 'string' && typeof principal.keyId === 'string' + } + return ( + principal.kind === 'workspace_api_key' && + typeof principal.workspaceId === 'string' && + typeof principal.keyId === 'string' + ) +} + function uploadNotFound(): UploadSessionError { return new UploadSessionError('not_found', 'Upload session not found') } diff --git a/apps/sim/lib/workspace-files/api/index.ts b/apps/sim/lib/workspace-files/api/index.ts new file mode 100644 index 00000000000..2b23ed91382 --- /dev/null +++ b/apps/sim/lib/workspace-files/api/index.ts @@ -0,0 +1,7 @@ +export { internalFileAnalytics } from '@/lib/workspace-files/api/internal-analytics' +export { internalFileErrorPolicies } from '@/lib/workspace-files/api/internal-error-policies' +export { internalFilePresenters } from '@/lib/workspace-files/api/internal-presenters' +export { + internalSessionOrServiceAuth, + v2FileErrorPolicies, +} from '@/lib/workspace-files/api/route-policies' diff --git a/apps/sim/lib/workspace-files/api/internal-analytics.ts b/apps/sim/lib/workspace-files/api/internal-analytics.ts new file mode 100644 index 00000000000..840c6d5044d --- /dev/null +++ b/apps/sim/lib/workspace-files/api/internal-analytics.ts @@ -0,0 +1,151 @@ +import type { SessionPrincipal } from '@sim/auth/principal' +import { captureServerEvent } from '@/lib/posthog/server' +import type { + ArchiveWorkspaceFileItemsInput, + ArchiveWorkspaceFileItemsResult, +} from '@/lib/workspace-files/application/archive-workspace-file-items' +import type { CreateWorkspaceFileResult } from '@/lib/workspace-files/application/create-workspace-file' +import type { DeleteWorkspaceFileResult } from '@/lib/workspace-files/application/delete-workspace-file' +import type { DownloadWorkspaceFileResult } from '@/lib/workspace-files/application/download-workspace-file' +import type { + DownloadWorkspaceFileItemsInput, + DownloadWorkspaceFileItemsResult, +} from '@/lib/workspace-files/application/download-workspace-file-items' +import type { MoveWorkspaceFileItemsInput } from '@/lib/workspace-files/application/move-workspace-file-items' +import type { RenameWorkspaceFileResult } from '@/lib/workspace-files/application/rename-workspace-file' +import type { + CreateWorkspaceFileFolderInput, + DeleteWorkspaceFileFolderInput, + RestoreWorkspaceFileFolderInput, + UpdateWorkspaceFileFolderInput, +} from '@/lib/workspace-files/application/workspace-file-folders' + +interface InternalSuccessArgs { + principal: SessionPrincipal + input: I + result: R +} + +export const internalFileAnalytics = { + renamed({ principal, result }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'file_renamed', + { workspace_id: result.file.workspaceId }, + { groups: { workspace: result.file.workspaceId } } + ) + }, + deleted({ principal, result }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'file_deleted', + { workspace_id: result.workspaceId }, + { groups: { workspace: result.workspaceId } } + ) + }, + downloaded({ principal, result }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'file_downloaded', + { workspace_id: result.file.workspaceId, is_bulk: false, file_count: 1 }, + { groups: { workspace: result.file.workspaceId } } + ) + }, + bulkDownloaded({ + principal, + input, + result, + }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'file_downloaded', + { + workspace_id: input.workspaceId, + is_bulk: true, + file_count: result.filesToZip.length, + }, + { groups: { workspace: input.workspaceId } } + ) + }, + uploaded({ principal, result }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'file_uploaded', + { workspace_id: result.file.workspaceId, file_type: result.file.type }, + { groups: { workspace: result.file.workspaceId } } + ) + }, + bulkDeleted({ + principal, + input, + }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'file_bulk_deleted', + { + workspace_id: input.workspaceId, + file_count: input.fileIds?.length ?? 0, + folder_count: input.folderIds?.length ?? 0, + }, + { groups: { workspace: input.workspaceId } } + ) + }, + folderRestored({ principal, input }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'folder_restored', + { folder_id: input.folderId, workspace_id: input.workspaceId }, + { groups: { workspace: input.workspaceId } } + ) + }, + folderRenamed({ principal, input }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'folder_renamed', + { workspace_id: input.workspaceId }, + { groups: { workspace: input.workspaceId } } + ) + }, + folderDeleted({ principal, input }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'folder_deleted', + { workspace_id: input.workspaceId }, + { groups: { workspace: input.workspaceId } } + ) + }, + folderCreated({ principal, input }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'folder_created', + { workspace_id: input.workspaceId }, + { groups: { workspace: input.workspaceId } } + ) + }, + moved({ principal, input }: InternalSuccessArgs) { + if (input.fileIds && input.fileIds.length > 0) { + captureServerEvent( + principal.userId, + 'file_moved', + { + workspace_id: input.workspaceId, + file_count: input.fileIds.length, + folder_count: input.folderIds?.length ?? 0, + }, + { groups: { workspace: input.workspaceId } } + ) + } + if (input.folderIds && input.folderIds.length > 0) { + captureServerEvent( + principal.userId, + 'folder_moved', + { + workspace_id: input.workspaceId, + file_count: input.fileIds?.length ?? 0, + folder_count: input.folderIds.length, + }, + { groups: { workspace: input.workspaceId } } + ) + } + }, +} as const diff --git a/apps/sim/lib/workspace-files/api/internal-error-policies.test.ts b/apps/sim/lib/workspace-files/api/internal-error-policies.test.ts new file mode 100644 index 00000000000..641514f0ef7 --- /dev/null +++ b/apps/sim/lib/workspace-files/api/internal-error-policies.test.ts @@ -0,0 +1,35 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api/internal-error-policies' +import { + CompiledCheckTooLargeError, + CompiledCheckUnsupportedError, +} from '@/lib/workspace-files/application/compiled-check-workspace-file' +import { StyleExtractionUnsupportedError } from '@/lib/workspace-files/application/style-workspace-file' + +describe('internal file error policies', () => { + it('projects style and compiled-check failures without constructing responses', () => { + expect( + internalFileErrorPolicies.style.project(new StyleExtractionUnsupportedError('Unsupported')) + ).toEqual({ status: 422, body: { error: 'Unsupported' }, headers: undefined }) + expect( + internalFileErrorPolicies.compiledCheck.project(new CompiledCheckUnsupportedError()) + ).toMatchObject({ status: 422 }) + expect( + internalFileErrorPolicies.compiledCheck.project(new CompiledCheckTooLargeError()) + ).toMatchObject({ status: 413 }) + }) + + it('conceals forbidden inline resources with the legacy not-found envelope', () => { + expect( + internalFileErrorPolicies.inline.project(new OrchestrationError('forbidden', 'Forbidden')) + ).toEqual({ + status: 404, + body: { error: 'FileNotFoundError', message: 'Not found' }, + headers: undefined, + }) + }) +}) diff --git a/apps/sim/lib/workspace-files/api/internal-error-policies.ts b/apps/sim/lib/workspace-files/api/internal-error-policies.ts new file mode 100644 index 00000000000..c5814cde15a --- /dev/null +++ b/apps/sim/lib/workspace-files/api/internal-error-policies.ts @@ -0,0 +1,87 @@ +import { createLogger } from '@sim/logger' +import { + extendInternalErrorPolicy, + type InternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, + internalPlainOrchestrationErrorPolicy, +} from '@/lib/api/server/routes' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { + CompiledCheckTooLargeError, + CompiledCheckUnsupportedError, +} from '@/lib/workspace-files/application/compiled-check-workspace-file' +import { StyleExtractionUnsupportedError } from '@/lib/workspace-files/application/style-workspace-file' + +const logger = createLogger('InternalWorkspaceFileErrors') + +const style = extendInternalErrorPolicy(internalPlainOrchestrationErrorPolicy, (error) => { + if (!(error instanceof StyleExtractionUnsupportedError)) return null + return internalErrorResponse(422, { error: error.message }) +}) + +const compiledCheck = extendInternalErrorPolicy(internalPlainOrchestrationErrorPolicy, (error) => { + if (error instanceof CompiledCheckUnsupportedError) { + return internalErrorResponse(422, { error: error.message }) + } + if (error instanceof CompiledCheckTooLargeError) { + return internalErrorResponse(413, { error: error.message }) + } + return null +}) + +const downloadUrl: InternalErrorPolicy = { + project(error) { + const typed = internalOrchestrationErrorPolicy.project(error) + if (typed) return typed + logger.error('Failed to generate workspace file download URL', { error }) + return internalErrorResponse(500, { + success: false, + error: 'Failed to generate download URL', + }) + }, +} + +const downloadArchive: InternalErrorPolicy = { + project(error) { + const classified = asOrchestrationError(error) + if (classified) { + return internalErrorResponse(statusForOrchestrationError(classified.code), { + error: classified.message, + }) + } + logger.error('Failed to download workspace file selection', { error }) + return internalErrorResponse(500, { error: 'Internal server error' }) + }, +} + +const inline: InternalErrorPolicy = { + project(error) { + const classified = asOrchestrationError(error) + if (classified) { + if (classified.code === 'not_found' || classified.code === 'forbidden') { + return internalErrorResponse(404, { error: 'FileNotFoundError', message: 'Not found' }) + } + return internalErrorResponse(statusForOrchestrationError(classified.code), { + error: 'Error', + message: classified.message, + }) + } + if (error instanceof Error) { + logger.error('Error serving workspace inline image', { error }) + return internalErrorResponse(500, { error: error.name, message: error.message }) + } + logger.error('Error serving workspace inline image', { error }) + return internalErrorResponse(500, { error: 'Error', message: 'Failed to serve file' }) + }, +} + +export const internalFileErrorPolicies = { + default: internalOrchestrationErrorPolicy, + plain: internalPlainOrchestrationErrorPolicy, + style, + compiledCheck, + downloadUrl, + downloadArchive, + inline, +} as const diff --git a/apps/sim/lib/workspace-files/api/internal-presenters.ts b/apps/sim/lib/workspace-files/api/internal-presenters.ts new file mode 100644 index 00000000000..7413b44e911 --- /dev/null +++ b/apps/sim/lib/workspace-files/api/internal-presenters.ts @@ -0,0 +1,29 @@ +import { workspaceFileStyleContract } from '@/lib/api/contracts/workspace-files' +import { getBaseUrl } from '@/lib/core/utils/urls' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import type { DownloadWorkspaceFileResult } from '@/lib/workspace-files/application/download-workspace-file' + +export const internalFilePresenters = { + successFile({ file }: { file: WorkspaceFileRecord }) { + return { success: true as const, file: { ...file, folderId: file.folderId ?? null } } + }, + successFiles({ files }: { files: WorkspaceFileRecord[] }) { + return { + success: true as const, + files: files.map((file) => ({ ...file, folderId: file.folderId ?? null })), + } + }, + downloadUrl({ file }: DownloadWorkspaceFileResult) { + const baseUrl = getBaseUrl() + return { + success: true as const, + downloadUrl: `${baseUrl}/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`, + viewerUrl: `${baseUrl}/workspace/${file.workspaceId}/files/${file.id}`, + fileName: file.name, + expiresIn: null, + } + }, + style(result: Parameters[0]) { + return workspaceFileStyleContract.response.schema.parse(result) + }, +} as const diff --git a/apps/sim/lib/workspace-files/api/route-policies.test.ts b/apps/sim/lib/workspace-files/api/route-policies.test.ts new file mode 100644 index 00000000000..3fd91c612bb --- /dev/null +++ b/apps/sim/lib/workspace-files/api/route-policies.test.ts @@ -0,0 +1,71 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockVerifyInternalToken } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockVerifyInternalToken: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/lib/auth/internal', () => ({ verifyInternalToken: mockVerifyInternalToken })) + +import { InternalUnauthenticatedError } from '@/lib/api/server/routes' +import { internalSessionOrServiceAuth } from '@/lib/workspace-files/api' + +describe('internal file route authentication', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue(null) + }) + + it('binds a verified internal user to an executor file principal', async () => { + mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-1' }) + + const principal = await internalSessionOrServiceAuth.authenticate( + new NextRequest('http://localhost/api/workspaces/ws-1/files/file-1', { + headers: { authorization: 'Bearer signed-token' }, + }), + { id: 'ws-1', fileId: 'file-1' } + ) + + expect(principal).toMatchObject({ + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'ws-1', + audience: 'sim:workspace-files', + resourceScope: { fileId: 'file-1' }, + }) + expect(mockGetSession).not.toHaveBeenCalled() + }) + + it('rejects internal tokens that do not carry a human subject', async () => { + mockVerifyInternalToken.mockResolvedValue({ valid: true }) + + await expect( + internalSessionOrServiceAuth.authenticate( + new NextRequest('http://localhost/api/workspaces/ws-1/files/file-1', { + headers: { authorization: 'Bearer signed-token' }, + }), + { id: 'ws-1', fileId: 'file-1' } + ) + ).rejects.toBeInstanceOf(InternalUnauthenticatedError) + }) + + it('preserves browser session principals when no service token is supplied', async () => { + mockGetSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + + await expect( + internalSessionOrServiceAuth.authenticate( + new NextRequest('http://localhost/api/workspaces/ws-1/files/file-1'), + { id: 'ws-1', fileId: 'file-1' } + ) + ).resolves.toEqual({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) + }) +}) diff --git a/apps/sim/lib/workspace-files/api/route-policies.ts b/apps/sim/lib/workspace-files/api/route-policies.ts new file mode 100644 index 00000000000..c287586ddd2 --- /dev/null +++ b/apps/sim/lib/workspace-files/api/route-policies.ts @@ -0,0 +1,35 @@ +import { + createInternalSessionOrServiceAuth, + type V2ErrorPolicy, + v2OrchestrationErrorPolicy, +} from '@/lib/api/server/routes' +import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' + +export const internalSessionOrServiceAuth = createInternalSessionOrServiceAuth( + ({ subjectUserId, params }) => { + const workspaceId = params.id + if (typeof workspaceId !== 'string' || !workspaceId) { + throw new Error('Internal file delegation requires a workspace route parameter') + } + return createWorkspaceFileDelegatedPrincipal({ + serviceId: 'executor', + subjectUserId, + workspaceId, + delegationId: `internal-file:${subjectUserId}`, + fileId: typeof params.fileId === 'string' ? params.fileId : undefined, + }) + } +) + +export const v2FileErrorPolicies = { + default: v2OrchestrationErrorPolicy, + concealResourceAuthorization: { + render(error) { + const response = v2CaughtOrchestrationError(error) + if (!response) return null + if (response.status === 403) return v2Error('NOT_FOUND', 'File not found') + return response + }, + } satisfies V2ErrorPolicy, +} as const diff --git a/apps/sim/lib/workspace-files/application/archive-workspace-file-items.test.ts b/apps/sim/lib/workspace-files/application/archive-workspace-file-items.test.ts new file mode 100644 index 00000000000..d7ecfb67b56 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/archive-workspace-file-items.test.ts @@ -0,0 +1,228 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + events, + mockLoadContext, + mockResolvePermission, + mockAssertItems, + mockArchive, + mockAudit, + mockNotify, +} = vi.hoisted(() => ({ + events: [] as string[], + mockLoadContext: vi.fn(), + mockResolvePermission: vi.fn(), + mockAssertItems: vi.fn(), + mockArchive: vi.fn(), + mockAudit: vi.fn(), + mockNotify: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + assertWorkspaceFileItemsBelongToWorkspace: mockAssertItems, + bulkArchiveWorkspaceFileItems: mockArchive, + loadWorkspaceFileOperationContext: mockLoadContext, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mockResolvePermission, +})) +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_DELETED: 'file.deleted', FOLDER_DELETED: 'folder.deleted' }, + AuditResourceType: { FILE: 'file', FOLDER: 'folder' }, + recordAudit: mockAudit, +})) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mockNotify })) + +import { archiveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/archive-workspace-file-items' + +describe('archiveWorkspaceFileItemsOperation', () => { + beforeEach(() => { + vi.clearAllMocks() + events.length = 0 + mockLoadContext.mockImplementation(async () => { + events.push('resolve') + return { + workspaceId: 'ws-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', + } + }) + mockResolvePermission.mockImplementation(async () => { + events.push('authorize') + return 'write' + }) + mockAssertItems.mockImplementation(async () => { + events.push('execute') + }) + mockArchive.mockImplementation(async () => ({ + files: 1, + folders: 0, + fileIds: ['file-1'], + folderIds: [], + })) + }) + + it('preserves atomic bulk archive results and emits side effects once', async () => { + const result = await archiveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1', fileIds: ['file-1'] }, + }) + + expect(result).toMatchObject({ deletedItems: { files: 1, folders: 0 } }) + expect(events).toEqual(['resolve', 'authorize', 'execute']) + expect(mockArchive).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + fileIds: ['file-1'], + folderIds: [], + }) + expect(mockAssertItems).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + fileIds: ['file-1'], + folderIds: [], + }) + expect(mockAudit).toHaveBeenCalledOnce() + expect(mockNotify).toHaveBeenCalledOnce() + }) + + it('classifies a single missing file without notifying', async () => { + mockArchive.mockResolvedValue({ files: 0, folders: 0, fileIds: [], folderIds: [] }) + await expect( + archiveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1', fileIds: ['missing'] }, + }) + ).rejects.toThrow('File not found') + expect(mockAudit).not.toHaveBeenCalled() + expect(mockNotify).not.toHaveBeenCalled() + }) + + it('authorizes a delegated bulk selection without borrowing the first file scope', async () => { + await expect( + archiveWorkspaceFileItemsOperation.execute({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'ws-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), + resourceScope: { fileId: 'file-1' }, + }, + input: { workspaceId: 'ws-1', fileIds: ['file-1', 'file-2'] }, + }) + ).rejects.toThrow('Delegated workspace access is no longer valid') + + expect(mockLoadContext).toHaveBeenCalledWith('ws-1') + expect(mockResolvePermission).not.toHaveBeenCalled() + expect(mockAssertItems).not.toHaveBeenCalled() + expect(mockArchive).not.toHaveBeenCalled() + }) + + it('allows a file-scoped delegated principal to archive its one explicit file', async () => { + const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'ws-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), + resourceScope: { fileId: 'file-1' }, + } + + await archiveWorkspaceFileItemsOperation.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: ['file-1'] }, + }) + + expect(mockResolvePermission).toHaveBeenCalledOnce() + }) + + it('denies a file-scoped delegated principal selecting a folder before validation', async () => { + await expect( + archiveWorkspaceFileItemsOperation.execute({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'ws-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), + resourceScope: { fileId: 'file-1' }, + }, + input: { workspaceId: 'ws-1', folderIds: ['folder-1'] }, + }) + ).rejects.toThrow('Delegated workspace access is no longer valid') + + expect(mockResolvePermission).not.toHaveBeenCalled() + expect(mockAssertItems).not.toHaveBeenCalled() + expect(mockArchive).not.toHaveBeenCalled() + }) + + it('audits and notifies only authoritative rows returned by the mutation', async () => { + mockArchive.mockResolvedValue({ + files: 1, + folders: 1, + fileIds: ['file-2'], + folderIds: ['folder-2'], + }) + + const result = await archiveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'ws-1', + fileIds: ['file-1', 'file-2'], + folderIds: ['folder-1'], + }, + }) + + expect(result).toMatchObject({ deletedItems: { files: 1, folders: 1 } }) + expect(mockAudit).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ metadata: expect.objectContaining({ fileIds: ['file-2'] }) }) + ) + expect(mockAudit).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ metadata: expect.objectContaining({ folderIds: ['folder-2'] }) }) + ) + expect(mockNotify).toHaveBeenCalledOnce() + }) + + it('does not claim or notify a zero-row bulk mutation', async () => { + mockArchive.mockResolvedValue({ files: 0, folders: 0, fileIds: [], folderIds: [] }) + + const result = await archiveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1', fileIds: ['file-1', 'file-2'] }, + }) + + expect(result).toMatchObject({ deletedItems: { files: 0, folders: 0 } }) + expect(mockAudit).not.toHaveBeenCalled() + expect(mockNotify).not.toHaveBeenCalled() + }) + + it('rejects oversized selections after authorization and before storage', async () => { + await expect( + archiveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'ws-1', + fileIds: Array.from({ length: 1_001 }, (_, index) => `file-${index}`), + }, + }) + ).rejects.toThrow('accept at most 1000') + expect(events).toEqual(['resolve', 'authorize']) + expect(mockArchive).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/archive-workspace-file-items.ts b/apps/sim/lib/workspace-files/application/archive-workspace-file-items.ts new file mode 100644 index 00000000000..30e7011c8a9 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/archive-workspace-file-items.ts @@ -0,0 +1,127 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { + assertWorkspaceFileItemsBelongToWorkspace, + bulkArchiveWorkspaceFileItems, + loadWorkspaceFileOperationContext, +} from '@/lib/uploads/contexts/workspace' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { MAX_WORKSPACE_FILE_BULK_REQUEST_IDS } from '@/lib/workspace-files/limits' + +const logger = createLogger('ArchiveWorkspaceFileItems') + +export interface ArchiveWorkspaceFileItemsInput { + workspaceId: string + fileIds?: string[] + folderIds?: string[] +} + +export interface ArchiveWorkspaceFileItemsResult { + deletedItems: { files: number; folders: number } + affectedIds: { fileIds: string[]; folderIds: string[] } +} + +function normalizeSelection(input: ArchiveWorkspaceFileItemsInput) { + return { + fileIds: [...new Set(input.fileIds ?? [])], + folderIds: [...new Set(input.folderIds ?? [])], + } +} + +async function executeArchiveWorkspaceFileItems({ + input, + context, +}: { + input: ArchiveWorkspaceFileItemsInput + context: Awaited> +}): Promise { + const { fileIds, folderIds } = normalizeSelection(input) + if (fileIds.length === 0 && folderIds.length === 0) { + throw new OrchestrationError('validation', 'At least one file or folder must be selected') + } + if ( + fileIds.length > MAX_WORKSPACE_FILE_BULK_REQUEST_IDS || + folderIds.length > MAX_WORKSPACE_FILE_BULK_REQUEST_IDS + ) { + throw new OrchestrationError( + 'validation', + `Bulk file operations accept at most ${MAX_WORKSPACE_FILE_BULK_REQUEST_IDS} file and folder IDs` + ) + } + + await assertWorkspaceFileItemsBelongToWorkspace({ + workspaceId: context.workspaceId, + fileIds, + folderIds, + }) + const archived = await bulkArchiveWorkspaceFileItems({ + workspaceId: context.workspaceId, + fileIds, + folderIds, + }) + const deletedItems = { files: archived.fileIds.length, folders: archived.folderIds.length } + + if (fileIds.length === 1 && folderIds.length === 0 && archived.fileIds.length === 0) { + throw new OrchestrationError('not_found', 'File not found') + } + if (folderIds.length === 1 && fileIds.length === 0 && archived.folderIds.length === 0) { + throw new OrchestrationError('not_found', 'Folder not found') + } + + logger.info('Archived workspace file items', { workspaceId: context.workspaceId, deletedItems }) + return { + deletedItems, + affectedIds: { fileIds: archived.fileIds, folderIds: archived.folderIds }, + } +} + +async function resolveArchiveContext({ input }: { input: ArchiveWorkspaceFileItemsInput }) { + const context = await loadWorkspaceFileOperationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + const { fileIds, folderIds } = normalizeSelection(input) + return { + ...context, + fileId: fileIds.length === 1 && folderIds.length === 0 ? fileIds[0] : undefined, + } +} + +export const archiveWorkspaceFileItemsOperation = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.delete, + resolveContext: resolveArchiveContext, + execute: executeArchiveWorkspaceFileItems, + projectAudit({ result }) { + const entries = [] + if (result.affectedIds.fileIds.length > 0) { + entries.push({ + action: AuditAction.FILE_DELETED, + resourceType: AuditResourceType.FILE, + description: `Deleted ${result.affectedIds.fileIds.length} file${result.affectedIds.fileIds.length === 1 ? '' : 's'}`, + metadata: { + fileIds: result.affectedIds.fileIds, + }, + }) + } + if (result.affectedIds.folderIds.length > 0) { + entries.push({ + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: + result.affectedIds.folderIds.length === 1 ? result.affectedIds.folderIds[0] : undefined, + description: `Deleted ${result.affectedIds.folderIds.length} file folder${result.affectedIds.folderIds.length === 1 ? '' : 's'}`, + metadata: { + folderIds: result.affectedIds.folderIds, + affected: result.deletedItems, + }, + }) + } + return entries + }, + async afterSuccess({ context, result }) { + if (result.affectedIds.fileIds.length > 0 || result.affectedIds.folderIds.length > 0) { + await notifyWorkspaceFilesChanged(context.workspaceId) + } + }, +}) diff --git a/apps/sim/lib/workspace-files/application/authorization.test.ts b/apps/sim/lib/workspace-files/application/authorization.test.ts new file mode 100644 index 00000000000..524fd4aed21 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/authorization.test.ts @@ -0,0 +1,152 @@ +/** + * @vitest-environment node + */ +import type { Principal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const resolvePermission = vi.hoisted(() => vi.fn()) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: resolvePermission, +})) + +import type { OrchestrationError } from '@/lib/core/orchestration/types' +import { authorizeWorkspaceFileAccess } from '@/lib/workspace-files/application/authorization' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +const authorizationContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + fileId: 'file-1', +} + +async function expectForbidden(principal: Principal) { + await expect( + authorizeWorkspaceFileAccess(principal, fileOperations.rename, authorizationContext) + ).rejects.toMatchObject>({ code: 'forbidden' }) +} + +describe('file operation authorization', () => { + beforeEach(() => { + vi.clearAllMocks() + resolvePermission.mockResolvedValue('write') + }) + + it('uses the current workspace permission for sessions', async () => { + await authorizeWorkspaceFileAccess( + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + fileOperations.rename, + authorizationContext + ) + + expect(resolvePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'organization-1', + undefined, + { forUpdate: undefined } + ) + }) + + it('rejects a reader for a write operation', async () => { + resolvePermission.mockResolvedValue('read') + await expectForbidden({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) + }) + + it('lets a workspace key use its fixed write ceiling only in its own workspace', async () => { + await authorizeWorkspaceFileAccess( + { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + fileOperations.rename, + authorizationContext + ) + expect(resolvePermission).not.toHaveBeenCalled() + + await expectForbidden({ + kind: 'workspace_api_key', + workspaceId: 'workspace-2', + keyId: 'key-2', + }) + }) + + it('fails a personal key immediately when the workspace disables it', async () => { + await expect( + authorizeWorkspaceFileAccess( + { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + fileOperations.rename, + { ...authorizationContext, allowPersonalApiKeys: false } + ) + ).rejects.toMatchObject>({ code: 'forbidden' }) + expect(resolvePermission).not.toHaveBeenCalled() + }) + + it('rejects a disallowed principal kind before principal-specific authorization', async () => { + await expect( + authorizeWorkspaceFileAccess( + { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + fileOperations.compiledCheck, + { ...authorizationContext, allowPersonalApiKeys: false } + ) + ).rejects.toMatchObject>({ + code: 'forbidden', + message: 'Principal kind personal_api_key cannot perform operation files.compiled_check', + }) + expect(resolvePermission).not.toHaveBeenCalled() + }) + + it('reauthorizes a valid file-scoped Copilot delegation as its human subject', async () => { + await authorizeWorkspaceFileAccess( + { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { fileId: 'file-1', chatId: 'chat-1' }, + }, + fileOperations.rename, + authorizationContext + ) + + expect(resolvePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'organization-1', + undefined, + { forUpdate: undefined } + ) + }) + + it('rejects expired or wrong-file delegations before permission lookup', async () => { + const base = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date(Date.now() - 10_000), + } + + await expectForbidden({ + ...base, + expiresAt: new Date(Date.now() - 1), + resourceScope: { fileId: 'file-1' }, + }) + await expectForbidden({ + ...base, + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { fileId: 'file-2' }, + }) + expect(resolvePermission).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/authorization.ts b/apps/sim/lib/workspace-files/application/authorization.ts new file mode 100644 index 00000000000..dca73ebab1e --- /dev/null +++ b/apps/sim/lib/workspace-files/application/authorization.ts @@ -0,0 +1,40 @@ +import type { Principal } from '@sim/auth/principal' +import type { WorkspaceOperation } from '@/lib/core/application' +import { + authorizeWorkspaceOperation, + type WorkspaceAuthorizationContext, + type WorkspaceAuthorizationOptions, +} from '@/lib/core/application' + +export const WORKSPACE_FILES_DELEGATION_AUDIENCE = 'sim:workspace-files' + +export interface WorkspaceFileAuthorizationContext extends WorkspaceAuthorizationContext { + fileId?: string +} + +export type WorkspaceFileAuthorizationOptions = Omit< + WorkspaceAuthorizationOptions, + 'delegation' +> + +export const workspaceFileDelegationPolicy = { + audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, + isWithinScope: ( + delegated: Extract, + canonicalContext: WorkspaceFileAuthorizationContext + ) => + delegated.resourceScope?.fileId === undefined || + delegated.resourceScope.fileId === canonicalContext.fileId, +} as const + +export async function authorizeWorkspaceFileAccess( + principal: Principal, + operation: WorkspaceOperation, + context: WorkspaceFileAuthorizationContext, + options?: WorkspaceFileAuthorizationOptions +): Promise { + await authorizeWorkspaceOperation(principal, operation, context, { + ...options, + delegation: workspaceFileDelegationPolicy, + }) +} diff --git a/apps/sim/lib/workspace-files/application/authorized-workspace-file-use-case.ts b/apps/sim/lib/workspace-files/application/authorized-workspace-file-use-case.ts new file mode 100644 index 00000000000..46ecccbc243 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/authorized-workspace-file-use-case.ts @@ -0,0 +1,28 @@ +import { + type AuthorizedWorkspaceUseCaseDefinition, + defineAuthorizedWorkspaceUseCase, + type WorkspaceOperation, +} from '@/lib/core/application' +import { + type WorkspaceFileAuthorizationContext, + workspaceFileDelegationPolicy, +} from '@/lib/workspace-files/application/authorization' + +type AuthorizedWorkspaceFileUseCaseDefinition< + O extends WorkspaceOperation, + I, + C extends WorkspaceFileAuthorizationContext, + R, +> = Omit, 'authorizationOptions'> + +export function defineAuthorizedWorkspaceFileUseCase< + const O extends WorkspaceOperation, + I, + C extends WorkspaceFileAuthorizationContext, + R, +>(definition: AuthorizedWorkspaceFileUseCaseDefinition) { + return defineAuthorizedWorkspaceUseCase({ + ...definition, + authorizationOptions: { delegation: workspaceFileDelegationPolicy }, + }) +} diff --git a/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.test.ts new file mode 100644 index 00000000000..04172b19904 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.test.ts @@ -0,0 +1,235 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getE2BDocFormat: vi.fn(), + getFile: vi.fn(), + loadContext: vi.fn(), + resolvePermission: vi.fn(), + fetchBuffer: vi.fn(), + runE2BCompiledCheck: vi.fn(), + runSandboxTask: vi.fn(), + validateMermaidSource: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/copilot/tools/server/files/doc-compile', () => ({ + getE2BDocFormat: mocks.getE2BDocFormat, +})) + +vi.mock('@/lib/copilot/tools/server/files/doc-recalc', () => ({ + runE2BCompiledCheck: mocks.runE2BCompiledCheck, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ isDocSandboxEnabled: true })) + +vi.mock('@/lib/execution/constants', () => ({ + BINARY_DOC_TASKS: { pptx: 'document-pptx' }, + MAX_DOCUMENT_PREVIEW_CODE_BYTES: 1_000, +})) + +vi.mock('@/lib/execution/sandbox/run-task', () => ({ + runSandboxTask: mocks.runSandboxTask, + SandboxUserCodeError: class SandboxUserCodeError extends Error { + constructor(message: string, name: string) { + super(message) + this.name = name + } + }, +})) + +vi.mock('@/lib/mermaid/validate', () => ({ + validateMermaidSource: mocks.validateMermaidSource, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + loadActiveWorkspaceFileContext: mocks.loadContext, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + fetchWorkspaceFileBuffer: mocks.fetchBuffer, + getWorkspaceFile: mocks.getFile, +})) + +import { SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' +import { compiledCheckWorkspaceFile } from '@/lib/workspace-files/application/compiled-check-workspace-file' + +const sessionPrincipal = { + kind: 'session' as const, + userId: 'current-user', + sessionId: 'session-1', +} + +function mockFile(name: string) { + mocks.getFile.mockResolvedValue({ + id: 'file-1', + workspaceId: 'workspace-1', + name, + size: 20, + uploadedBy: 'original-uploader', + }) + mocks.fetchBuffer.mockResolvedValue(Buffer.from('source code')) +} + +describe('compiledCheckWorkspaceFile', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getE2BDocFormat.mockResolvedValue(null) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.loadContext.mockResolvedValue({ + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: false, + billedAccountUserId: 'billing-owner', + }) + mocks.runSandboxTask.mockResolvedValue(Buffer.from('compiled')) + }) + + it('rejects API-key principals before canonical loading or business execution', async () => { + const unsupportedPrincipals = [ + { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'personal-key' }, + { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'workspace-key', + }, + ] + + for (const principal of unsupportedPrincipals) { + await expect( + compiledCheckWorkspaceFile.execute({ + principal, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + message: `Principal kind ${principal.kind} cannot perform operation files.compiled_check`, + }) + } + + expect(compiledCheckWorkspaceFile.operation).toMatchObject({ + id: 'files.compiled_check', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }) + expect(mocks.loadContext).not.toHaveBeenCalled() + expect(mocks.getFile).not.toHaveBeenCalled() + expect(mocks.fetchBuffer).not.toHaveBeenCalled() + }) + + it('uses the current session user as the legacy sandbox owner, never the uploader', async () => { + mockFile('report.pptx') + + await expect( + compiledCheckWorkspaceFile.execute({ + principal: sessionPrincipal, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ ok: true }) + + expect(mocks.runSandboxTask).toHaveBeenCalledWith( + 'document-pptx', + { code: 'source code', workspaceId: 'workspace-1' }, + { ownerKey: 'user:current-user' } + ) + expect(mocks.runSandboxTask).not.toHaveBeenCalledWith(expect.anything(), expect.anything(), { + ownerKey: 'user:original-uploader', + }) + expect(mocks.loadContext).toHaveBeenCalledTimes(1) + expect(mocks.resolvePermission).toHaveBeenCalledTimes(1) + expect(mocks.getFile).toHaveBeenCalledTimes(1) + expect(mocks.fetchBuffer).toHaveBeenCalledTimes(1) + }) + + it('preserves legacy sandbox user-code failures in the successful response envelope', async () => { + mockFile('report.pptx') + mocks.runSandboxTask.mockRejectedValue( + new SandboxUserCodeError('Presentation source is invalid', 'SyntaxError') + ) + + await expect( + compiledCheckWorkspaceFile.execute({ + principal: sessionPrincipal, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ + ok: false, + error: 'Presentation source is invalid', + errorName: 'SyntaxError', + }) + }) + + it('keeps Mermaid validation on the in-process path', async () => { + mockFile('diagram.mmd') + mocks.validateMermaidSource.mockResolvedValue({ + ok: false, + error: 'Unexpected token', + errorName: 'MermaidError', + }) + + await expect( + compiledCheckWorkspaceFile.execute({ + principal: sessionPrincipal, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ + ok: false, + error: 'Unexpected token', + errorName: 'MermaidError', + }) + + expect(mocks.validateMermaidSource).toHaveBeenCalledWith('source code') + expect(mocks.runE2BCompiledCheck).not.toHaveBeenCalled() + expect(mocks.runSandboxTask).not.toHaveBeenCalled() + }) + + it('keeps E2B user-code failures in the successful response envelope', async () => { + mockFile('report.pptx') + mocks.getE2BDocFormat.mockResolvedValue({ ext: 'pptx' }) + mocks.runE2BCompiledCheck.mockResolvedValue({ ok: false, error: 'Script failed' }) + + await expect( + compiledCheckWorkspaceFile.execute({ + principal: sessionPrincipal, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ + ok: false, + error: 'Script failed', + errorName: 'CompiledCheckError', + }) + + expect(mocks.runE2BCompiledCheck).toHaveBeenCalledWith({ + source: 'source code', + fileName: 'report.pptx', + workspaceId: 'workspace-1', + ext: 'pptx', + principal: sessionPrincipal, + }) + expect(mocks.runSandboxTask).not.toHaveBeenCalled() + }) + + it('propagates E2B infrastructure failures', async () => { + mockFile('report.pptx') + mocks.getE2BDocFormat.mockResolvedValue({ ext: 'pptx' }) + const failure = new Error('E2B unavailable') + mocks.runE2BCompiledCheck.mockRejectedValue(failure) + + await expect( + compiledCheckWorkspaceFile.execute({ + principal: sessionPrincipal, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).rejects.toBe(failure) + + expect(mocks.runSandboxTask).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.ts b/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.ts new file mode 100644 index 00000000000..f10d806d9a4 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.ts @@ -0,0 +1,114 @@ +import { getE2BDocFormat } from '@/lib/copilot/tools/server/files/doc-compile' +import { runE2BCompiledCheck } from '@/lib/copilot/tools/server/files/doc-recalc' +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' +import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' +import { validateMermaidSource } from '@/lib/mermaid/validate' +import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import type { ActiveWorkspaceFileContext } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' + +export class CompiledCheckUnsupportedError extends Error { + constructor() { + super('Compiled check only supports .docx, .pptx, .pdf, .xlsx, and .mmd files') + this.name = 'CompiledCheckUnsupportedError' + } +} + +export class CompiledCheckTooLargeError extends Error { + constructor() { + super('File source exceeds maximum size') + this.name = 'CompiledCheckTooLargeError' + } +} + +export interface CompiledCheckWorkspaceFileInput { + fileId: string + assertedWorkspaceId?: string +} + +export type CompiledCheckWorkspaceFileResult = + | { ok: true } + | { ok: false; error: string; errorName: string } + +function normalizeCompiledCheckResult(result: { + ok: boolean + error?: string + errorName?: string +}): CompiledCheckWorkspaceFileResult { + if (result.ok) return { ok: true } + return { + ok: false, + error: result.error ?? 'Compiled check failed', + errorName: result.errorName ?? 'CompiledCheckError', + } +} + +async function executeCompiledCheckWorkspaceFile({ + principal, + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.compiledCheck, + CompiledCheckWorkspaceFileInput, + ActiveWorkspaceFileContext +>): Promise { + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + const ext = file.name.split('.').pop()?.toLowerCase() ?? '' + const e2bFmt = isDocSandboxEnabled ? await getE2BDocFormat(file.name) : null + const taskId = BINARY_DOC_TASKS[ext] + const isMermaidFile = ext === 'mmd' || ext === 'mermaid' + if (!e2bFmt && !taskId && !isMermaidFile) throw new CompiledCheckUnsupportedError() + + if (file.size > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { + throw new CompiledCheckTooLargeError() + } + + const content = await fetchWorkspaceFileBuffer(file, { + maxBytes: MAX_DOCUMENT_PREVIEW_CODE_BYTES, + }) + + const code = content.toString('utf-8') + if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { + throw new CompiledCheckTooLargeError() + } + if (isMermaidFile) return normalizeCompiledCheckResult(await validateMermaidSource(code)) + if (e2bFmt) { + return normalizeCompiledCheckResult( + await runE2BCompiledCheck({ + source: code, + fileName: file.name, + workspaceId: file.workspaceId, + ext, + principal, + }) + ) + } + + try { + if (!taskId) throw new CompiledCheckUnsupportedError() + await runSandboxTask( + taskId, + { code, workspaceId: file.workspaceId }, + { ownerKey: `user:${principal.userId}` } + ) + return { ok: true } + } catch (error) { + if (error instanceof SandboxUserCodeError) { + return { ok: false, error: error.message, errorName: error.name } + } + throw error + } +} + +export const compiledCheckWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.compiledCheck, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), + execute: executeCompiledCheckWorkspaceFile, +}) diff --git a/apps/sim/lib/workspace-files/application/create-workspace-file.ts b/apps/sim/lib/workspace-files/application/create-workspace-file.ts new file mode 100644 index 00000000000..edcbc830a91 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/create-workspace-file.ts @@ -0,0 +1,150 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { getPostgresErrorCode } from '@sim/utils/errors' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + FileConflictError, + loadActiveWorkspaceContext, + uploadWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace' +import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { MAX_WORKSPACE_FILE_CONTENT_BYTES } from '@/lib/workspace-files/orchestration' + +const logger = createLogger('CreateWorkspaceFile') + +export interface CreateWorkspaceFileInput { + workspaceId: string + name: string + contentType: string + content: string + encoding: 'utf-8' | 'base64' + folderId?: string | null + folderPath?: string + exactName: boolean + secretProvenance?: WorkspaceFileSecretProvenance +} + +export interface CreateWorkspaceFileResult { + file: WorkspaceFileRecord +} + +export interface CreateWorkspaceFileBufferInput + extends Omit { + content: Buffer +} + +async function resolveCreateWorkspaceFileContext(workspaceId: string) { + const workspace = await loadActiveWorkspaceContext(workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + return workspace +} + +async function createAuthorizedWorkspaceFile({ + principal, + input, + content, + workspace, +}: { + principal: Principal + input: Omit + content: Buffer + workspace: Awaited> +}): Promise { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: workspace.billedAccountUserId, + }) + let file: WorkspaceFileRecord + try { + file = await uploadWorkspaceFile( + workspace.workspaceId, + attribution.attributedUserId, + content, + input.name, + input.contentType, + { + folderId: input.folderId, + folderPath: input.folderPath, + exactName: input.exactName, + secretProvenance: input.secretProvenance ?? EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, + } + ) + } catch (error) { + if (error instanceof FileConflictError || getPostgresErrorCode(error) === '23505') { + throw new OrchestrationError('conflict', 'File already exists') + } + throw error + } + + logger.info('Created workspace file', { + workspaceId: workspace.workspaceId, + fileId: file.id, + folderId: file.folderId, + size: file.size, + principalKind: principal.kind, + }) + return { file } +} + +function projectCreateWorkspaceFileAudit(result: CreateWorkspaceFileResult) { + return { + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Uploaded file "${result.file.name}"`, + metadata: { + fileSize: result.file.size, + fileType: result.file.type, + }, + } as const +} + +const admitCreateWorkspaceFileUseCase = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.create, + resolveContext: ({ input }: { input: { workspaceId: string } }) => + resolveCreateWorkspaceFileContext(input.workspaceId), + async execute() {}, +}) + +export async function admitCreateWorkspaceFile( + principal: Principal, + workspaceId: string +): Promise { + await admitCreateWorkspaceFileUseCase.execute({ principal, input: { workspaceId } }) +} + +export const createWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.create, + resolveContext: ({ input }: { input: CreateWorkspaceFileInput }) => + resolveCreateWorkspaceFileContext(input.workspaceId), + async execute({ principal, input, context }): Promise { + const content = Buffer.from(input.content, input.encoding === 'base64' ? 'base64' : 'utf-8') + if (content.length > MAX_WORKSPACE_FILE_CONTENT_BYTES) { + throw new OrchestrationError( + 'payload_too_large', + `File size exceeds ${MAX_WORKSPACE_FILE_CONTENT_BYTES / 1024 / 1024}MB limit` + ) + } + return createAuthorizedWorkspaceFile({ principal, input, content, workspace: context }) + }, + projectAudit: ({ result }) => projectCreateWorkspaceFileAudit(result), +}) + +export const createWorkspaceFileFromBuffer = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.create, + resolveContext: ({ input }: { input: CreateWorkspaceFileBufferInput }) => + resolveCreateWorkspaceFileContext(input.workspaceId), + execute: ({ principal, input, context }) => + createAuthorizedWorkspaceFile({ + principal, + input, + content: input.content, + workspace: context, + }), + projectAudit: ({ result }) => projectCreateWorkspaceFileAudit(result), +}) diff --git a/apps/sim/lib/workspace-files/application/csv-preview-workspace-file.ts b/apps/sim/lib/workspace-files/application/csv-preview-workspace-file.ts new file mode 100644 index 00000000000..da862465443 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/csv-preview-workspace-file.ts @@ -0,0 +1,36 @@ +import type { Principal } from '@sim/auth/principal' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getCsvPreviewSlice } from '@/lib/file-parsers/csv-preview-slice' +import { readWorkspaceFileContentRecord } from '@/lib/workspace-files/application/read-workspace-file-record' + +export interface CsvPreviewWorkspaceFileInput { + fileId: string + assertedWorkspaceId?: string + key: string + signal?: AbortSignal +} + +async function executeCsvPreviewWorkspaceFile({ + principal, + input, + request, +}: { + principal: Principal + input: CsvPreviewWorkspaceFileInput + request?: OrchestrationRequestContext +}) { + const { file } = await readWorkspaceFileContentRecord.execute({ principal, input, request }) + if (file.key !== input.key) throw new OrchestrationError('not_found', 'File not found') + const slice = await getCsvPreviewSlice({ + key: file.key, + context: 'workspace', + signal: input.signal, + }) + return { success: true as const, ...slice } +} + +export const csvPreviewWorkspaceFile = { + operation: readWorkspaceFileContentRecord.operation, + execute: executeCsvPreviewWorkspaceFile, +} as const diff --git a/apps/sim/lib/workspace-files/application/delegated-principal.ts b/apps/sim/lib/workspace-files/application/delegated-principal.ts new file mode 100644 index 00000000000..7cb5040c86c --- /dev/null +++ b/apps/sim/lib/workspace-files/application/delegated-principal.ts @@ -0,0 +1,39 @@ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' + +const WORKSPACE_FILE_DELEGATION_TTL_MS = 5 * 60 * 1000 + +export interface WorkspaceFileDelegationInput { + serviceId: DelegatedPrincipal['serviceId'] + subjectUserId: string + workspaceId: string + delegationId: string + fileId?: string + chatId?: string + executionId?: string +} + +/** Binds a trusted service execution to the shared workspace-file principal shape. */ +export function createWorkspaceFileDelegatedPrincipal( + input: WorkspaceFileDelegationInput +): DelegatedPrincipal { + if (!input.subjectUserId || !input.workspaceId || !input.delegationId) { + throw new Error('Workspace file delegation requires subject, workspace, and delegation IDs') + } + const issuedAt = new Date() + return { + kind: 'delegated', + serviceId: input.serviceId, + subjectUserId: input.subjectUserId, + workspaceId: input.workspaceId, + delegationId: input.delegationId, + audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, + issuedAt, + expiresAt: new Date(issuedAt.getTime() + WORKSPACE_FILE_DELEGATION_TTL_MS), + resourceScope: { + ...(input.fileId ? { fileId: input.fileId } : {}), + ...(input.chatId ? { chatId: input.chatId } : {}), + ...(input.executionId ? { executionId: input.executionId } : {}), + }, + } +} diff --git a/apps/sim/lib/workspace-files/application/delete-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/delete-workspace-file.test.ts new file mode 100644 index 00000000000..21058040263 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/delete-workspace-file.test.ts @@ -0,0 +1,85 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadContext: vi.fn(), + deleteStored: vi.fn(), + resolvePermission: vi.fn(), + recordAudit: vi.fn(), + notify: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_DELETED: 'FILE_DELETED' }, + AuditResourceType: { FILE: 'FILE' }, + recordAudit: mocks.recordAudit, +})) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mocks.notify })) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + deleteWorkspaceFile: mocks.deleteStored, + loadActiveWorkspaceFileContext: mocks.loadContext, +})) + +import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' + +const context = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', +} + +describe('deleteWorkspaceFileOperation', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(context) + mocks.deleteStored.mockResolvedValue(undefined) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.notify.mockResolvedValue(undefined) + }) + + it('authorizes, archives, audits, and notifies once', async () => { + const result = await deleteWorkspaceFileOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + + expect(result).toEqual({ id: 'file-1', workspaceId: 'workspace-1', deleted: true }) + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { includeDeleted: undefined }) + expect(mocks.resolvePermission).toHaveBeenCalled() + expect(mocks.deleteStored).toHaveBeenCalledWith('workspace-1', 'file-1') + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + actorId: 'user-1', + metadata: expect.objectContaining({ operation: 'files.delete' }), + }) + ) + expect(mocks.notify).toHaveBeenCalledWith('workspace-1') + expect(mocks.recordAudit.mock.invocationCallOrder[0]).toBeLessThan( + mocks.notify.mock.invocationCallOrder[0] + ) + }) + + it('does not emit side effects when storage fails', async () => { + const failure = new Error('storage unavailable') + mocks.deleteStored.mockRejectedValueOnce(failure) + + await expect( + deleteWorkspaceFileOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1' }, + }) + ).rejects.toBe(failure) + expect(mocks.recordAudit).not.toHaveBeenCalled() + expect(mocks.notify).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/delete-workspace-file.ts b/apps/sim/lib/workspace-files/application/delete-workspace-file.ts new file mode 100644 index 00000000000..1e29b040c83 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/delete-workspace-file.ts @@ -0,0 +1,56 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { createLogger } from '@sim/logger' +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { + type ActiveWorkspaceFileContext, + deleteWorkspaceFile, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' + +const logger = createLogger('DeleteWorkspaceFile') + +export interface DeleteWorkspaceFileInput { + fileId: string + assertedWorkspaceId?: string +} + +export interface DeleteWorkspaceFileResult { + id: string + workspaceId: string + deleted: true +} + +async function executeDeleteWorkspaceFile({ + principal, + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.delete, + DeleteWorkspaceFileInput, + ActiveWorkspaceFileContext +>): Promise { + await deleteWorkspaceFile(context.workspaceId, context.fileId) + return { id: context.fileId, workspaceId: context.workspaceId, deleted: true } +} + +export const deleteWorkspaceFileOperation = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.delete, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), + execute: executeDeleteWorkspaceFile, + projectAudit: ({ result }) => ({ + action: AuditAction.FILE_DELETED, + resourceType: AuditResourceType.FILE, + resourceId: result.id, + description: `Deleted workspace file ${result.id}`, + }), + async afterSuccess({ principal, result }) { + await notifyWorkspaceFilesChanged(result.workspaceId) + logger.info('Deleted workspace file', { + workspaceId: result.workspaceId, + fileId: result.id, + principalKind: principal.kind, + }) + }, +}) diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts new file mode 100644 index 00000000000..459baa164cb --- /dev/null +++ b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts @@ -0,0 +1,198 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + events, + mockLoadContext, + mockResolvePermission, + mockListFiles, + mockListFolders, + mockFetchServable, + mockRecordAudit, + mockIsGenerated, + mockIsRenderable, + mockIsDocNotReady, +} = vi.hoisted(() => ({ + events: [] as string[], + mockLoadContext: vi.fn(), + mockResolvePermission: vi.fn(), + mockListFiles: vi.fn(), + mockListFolders: vi.fn(), + mockFetchServable: vi.fn(), + mockRecordAudit: vi.fn(), + mockIsGenerated: vi.fn(), + mockIsRenderable: vi.fn(), + mockIsDocNotReady: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + buildWorkspaceFileFolderPathMap: (folders: Array<{ id: string; path?: string; name: string }>) => + new Map(folders.map((folder) => [folder.id, folder.path ?? folder.name])), + fetchServableWorkspaceFileBuffer: mockFetchServable, + listWorkspaceFileFolders: mockListFolders, + listWorkspaceFiles: mockListFiles, + loadWorkspaceFileOperationContext: mockLoadContext, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mockResolvePermission, +})) +vi.mock('@/lib/uploads/utils/file-utils', () => ({ + formatFileSize: (bytes: number) => `${bytes} bytes`, + isGeneratedDocumentSourceType: mockIsGenerated, + isRenderableDocumentName: mockIsRenderable, + MAX_RENDERED_DOCUMENT_BYTES: 50 * 1024 * 1024, +})) +vi.mock('@/lib/uploads/utils/servable-file-response', () => ({ + docNotReadyMessage: (names: string[]) => `Pending: ${names.join(', ')}`, + isDocNotReadyError: mockIsDocNotReady, +})) +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_DOWNLOADED: 'file.downloaded' }, + AuditResourceType: { FILE: 'file' }, + recordAudit: mockRecordAudit, +})) + +import { downloadWorkspaceFileItems } from '@/lib/workspace-files/application/download-workspace-file-items' + +const principal = { kind: 'session' as const, userId: 'u1', sessionId: 's1' } +const delegatedPrincipal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'u1', + workspaceId: 'ws-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), + resourceScope: { fileId: 'f1' }, +} +const workspace = { + workspaceId: 'ws-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', +} + +function file(id: string, name: string, folderId: string | null = null, size = 10) { + return { id, name, folderId, size, type: 'application/octet-stream', key: `key-${id}` } +} + +describe('downloadWorkspaceFileItems', () => { + beforeEach(() => { + vi.clearAllMocks() + events.length = 0 + mockLoadContext.mockImplementation(async () => { + events.push('resolve') + return workspace + }) + mockResolvePermission.mockImplementation(async () => { + events.push('authorize') + return 'read' + }) + mockListFiles.mockImplementation(async () => { + events.push('execute') + return [file('f1', 'clip.mp4')] + }) + mockListFolders.mockResolvedValue([]) + mockIsGenerated.mockReturnValue(false) + mockIsRenderable.mockReturnValue(false) + mockIsDocNotReady.mockReturnValue(false) + }) + + it('authorizes the workspace once and returns the bounded selection', async () => { + const result = await downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: ['f1'], folderIds: [] }, + }) + + expect(events).toEqual(['resolve', 'authorize', 'execute']) + expect(result.filesToZip).toHaveLength(1) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ actorId: 'u1', workspaceId: 'ws-1' }) + ) + }) + + it('allows a file-scoped delegated principal to download its one explicit file', async () => { + const result = await downloadWorkspaceFileItems.execute({ + principal: delegatedPrincipal, + input: { workspaceId: 'ws-1', fileIds: ['f1'], folderIds: [] }, + }) + + expect(result.filesToZip.map((item) => item.id)).toEqual(['f1']) + expect(mockResolvePermission).toHaveBeenCalledOnce() + }) + + it.each([ + { label: 'multiple files', fileIds: ['f1', 'f2'], folderIds: [] }, + { label: 'a folder', fileIds: [], folderIds: ['folder-1'] }, + { label: 'a file and folder', fileIds: ['f1'], folderIds: ['folder-1'] }, + ])('denies a file-scoped delegated principal selecting $label before listing', async (input) => { + await expect( + downloadWorkspaceFileItems.execute({ + principal: delegatedPrincipal, + input: { workspaceId: 'ws-1', ...input }, + }) + ).rejects.toThrow('Delegated workspace access is no longer valid') + + expect(mockResolvePermission).not.toHaveBeenCalled() + expect(mockListFiles).not.toHaveBeenCalled() + expect(mockListFolders).not.toHaveBeenCalled() + }) + + it('expands selected folders and renders generated documents before returning', async () => { + mockListFolders.mockResolvedValue([ + { id: 'folder-1', name: 'Reports', path: 'Reports', parentId: null }, + { id: 'folder-2', name: 'Drafts', path: 'Reports/Drafts', parentId: 'folder-1' }, + ]) + mockListFiles.mockResolvedValue([file('f1', 'report.docx', 'folder-2')]) + mockIsGenerated.mockReturnValue(true) + mockFetchServable.mockResolvedValue({ buffer: Buffer.from('rendered') }) + + const result = await downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: [], folderIds: ['folder-1'] }, + }) + + expect(result.filesToZip[0].id).toBe('f1') + expect(result.renderedDocuments.get('f1')).toEqual(Buffer.from('rendered')) + expect(mockFetchServable).toHaveBeenCalledWith(expect.objectContaining({ id: 'f1' }), { + maxBytes: 50 * 1024 * 1024, + }) + }) + + it('returns typed validation and conflict failures without recording audit', async () => { + await expect( + downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: [], folderIds: [] }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(events).toEqual(['resolve', 'authorize']) + + mockListFiles.mockResolvedValue([file('f1', 'pending.docx')]) + mockIsGenerated.mockReturnValue(true) + mockIsDocNotReady.mockReturnValue(true) + mockFetchServable.mockRejectedValue(new Error('pending')) + await expect( + downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: ['f1'], folderIds: [] }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('rejects unknown selected IDs rather than exposing another workspace selection', async () => { + mockListFiles.mockResolvedValue([]) + await expect( + downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: ['other-workspace-file'], folderIds: [] }, + }) + ).rejects.toEqual(expect.objectContaining({ code: 'not_found' })) + }) +}) diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts b/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts new file mode 100644 index 00000000000..4509350875a --- /dev/null +++ b/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts @@ -0,0 +1,183 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { + buildWorkspaceFileFolderPathMap, + fetchServableWorkspaceFileBuffer, + listWorkspaceFileFolders, + listWorkspaceFiles, + loadWorkspaceFileOperationContext, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace' +import { + formatFileSize, + isGeneratedDocumentSourceType, + isRenderableDocumentName, + MAX_RENDERED_DOCUMENT_BYTES, +} from '@/lib/uploads/utils/file-utils' +import { docNotReadyMessage, isDocNotReadyError } from '@/lib/uploads/utils/servable-file-response' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +export const MAX_ZIP_DOWNLOAD_FILES = 100 +export const MAX_ZIP_DOWNLOAD_BYTES = 250 * 1024 * 1024 +const MAX_REQUESTED_FILE_IDS = 1_000 +const MAX_REQUESTED_FOLDER_IDS = 1_000 + +export interface DownloadWorkspaceFileItemsInput { + workspaceId: string + fileIds: string[] + folderIds: string[] +} + +export interface DownloadWorkspaceFileItemsResult { + filesToZip: WorkspaceFileRecord[] + folderPaths: Map + renderedDocuments: Map + declaredBytes: number +} + +function needsRendering(file: WorkspaceFileRecord): boolean { + return file.type ? isGeneratedDocumentSourceType(file.type) : isRenderableDocumentName(file.name) +} + +function collectDescendantFolderIds( + selectedFolderIds: string[], + folders: Array<{ id: string; parentId: string | null }> +): Set { + const folderIds = new Set(selectedFolderIds) + let changed = true + while (changed) { + changed = false + for (const folder of folders) { + if (folder.parentId && folderIds.has(folder.parentId) && !folderIds.has(folder.id)) { + folderIds.add(folder.id) + changed = true + } + } + } + return folderIds +} + +function validationError(message: string): never { + throw new OrchestrationError('validation', message) +} + +async function executeDownloadWorkspaceFileItems({ + input, + context, +}: { + input: DownloadWorkspaceFileItemsInput + context: Awaited> +}): Promise { + const fileIds = [...new Set(input.fileIds)] + const folderIds = [...new Set(input.folderIds)] + if (fileIds.length > MAX_REQUESTED_FILE_IDS) { + validationError(`Too many file IDs selected. Select ${MAX_REQUESTED_FILE_IDS} or fewer files.`) + } + if (folderIds.length > MAX_REQUESTED_FOLDER_IDS) { + validationError( + `Too many folder IDs selected. Select ${MAX_REQUESTED_FOLDER_IDS} or fewer folders.` + ) + } + if (fileIds.length === 0 && folderIds.length === 0) { + validationError('No files selected for download') + } + + const [files, folders] = await Promise.all([ + listWorkspaceFiles(context.workspaceId, { hydrateFolderPaths: false, throwOnError: true }), + listWorkspaceFileFolders(context.workspaceId), + ]) + const folderPaths = buildWorkspaceFileFolderPathMap(folders) + const knownFileIds = new Set(files.map((file) => file.id)) + const knownFolderIds = new Set(folders.map((folder) => folder.id)) + if ( + fileIds.some((fileId) => !knownFileIds.has(fileId)) || + folderIds.some((folderId) => !knownFolderIds.has(folderId)) + ) { + throw new OrchestrationError('not_found', 'File selection not found') + } + const selectedFolderIds = collectDescendantFolderIds(folderIds, folders) + const requestedFileIds = new Set(fileIds) + const filesToZip = files.filter( + (file) => + requestedFileIds.has(file.id) || + (file.folderId != null && selectedFolderIds.has(file.folderId)) + ) + + if (filesToZip.length === 0) validationError('No files selected for download') + if (filesToZip.length > MAX_ZIP_DOWNLOAD_FILES) { + validationError( + `Too many files selected for download. Select ${MAX_ZIP_DOWNLOAD_FILES} or fewer files.` + ) + } + + const declaredBytes = filesToZip.reduce((sum, file) => sum + file.size, 0) + if (declaredBytes > MAX_ZIP_DOWNLOAD_BYTES) { + validationError( + `Selected files total ${formatFileSize(declaredBytes)}, which exceeds the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit.` + ) + } + + const reservedForStreamed = filesToZip + .filter((file) => !needsRendering(file)) + .reduce((sum, file) => sum + file.size, 0) + const renderedDocuments = new Map() + const pendingNames: string[] = [] + let renderedBytes = 0 + + for (const file of filesToZip) { + if (!needsRendering(file)) continue + const remaining = Math.max(0, MAX_ZIP_DOWNLOAD_BYTES - reservedForStreamed - renderedBytes) + const allowance = Math.min(remaining, MAX_RENDERED_DOCUMENT_BYTES) + try { + const { buffer } = await fetchServableWorkspaceFileBuffer(file, { maxBytes: allowance }) + renderedBytes += buffer.length + renderedDocuments.set(file.id, buffer) + } catch (error) { + if (error instanceof PayloadSizeLimitError) { + validationError( + allowance === MAX_RENDERED_DOCUMENT_BYTES + ? `"${file.name}" renders to more than ${formatFileSize(MAX_RENDERED_DOCUMENT_BYTES)} and is too large to include in a zip; download it on its own instead.` + : `The selected files exceed the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit once documents are rendered. Select fewer files.` + ) + } + if (!isDocNotReadyError(error)) throw error + pendingNames.push(file.name) + } + } + + if (pendingNames.length > 0) { + throw new OrchestrationError('conflict', docNotReadyMessage(pendingNames)) + } + + return { filesToZip, folderPaths, renderedDocuments, declaredBytes } +} + +async function resolveDownloadContext({ input }: { input: DownloadWorkspaceFileItemsInput }) { + const context = await loadWorkspaceFileOperationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + const fileIds = [...new Set(input.fileIds)] + const folderIds = [...new Set(input.folderIds)] + return { + ...context, + fileId: fileIds.length === 1 && folderIds.length === 0 ? fileIds[0] : undefined, + } +} + +export const downloadWorkspaceFileItems = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.download, + resolveContext: resolveDownloadContext, + execute: executeDownloadWorkspaceFileItems, + projectAudit({ result }) { + return { + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + description: `Downloaded ${result.filesToZip.length} file${result.filesToZip.length === 1 ? '' : 's'} as zip`, + metadata: { + fileCount: result.filesToZip.length, + totalBytes: result.declaredBytes, + }, + } + }, +}) diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/download-workspace-file.test.ts new file mode 100644 index 00000000000..37e932ca15f --- /dev/null +++ b/apps/sim/lib/workspace-files/application/download-workspace-file.test.ts @@ -0,0 +1,104 @@ +/** + * @vitest-environment node + */ +import { Readable } from 'node:stream' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + downloadStream: vi.fn(), + getFile: vi.fn(), + loadContext: vi.fn(), + recordAudit: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_DOWNLOADED: 'FILE_DOWNLOADED' }, + AuditResourceType: { FILE: 'FILE' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFile: mocks.getFile, + loadActiveWorkspaceFileContext: mocks.loadContext, +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + downloadFileStream: mocks.downloadStream, +})) + +import { + downloadWorkspaceFile, + downloadWorkspaceFileStream, +} from '@/lib/workspace-files/application/download-workspace-file' + +const context = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', +} + +const file = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'report.pdf', + key: 'workspace/workspace-1/report.pdf', + size: 42, + storageContext: 'workspace', +} + +describe('workspace file downloads', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.getFile.mockResolvedValue(file) + mocks.downloadStream.mockResolvedValue(Readable.from(Buffer.from('pdf'))) + }) + + it('returns the authoritative file and records its semantic download audit', async () => { + await expect( + downloadWorkspaceFile.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ file }) + + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + actorId: 'user-1', + action: 'FILE_DOWNLOADED', + resourceId: 'file-1', + resourceName: 'report.pdf', + metadata: expect.objectContaining({ + operation: 'files.download', + fileId: 'file-1', + fileName: 'report.pdf', + bytes: 42, + }), + }) + ) + }) + + it('does not audit a streaming download when storage acquisition fails', async () => { + const failure = new Error('storage unavailable') + mocks.downloadStream.mockRejectedValueOnce(failure) + + await expect( + downloadWorkspaceFileStream.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1' }, + }) + ).rejects.toBe(failure) + + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file.ts b/apps/sim/lib/workspace-files/application/download-workspace-file.ts new file mode 100644 index 00000000000..70be04fbe93 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/download-workspace-file.ts @@ -0,0 +1,87 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' +import { + type ActiveWorkspaceFileContext, + getWorkspaceFile, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { downloadFileStream } from '@/lib/uploads/core/storage-service' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' + +export interface DownloadWorkspaceFileInput { + fileId: string + assertedWorkspaceId?: string +} + +export interface DownloadWorkspaceFileResult { + file: NonNullable>> +} + +export interface DownloadWorkspaceFileStreamResult extends DownloadWorkspaceFileResult { + stream: ReadableStream +} + +function projectDownloadAudit(file: DownloadWorkspaceFileResult['file']) { + return { + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + resourceId: file.id, + resourceName: file.name, + description: `Downloaded file "${file.name}"`, + metadata: { + fileId: file.id, + fileName: file.name, + bytes: file.size, + }, + } +} + +async function executeDownloadWorkspaceFile({ + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.download, + DownloadWorkspaceFileInput, + ActiveWorkspaceFileContext +>): Promise { + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return { file } +} + +export const downloadWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.download, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), + execute: executeDownloadWorkspaceFile, + projectAudit: ({ result }) => projectDownloadAudit(result.file), +}) + +async function executeDownloadWorkspaceFileStream({ + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.download, + DownloadWorkspaceFileInput, + ActiveWorkspaceFileContext +>): Promise { + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + const stream = await downloadFileStream({ + key: file.key, + context: file.storageContext ?? 'workspace', + }) + return { file, stream: nodeReadableToWebStream(stream) } +} + +/** Authorized and audited binary download without materializing the file in memory. */ +export const downloadWorkspaceFileStream = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.download, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), + execute: executeDownloadWorkspaceFileStream, + projectAudit: ({ result }) => projectDownloadAudit(result.file), +}) diff --git a/apps/sim/lib/workspace-files/application/list-workspace-files.ts b/apps/sim/lib/workspace-files/application/list-workspace-files.ts new file mode 100644 index 00000000000..bbf9924e47c --- /dev/null +++ b/apps/sim/lib/workspace-files/application/list-workspace-files.ts @@ -0,0 +1,75 @@ +import type { CursorKey } from '@/lib/api/list-query' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { getWorkspaceShares } from '@/lib/public-shares/share-manager' +import { + listWorkspaceFiles, + loadActiveWorkspaceContext, + queryWorkspaceFiles, +} from '@/lib/uploads/contexts/workspace' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +export interface ListAllWorkspaceFilesInput { + workspaceId: string + scope: 'active' | 'archived' | 'all' +} + +export interface QueryWorkspaceFilePageInput { + workspaceId: string + folderPath?: string + search?: string + sortBy: 'name' | 'size' | 'uploadedAt' | 'updatedAt' + sortOrder: 'asc' | 'desc' + limit: number + after?: CursorKey[] + cursorSort: string +} + +async function resolveListWorkspaceFileContext(workspaceId: string) { + const workspace = await loadActiveWorkspaceContext(workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + return workspace +} + +export const listAllWorkspaceFiles = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.list, + resolveContext: ({ input }: { input: ListAllWorkspaceFilesInput }) => + resolveListWorkspaceFileContext(input.workspaceId), + async execute({ input, context }) { + const files = await listWorkspaceFiles(context.workspaceId, { scope: input.scope }) + const shares = await getWorkspaceShares('file', context.workspaceId) + return { + files: files.map((file) => ({ ...file, share: shares.get(file.id) ?? null })), + } + }, +}) + +export const queryWorkspaceFilePage = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.list, + resolveContext: ({ input }: { input: QueryWorkspaceFilePageInput }) => + resolveListWorkspaceFileContext(input.workspaceId), + async execute({ input, context }) { + const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'file') + const folderId = + input.folderPath === undefined + ? undefined + : input.folderPath === ROOT_FOLDER_PATH + ? null + : folderIndex.idByPath.get(input.folderPath) + if (input.folderPath !== undefined && folderId === undefined) { + throw new OrchestrationError('not_found', 'Folder not found') + } + + const { files, nextKeys } = await queryWorkspaceFiles(context.workspaceId, { + folderId, + search: input.search, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + limit: input.limit, + after: input.after, + }) + return { files, nextKeys, cursorSort: input.cursorSort } + }, +}) diff --git a/apps/sim/lib/workspace-files/application/move-workspace-file-items.test.ts b/apps/sim/lib/workspace-files/application/move-workspace-file-items.test.ts new file mode 100644 index 00000000000..61ced7919d4 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/move-workspace-file-items.test.ts @@ -0,0 +1,168 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + events, + mockLoadContext, + mockResolvePermission, + mockAssertItems, + mockMove, + mockAudit, + mockNotify, +} = vi.hoisted(() => ({ + events: [] as string[], + mockLoadContext: vi.fn(), + mockResolvePermission: vi.fn(), + mockAssertItems: vi.fn(), + mockMove: vi.fn(), + mockAudit: vi.fn(), + mockNotify: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + assertWorkspaceFileItemsBelongToWorkspace: mockAssertItems, + loadWorkspaceFileOperationContext: mockLoadContext, + moveWorkspaceFileItems: mockMove, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mockResolvePermission, +})) +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_MOVED: 'file.moved', FOLDER_MOVED: 'folder.moved' }, + AuditResourceType: { FILE: 'file', FOLDER: 'folder' }, + recordAudit: mockAudit, +})) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mockNotify })) + +import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' + +describe('moveWorkspaceFileItemsOperation', () => { + beforeEach(() => { + vi.clearAllMocks() + events.length = 0 + mockLoadContext.mockImplementation(async () => { + events.push('resolve') + return { + workspaceId: 'ws-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', + } + }) + mockResolvePermission.mockImplementation(async () => { + events.push('authorize') + return 'write' + }) + mockAssertItems.mockImplementation(async () => { + events.push('execute') + }) + mockMove.mockImplementation(async () => ({ + movedFiles: 2, + movedFolders: 1, + movedFileIds: ['file-1', 'file-2'], + movedFolderIds: ['folder-1'], + })) + }) + + it('uses the atomic manager primitive and records each semantic category once', async () => { + const result = await moveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'ws-1', + fileIds: ['file-1', 'file-2'], + folderIds: ['folder-1'], + targetFolderId: null, + }, + }) + + expect(result).toMatchObject({ movedItems: { files: 2, folders: 1 } }) + expect(events).toEqual(['resolve', 'authorize', 'execute']) + expect(mockAssertItems).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + fileIds: ['file-1', 'file-2'], + folderIds: ['folder-1'], + }) + expect(mockMove).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + fileIds: ['file-1', 'file-2'], + folderIds: ['folder-1'], + targetFolderId: null, + targetFolderPath: undefined, + }) + expect(mockAudit).toHaveBeenCalledTimes(2) + expect(mockNotify).toHaveBeenCalledOnce() + }) + + it('allows authorization to carry a resource ID only for one explicit file', async () => { + mockMove.mockResolvedValue({ + movedFiles: 1, + movedFolders: 0, + movedFileIds: ['file-1'], + movedFolderIds: [], + }) + + await moveWorkspaceFileItemsOperation.execute({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'ws-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), + resourceScope: { fileId: 'file-1' }, + }, + input: { workspaceId: 'ws-1', fileIds: ['file-1'], targetFolderId: null }, + }) + + expect(mockResolvePermission).toHaveBeenCalledOnce() + }) + + it('does not audit or notify requested IDs absent from the mutation result', async () => { + mockMove.mockResolvedValue({ + movedFiles: 0, + movedFolders: 0, + movedFileIds: [], + movedFolderIds: [], + }) + + const result = await moveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1', fileIds: ['file-1'], targetFolderId: null }, + }) + + expect(result).toMatchObject({ movedItems: { files: 0, folders: 0 } }) + expect(mockAudit).not.toHaveBeenCalled() + expect(mockNotify).not.toHaveBeenCalled() + }) + + it('authorizes before rejecting an empty selection without touching storage', async () => { + await expect( + moveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1' }, + }) + ).rejects.toThrow('At least one file or folder must be selected') + expect(events).toEqual(['resolve', 'authorize']) + expect(mockMove).not.toHaveBeenCalled() + }) + + it('rejects oversized selections after authorization', async () => { + await expect( + moveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'ws-1', + folderIds: Array.from({ length: 1_001 }, (_, index) => `folder-${index}`), + }, + }) + ).rejects.toThrow('accept at most 1000') + expect(events).toEqual(['resolve', 'authorize']) + expect(mockMove).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/move-workspace-file-items.ts b/apps/sim/lib/workspace-files/application/move-workspace-file-items.ts new file mode 100644 index 00000000000..5e8a998e335 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/move-workspace-file-items.ts @@ -0,0 +1,127 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { + assertWorkspaceFileItemsBelongToWorkspace, + loadWorkspaceFileOperationContext, + moveWorkspaceFileItems, +} from '@/lib/uploads/contexts/workspace' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { MAX_WORKSPACE_FILE_BULK_REQUEST_IDS } from '@/lib/workspace-files/limits' + +const logger = createLogger('MoveWorkspaceFileItems') + +export interface MoveWorkspaceFileItemsInput { + workspaceId: string + fileIds?: string[] + folderIds?: string[] + targetFolderId?: string | null + targetFolderPath?: string +} + +export interface MoveWorkspaceFileItemsResult { + movedItems: { files: number; folders: number } + affectedIds: { fileIds: string[]; folderIds: string[] } +} + +function normalizeSelection(input: MoveWorkspaceFileItemsInput) { + return { + fileIds: [...new Set(input.fileIds ?? [])], + folderIds: [...new Set(input.folderIds ?? [])], + } +} + +async function executeMoveWorkspaceFileItems({ + input, + context, +}: { + input: MoveWorkspaceFileItemsInput + context: Awaited> +}): Promise { + const { fileIds, folderIds } = normalizeSelection(input) + if (fileIds.length === 0 && folderIds.length === 0) { + throw new OrchestrationError('validation', 'At least one file or folder must be selected') + } + if ( + fileIds.length > MAX_WORKSPACE_FILE_BULK_REQUEST_IDS || + folderIds.length > MAX_WORKSPACE_FILE_BULK_REQUEST_IDS + ) { + throw new OrchestrationError( + 'validation', + `Bulk file operations accept at most ${MAX_WORKSPACE_FILE_BULK_REQUEST_IDS} file and folder IDs` + ) + } + + await assertWorkspaceFileItemsBelongToWorkspace({ + workspaceId: context.workspaceId, + fileIds, + folderIds, + }) + const moved = await moveWorkspaceFileItems({ + workspaceId: context.workspaceId, + fileIds, + folderIds, + targetFolderId: input.targetFolderId, + targetFolderPath: input.targetFolderPath, + }) + const movedItems = { files: moved.movedFileIds.length, folders: moved.movedFolderIds.length } + + logger.info('Moved workspace file items', { workspaceId: context.workspaceId, movedItems }) + return { + movedItems, + affectedIds: { fileIds: moved.movedFileIds, folderIds: moved.movedFolderIds }, + } +} + +async function resolveMoveContext({ input }: { input: MoveWorkspaceFileItemsInput }) { + const context = await loadWorkspaceFileOperationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + const { fileIds, folderIds } = normalizeSelection(input) + return { + ...context, + fileId: fileIds.length === 1 && folderIds.length === 0 ? fileIds[0] : undefined, + } +} + +export const moveWorkspaceFileItemsOperation = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.move, + resolveContext: resolveMoveContext, + execute: executeMoveWorkspaceFileItems, + projectAudit({ input, result }) { + const entries = [] + if (result.affectedIds.fileIds.length > 0) { + entries.push({ + action: AuditAction.FILE_MOVED, + resourceType: AuditResourceType.FILE, + description: `Moved ${result.affectedIds.fileIds.length} file${result.affectedIds.fileIds.length === 1 ? '' : 's'}`, + metadata: { + fileIds: result.affectedIds.fileIds, + targetFolderId: input.targetFolderId, + targetFolderPath: input.targetFolderPath, + }, + }) + } + if (result.affectedIds.folderIds.length > 0) { + entries.push({ + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: + result.affectedIds.folderIds.length === 1 ? result.affectedIds.folderIds[0] : undefined, + description: `Moved ${result.affectedIds.folderIds.length} file folder${result.affectedIds.folderIds.length === 1 ? '' : 's'}`, + metadata: { + folderIds: result.affectedIds.folderIds, + targetFolderId: input.targetFolderId, + targetFolderPath: input.targetFolderPath, + }, + }) + } + return entries + }, + async afterSuccess({ context, result }) { + if (result.affectedIds.fileIds.length > 0 || result.affectedIds.folderIds.length > 0) { + await notifyWorkspaceFilesChanged(context.workspaceId) + } + }, +}) diff --git a/apps/sim/lib/workspace-files/application/operations.test.ts b/apps/sim/lib/workspace-files/application/operations.test.ts new file mode 100644 index 00000000000..82b5bdbd55c --- /dev/null +++ b/apps/sim/lib/workspace-files/application/operations.test.ts @@ -0,0 +1,56 @@ +/** + * @vitest-environment node + */ + +import { permissionSatisfies } from '@sim/platform-authz/workspace' +import { describe, expect, it } from 'vitest' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +describe('file operation registry', () => { + it('keeps every workspace-key operation at or below the fixed write ceiling', () => { + for (const operation of Object.values(fileOperations)) { + expect( + operation.principalKinds.length, + `${operation.id} has no allowed principals` + ).toBeGreaterThan(0) + expect( + new Set(operation.principalKinds).size, + `${operation.id} repeats a principal kind` + ).toBe(operation.principalKinds.length) + expect( + operation.principalKinds.includes('workspace_api_key'), + `${operation.id} has inconsistent workspace API-key declarations` + ).toBe(operation.workspaceApiKey === 'allow') + + if (operation.workspaceApiKey === 'allow') { + expect( + permissionSatisfies('write', operation.minimumRole), + `${operation.id} exceeds the workspace API-key write ceiling` + ).toBe(true) + } + } + }) + + it('uses unique stable operation IDs', () => { + const ids = Object.values(fileOperations).map((operation) => operation.id) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('keeps external sharing policy changes human-delegated', () => { + expect(fileOperations.updateShare.workspaceApiKey).toBe('deny') + expect(fileOperations.updateShare.principalKinds).toEqual([ + 'session', + 'personal_api_key', + 'delegated', + ]) + }) + + it('restricts compiled checks to authenticated sessions', () => { + expect(fileOperations.compiledCheck).toMatchObject({ + id: 'files.compiled_check', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }) + }) +}) diff --git a/apps/sim/lib/workspace-files/application/operations.ts b/apps/sim/lib/workspace-files/application/operations.ts new file mode 100644 index 00000000000..cd4f6179b23 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/operations.ts @@ -0,0 +1,152 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +const ALL_PRINCIPAL_KINDS = [ + 'session', + 'personal_api_key', + 'workspace_api_key', + 'delegated', +] as const +const HUMAN_PRINCIPAL_KINDS = ['session', 'personal_api_key', 'delegated'] as const + +export const fileOperations = { + list: defineWorkspaceOperation({ + id: 'files.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + readMetadata: defineWorkspaceOperation({ + id: 'files.read_metadata', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + readContent: defineWorkspaceOperation({ + id: 'files.read_content', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + download: defineWorkspaceOperation({ + id: 'files.download', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + compiledCheck: defineWorkspaceOperation({ + id: 'files.compiled_check', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + create: defineWorkspaceOperation({ + id: 'files.create', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + rename: defineWorkspaceOperation({ + id: 'files.rename', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + updateContent: defineWorkspaceOperation({ + id: 'files.update_content', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + updateMetadata: defineWorkspaceOperation({ + id: 'files.update_metadata', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + move: defineWorkspaceOperation({ + id: 'files.move', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + delete: defineWorkspaceOperation({ + id: 'files.delete', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + restore: defineWorkspaceOperation({ + id: 'files.restore', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + readShare: defineWorkspaceOperation({ + id: 'files.share.read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + updateShare: defineWorkspaceOperation({ + id: 'files.share.update', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: HUMAN_PRINCIPAL_KINDS, + }), + listFolders: defineWorkspaceOperation({ + id: 'files.folders.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + createFolder: defineWorkspaceOperation({ + id: 'files.folders.create', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + updateFolder: defineWorkspaceOperation({ + id: 'files.folders.update', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + deleteFolder: defineWorkspaceOperation({ + id: 'files.folders.delete', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + restoreFolder: defineWorkspaceOperation({ + id: 'files.folders.restore', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + uploadCreate: defineWorkspaceOperation({ + id: 'files.upload.create', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + uploadParts: defineWorkspaceOperation({ + id: 'files.upload.parts', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + uploadComplete: defineWorkspaceOperation({ + id: 'files.upload.complete', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + uploadCancel: defineWorkspaceOperation({ + id: 'files.upload.cancel', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), +} as const + +export type FileOperation = (typeof fileOperations)[keyof typeof fileOperations] diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-content.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-content.test.ts new file mode 100644 index 00000000000..71a666fa392 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-content.test.ts @@ -0,0 +1,87 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + fetchBuffer: vi.fn(), + getFile: vi.fn(), + loadContext: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + fetchWorkspaceFileBuffer: mocks.fetchBuffer, + getWorkspaceFile: mocks.getFile, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + loadActiveWorkspaceFileContext: mocks.loadContext, +})) + +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' + +const context = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', +} + +const file = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'source.txt', + key: 'workspace/workspace-1/source.txt', + size: 12, +} + +describe('readWorkspaceFileContent', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.getFile.mockResolvedValue(file) + mocks.fetchBuffer.mockResolvedValue(Buffer.from('source')) + }) + + it('authorizes the canonical file before performing a bounded content read', async () => { + await expect( + readWorkspaceFileContent.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + fileId: 'file-1', + assertedWorkspaceId: 'workspace-1', + includeDeleted: true, + maxBytes: 512, + }, + }) + ).resolves.toEqual({ file, content: Buffer.from('source') }) + + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { includeDeleted: true }) + expect(mocks.getFile).toHaveBeenCalledWith('workspace-1', 'file-1', { + includeDeleted: true, + throwOnError: true, + }) + expect(mocks.fetchBuffer).toHaveBeenCalledWith(file, { maxBytes: 512 }) + }) + + it('conceals an asserted-workspace mismatch before authorization or storage reads', async () => { + await expect( + readWorkspaceFileContent.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-2' }, + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'File not found' }) + + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.getFile).not.toHaveBeenCalled() + expect(mocks.fetchBuffer).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-content.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-content.ts new file mode 100644 index 00000000000..7ac5d641d13 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-content.ts @@ -0,0 +1,49 @@ +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + type ActiveWorkspaceFileContext, + fetchWorkspaceFileBuffer, + getWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' + +export interface ReadWorkspaceFileContentInput { + fileId: string + assertedWorkspaceId?: string + /** Optional post-authorization storage ceiling for bounded binary reads. */ + maxBytes?: number + includeDeleted?: boolean +} + +export interface ReadWorkspaceFileContentResult { + file: WorkspaceFileRecord + content: Buffer +} + +async function executeReadWorkspaceFileContent({ + input, + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.readContent, + ReadWorkspaceFileContentInput, + ActiveWorkspaceFileContext +>): Promise { + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { + includeDeleted: input.includeDeleted, + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return { + file, + content: await fetchWorkspaceFileBuffer(file, { maxBytes: input.maxBytes }), + } +} + +export const readWorkspaceFileContent = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.readContent, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), + execute: executeReadWorkspaceFileContent, +}) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.test.ts new file mode 100644 index 00000000000..888869e4918 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.test.ts @@ -0,0 +1,85 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadContext: vi.fn(), + getWorkspaceFile: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFile: mocks.getWorkspaceFile, + loadActiveWorkspaceFileContext: mocks.loadContext, +})) + +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' + +const canonical = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const file = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'data.csv', + key: 'workspace/ws/data.csv', + path: '/api/files/serve/data.csv', + size: 42, + type: 'text/csv', + uploadedBy: 'user-1', + folderId: null, + uploadedAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} + +describe('readWorkspaceFileMetadata', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(canonical) + mocks.getWorkspaceFile.mockResolvedValue(file) + mocks.resolvePermission.mockResolvedValue('admin') + }) + + it('returns the canonical active file without side effects', async () => { + const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } + + await expect( + readWorkspaceFileMetadata.execute({ + principal, + input: { + fileId: 'file-1', + assertedWorkspaceId: 'workspace-1', + }, + }) + ).resolves.toEqual({ file }) + + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { + includeDeleted: undefined, + }) + expect(mocks.getWorkspaceFile).toHaveBeenCalledWith('workspace-1', 'file-1', { + throwOnError: true, + }) + }) + + it('fails fast if the authorized file disappears before projection', async () => { + mocks.getWorkspaceFile.mockResolvedValueOnce(null) + + await expect( + readWorkspaceFileMetadata.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) +}) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.ts new file mode 100644 index 00000000000..9fbc52ff850 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.ts @@ -0,0 +1,42 @@ +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + type ActiveWorkspaceFileContext, + getWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' + +export interface ReadWorkspaceFileMetadataInput { + fileId: string + assertedWorkspaceId?: string + includeDeleted?: boolean +} + +export interface ReadWorkspaceFileMetadataResult { + file: WorkspaceFileRecord +} + +async function executeReadWorkspaceFileMetadata({ + input, + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.readMetadata, + ReadWorkspaceFileMetadataInput, + ActiveWorkspaceFileContext +>): Promise { + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { + includeDeleted: input.includeDeleted, + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return { file } +} + +export const readWorkspaceFileMetadata = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.readMetadata, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), + execute: executeReadWorkspaceFileMetadata, +}) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-record.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-record.test.ts new file mode 100644 index 00000000000..4173a836a39 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-record.test.ts @@ -0,0 +1,69 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getFile: vi.fn(), + loadContext: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFile: mocks.getFile, + loadActiveWorkspaceFileContext: mocks.loadContext, +})) + +import { + downloadWorkspaceFileRecord, + readWorkspaceFileContentRecord, +} from '@/lib/workspace-files/application/read-workspace-file-record' + +const context = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', +} + +const file = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'report.pdf', +} + +describe('workspace file record reads', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.getFile.mockResolvedValue(file) + }) + + it.each([ + [readWorkspaceFileContentRecord, 'files.read_content'], + [downloadWorkspaceFileRecord, 'files.download'], + ] as const)( + 'uses the %s operation for its canonical record read', + async (useCase, operationId) => { + await expect( + useCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ file }) + + expect(useCase.operation.id).toBe(operationId) + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { includeDeleted: undefined }) + expect(mocks.getFile).toHaveBeenCalledWith('workspace-1', 'file-1', { + throwOnError: true, + }) + } + ) +}) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-record.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-record.ts new file mode 100644 index 00000000000..f9570ad679a --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-record.ts @@ -0,0 +1,46 @@ +import type { AuthorizedWorkspaceUseCaseContext, WorkspaceOperation } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + type ActiveWorkspaceFileContext, + getWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' + +export interface ReadWorkspaceFileRecordInput { + fileId: string + assertedWorkspaceId?: string +} + +export interface ReadWorkspaceFileRecordResult { + file: WorkspaceFileRecord +} + +function createReadWorkspaceFileRecord(operation: O) { + return defineAuthorizedWorkspaceFileUseCase({ + operation, + resolveContext: ({ input }: { input: ReadWorkspaceFileRecordInput }) => + resolveActiveWorkspaceFileContext(input), + async execute({ + context, + }: AuthorizedWorkspaceUseCaseContext< + O, + ReadWorkspaceFileRecordInput, + ActiveWorkspaceFileContext + >): Promise { + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return { file } + }, + }) +} + +export const readWorkspaceFileContentRecord = createReadWorkspaceFileRecord( + fileOperations.readContent +) + +export const downloadWorkspaceFileRecord = createReadWorkspaceFileRecord(fileOperations.download) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-inline-file.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-inline-file.test.ts new file mode 100644 index 00000000000..f1012bc3c12 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-inline-file.test.ts @@ -0,0 +1,91 @@ +/** + * @vitest-environment node + */ +import { Readable } from 'node:stream' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockLoadContext, mockGetWorkspaceFile, mockGetMetadataByKey, mockDownloadFileStream } = + vi.hoisted(() => ({ + mockLoadContext: vi.fn(), + mockGetWorkspaceFile: vi.fn(), + mockGetMetadataByKey: vi.fn(), + mockDownloadFileStream: vi.fn(), + })) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: vi.fn().mockResolvedValue('admin'), +})) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFile: mockGetWorkspaceFile, + loadActiveWorkspaceFileContext: mockLoadContext, +})) +vi.mock('@/lib/uploads/server/metadata', () => ({ getFileMetadataByKey: mockGetMetadataByKey })) +vi.mock('@/lib/uploads/core/storage-service', () => ({ + downloadFileStream: mockDownloadFileStream, +})) + +import { readWorkspaceInlineFile } from '@/lib/workspace-files/application/read-workspace-inline-file' + +const principal = { kind: 'session' as const, userId: 'u1', sessionId: 's1' } +const file = { + id: 'f1', + workspaceId: 'ws-1', + key: 'workspace/ws-1/photo.png', + name: 'photo.png', + type: 'image/png', +} + +describe('readWorkspaceInlineFile', () => { + beforeEach(() => { + vi.clearAllMocks() + mockLoadContext.mockResolvedValue({ + fileId: 'f1', + workspaceId: 'ws-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', + }) + mockGetWorkspaceFile.mockResolvedValue(file) + mockDownloadFileStream.mockResolvedValue(Readable.from(Buffer.from('png'))) + }) + + it('authorizes a file-id reference against the asserted workspace before reading bytes', async () => { + const result = await readWorkspaceInlineFile.execute({ + principal, + input: { workspaceId: 'ws-1', fileId: 'f1' }, + }) + + expect(mockLoadContext).toHaveBeenCalledWith('f1') + expect(mockDownloadFileStream).toHaveBeenCalledWith({ + key: file.key, + context: 'workspace', + }) + expect(Buffer.from(await new Response(result.stream).arrayBuffer())).toEqual(Buffer.from('png')) + }) + + it('resolves a storage-key reference to its canonical file id before authorizing', async () => { + mockGetMetadataByKey.mockResolvedValue({ id: 'f1', workspaceId: 'ws-1' }) + + await readWorkspaceInlineFile.execute({ + principal, + input: { workspaceId: 'ws-1', key: file.key }, + }) + + expect(mockGetMetadataByKey).toHaveBeenCalledWith(file.key, 'workspace') + expect(mockLoadContext).toHaveBeenCalledWith('f1') + }) + + it('conceals a key belonging to another workspace before authorization', async () => { + mockGetMetadataByKey.mockResolvedValue({ id: 'other', workspaceId: 'ws-other' }) + + await expect( + readWorkspaceInlineFile.execute({ + principal, + input: { workspaceId: 'ws-1', key: 'workspace/ws-other/photo.png' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mockLoadContext).not.toHaveBeenCalled() + expect(mockDownloadFileStream).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-inline-file.ts b/apps/sim/lib/workspace-files/application/read-workspace-inline-file.ts new file mode 100644 index 00000000000..1dba3799607 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-inline-file.ts @@ -0,0 +1,62 @@ +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' +import { + type ActiveWorkspaceFileContext, + getWorkspaceFile, + loadActiveWorkspaceFileContext, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { downloadFileStream } from '@/lib/uploads/core/storage-service' +import { getFileMetadataByKey } from '@/lib/uploads/server/metadata' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +export interface ReadWorkspaceInlineFileInput { + workspaceId: string + key?: string + fileId?: string +} + +export interface ReadWorkspaceInlineFileResult { + file: WorkspaceFileRecord + stream: ReadableStream +} + +async function executeReadWorkspaceInlineFile({ + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.readContent, + ReadWorkspaceInlineFileInput, + ActiveWorkspaceFileContext +>): Promise { + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'Not found') + + const stream = await downloadFileStream({ key: file.key, context: 'workspace' }) + return { file, stream: nodeReadableToWebStream(stream) } +} + +export const readWorkspaceInlineFile = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.readContent, + async resolveContext({ input }) { + let fileId = input.fileId + if (!fileId && input.key) { + const metadata = await getFileMetadataByKey(input.key, 'workspace') + if (!metadata || metadata.workspaceId !== input.workspaceId) { + throw new OrchestrationError('not_found', 'Not found') + } + fileId = metadata.id + } + if (!fileId) throw new OrchestrationError('validation', 'Provide exactly one file reference') + + const canonical = await loadActiveWorkspaceFileContext(fileId) + if (!canonical || canonical.workspaceId !== input.workspaceId) { + throw new OrchestrationError('not_found', 'Not found') + } + return canonical + }, + execute: executeReadWorkspaceInlineFile, +}) diff --git a/apps/sim/lib/workspace-files/application/rename-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/rename-workspace-file.test.ts new file mode 100644 index 00000000000..1cd669986f4 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/rename-workspace-file.test.ts @@ -0,0 +1,152 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadContext: vi.fn(), + renameStored: vi.fn(), + resolvePermission: vi.fn(), + recordAudit: vi.fn(), + notify: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_UPDATED: 'FILE_UPDATED' }, + AuditResourceType: { FILE: 'FILE' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mocks.notify })) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + loadActiveWorkspaceFileContext: mocks.loadContext, + renameWorkspaceFile: mocks.renameStored, +})) + +import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' + +const canonical = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const mappedFile = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'new.csv', + key: 'workspace/ws/file.csv', + path: '/api/files/serve/file.csv', + size: 42, + type: 'text/csv', + uploadedBy: 'uploader-1', + folderId: null, + uploadedAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} + +describe('renameWorkspaceFile application service', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(canonical) + mocks.renameStored.mockResolvedValue(mappedFile) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.notify.mockResolvedValue(undefined) + }) + + it('loads, authorizes, renames, then emits side effects', async () => { + const result = await renameWorkspaceFile.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1', name: 'new.csv' }, + }) + + expect(result).toEqual({ file: mappedFile }) + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { includeDeleted: undefined }) + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'organization-1', + undefined, + { forUpdate: undefined } + ) + expect(mocks.renameStored).toHaveBeenCalledWith('workspace-1', 'file-1', 'new.csv') + expect(mocks.renameStored.mock.invocationCallOrder[0]).toBeLessThan( + mocks.recordAudit.mock.invocationCallOrder[0] + ) + expect(mocks.renameStored.mock.invocationCallOrder[0]).toBeLessThan( + mocks.notify.mock.invocationCallOrder[0] + ) + expect(mocks.recordAudit.mock.invocationCallOrder[0]).toBeLessThan( + mocks.notify.mock.invocationCallOrder[0] + ) + }) + + it('conceals an asserted-workspace mismatch before authorization or mutation', async () => { + await expect( + renameWorkspaceFile.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-2', name: 'new.csv' }, + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'File not found' }) + + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.renameStored).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('keeps workspace-key audit attribution non-human', async () => { + await renameWorkspaceFile.execute({ + principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1', name: 'new.csv' }, + }) + + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: null, + actorName: 'Workspace API key', + metadata: expect.objectContaining({ + actor: { + kind: 'workspace_api_key', + keyId: 'key-1', + workspaceId: 'workspace-1', + }, + }), + }) + ) + }) + + it('propagates a typed rename conflict', async () => { + const failure = Object.assign(new Error('File already exists'), { code: 'conflict' }) + mocks.renameStored.mockRejectedValueOnce(failure) + + await expect( + renameWorkspaceFile.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1', name: 'new.csv' }, + }) + ).rejects.toBe(failure) + + expect(mocks.recordAudit).not.toHaveBeenCalled() + expect(mocks.notify).not.toHaveBeenCalled() + }) + + it('propagates an infrastructure read failure without classifying it as not found', async () => { + const failure = new Error('database unavailable') + mocks.loadContext.mockRejectedValueOnce(failure) + + await expect( + renameWorkspaceFile.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', name: 'new.csv' }, + }) + ).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/workspace-files/application/rename-workspace-file.ts b/apps/sim/lib/workspace-files/application/rename-workspace-file.ts new file mode 100644 index 00000000000..72bb001ac39 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/rename-workspace-file.ts @@ -0,0 +1,58 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { createLogger } from '@sim/logger' +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { + type ActiveWorkspaceFileContext, + renameWorkspaceFile as renameStoredWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' + +const logger = createLogger('RenameWorkspaceFile') + +export interface RenameWorkspaceFileInput { + fileId: string + assertedWorkspaceId?: string + name: string +} + +export interface RenameWorkspaceFileResult { + file: WorkspaceFileRecord +} + +async function executeRenameWorkspaceFile({ + principal, + input, + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.rename, + RenameWorkspaceFileInput, + ActiveWorkspaceFileContext +>): Promise { + const file = await renameStoredWorkspaceFile(context.workspaceId, context.fileId, input.name) + + logger.info('Renamed workspace file', { + workspaceId: context.workspaceId, + fileId: context.fileId, + name: file.name, + principalKind: principal.kind, + }) + return { file } +} + +export const renameWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.rename, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), + execute: executeRenameWorkspaceFile, + projectAudit: ({ result }) => ({ + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Renamed file to "${result.file.name}"`, + }), + afterSuccess: ({ context }) => notifyWorkspaceFilesChanged(context.workspaceId), +}) diff --git a/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts new file mode 100644 index 00000000000..cd69a123118 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts @@ -0,0 +1,107 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + fetchBuffer: vi.fn(), + loadContext: vi.fn(), + resolvePermission: vi.fn(), + resolveStoredReference: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + fetchWorkspaceFileBuffer: mocks.fetchBuffer, + loadActiveWorkspaceFileContext: mocks.loadContext, + resolveWorkspaceFileReference: mocks.resolveStoredReference, +})) + +import { defineWorkspaceOperation } from '@/lib/core/application' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { + readWorkspaceFileReference, + resolveWorkspaceFileReference, +} from '@/lib/workspace-files/application/resolve-workspace-file-reference' + +const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } +const file = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'source.txt', + key: 'workspace/workspace-1/source.txt', + size: 12, +} +const context = { + fileId: file.id, + workspaceId: file.workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', +} + +describe('workspace file reference application service', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveStoredReference.mockResolvedValue(file) + mocks.loadContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.fetchBuffer.mockResolvedValue(Buffer.from('source')) + }) + + it('uses one fixed semantic use case for an authorized reference lookup', async () => { + await expect( + resolveWorkspaceFileReference({ + principal, + operation: fileOperations.rename, + workspaceId: 'workspace-1', + reference: 'files/source.txt', + }) + ).resolves.toBe(file) + + expect(mocks.resolveStoredReference).toHaveBeenCalledTimes(1) + expect(mocks.loadContext).toHaveBeenCalledTimes(1) + expect(mocks.resolvePermission).toHaveBeenCalledTimes(1) + }) + + it('reads a referenced file with one canonical load and authorization', async () => { + await expect( + readWorkspaceFileReference({ + principal, + workspaceId: 'workspace-1', + reference: 'files/source.txt', + maxBytes: 512, + }) + ).resolves.toEqual({ file, content: Buffer.from('source') }) + + expect(mocks.resolveStoredReference).toHaveBeenCalledTimes(1) + expect(mocks.loadContext).toHaveBeenCalledTimes(1) + expect(mocks.resolvePermission).toHaveBeenCalledTimes(1) + expect(mocks.fetchBuffer).toHaveBeenCalledWith(file, { maxBytes: 512 }) + }) + + it('fails before canonical loading for an unregistered operation object', async () => { + const duplicateOperation = defineWorkspaceOperation({ + id: fileOperations.rename.id, + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }) + + await expect( + resolveWorkspaceFileReference({ + principal, + operation: duplicateOperation, + workspaceId: 'workspace-1', + reference: 'files/source.txt', + }) + ).rejects.toThrow('No workspace file reference resolver is defined for files.rename') + + expect(mocks.resolveStoredReference).not.toHaveBeenCalled() + expect(mocks.loadContext).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts new file mode 100644 index 00000000000..89962635c5c --- /dev/null +++ b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts @@ -0,0 +1,125 @@ +import type { Principal } from '@sim/auth/principal' +import type { OperationUseCase, WorkspaceOperation } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + fetchWorkspaceFileBuffer, + loadActiveWorkspaceFileContext, + resolveWorkspaceFileReference as resolveStoredWorkspaceFileReference, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +export interface ResolveWorkspaceFileReferenceInput { + principal: Principal + operation: WorkspaceOperation + workspaceId: string + reference: string +} + +interface WorkspaceFileReferenceInput { + workspaceId: string + reference: string +} + +interface WorkspaceFileReferenceResult { + file: WorkspaceFileRecord +} + +interface WorkspaceFileReferenceReadInput extends WorkspaceFileReferenceInput { + maxBytes: number +} + +async function resolveWorkspaceFileReferenceContext({ + input, +}: { + input: WorkspaceFileReferenceInput +}) { + const file = await resolveStoredWorkspaceFileReference(input.workspaceId, input.reference) + if (!file) throw new OrchestrationError('not_found', 'File not found') + const canonical = await loadActiveWorkspaceFileContext(file.id) + if (!canonical || canonical.workspaceId !== input.workspaceId) { + throw new OrchestrationError('not_found', 'File not found') + } + return { ...canonical, file } +} + +function defineWorkspaceFileReferenceUseCase(operation: O) { + return defineAuthorizedWorkspaceFileUseCase({ + operation, + resolveContext: resolveWorkspaceFileReferenceContext, + async execute({ context }): Promise { + return { file: context.file } + }, + }) +} + +type WorkspaceFileReferenceUseCase = OperationUseCase< + WorkspaceOperation, + WorkspaceFileReferenceInput, + WorkspaceFileReferenceResult +> + +const workspaceFileReferenceUseCases = { + [fileOperations.readContent.id]: defineWorkspaceFileReferenceUseCase(fileOperations.readContent), + [fileOperations.create.id]: defineWorkspaceFileReferenceUseCase(fileOperations.create), + [fileOperations.rename.id]: defineWorkspaceFileReferenceUseCase(fileOperations.rename), + [fileOperations.updateContent.id]: defineWorkspaceFileReferenceUseCase( + fileOperations.updateContent + ), + [fileOperations.move.id]: defineWorkspaceFileReferenceUseCase(fileOperations.move), + [fileOperations.delete.id]: defineWorkspaceFileReferenceUseCase(fileOperations.delete), + [fileOperations.updateShare.id]: defineWorkspaceFileReferenceUseCase(fileOperations.updateShare), +} satisfies Record + +function getWorkspaceFileReferenceUseCase(operation: WorkspaceOperation) { + const operationId = operation.id as keyof typeof workspaceFileReferenceUseCases + const useCase: WorkspaceFileReferenceUseCase | undefined = + workspaceFileReferenceUseCases[operationId] + if (!useCase || useCase.operation !== operation) { + throw new Error(`No workspace file reference resolver is defined for ${operation.id}`) + } + return useCase +} + +/** Resolve one workspace-file reference under an explicit semantic operation policy. */ +export async function resolveWorkspaceFileReference({ + principal, + operation, + workspaceId, + reference, +}: ResolveWorkspaceFileReferenceInput): Promise { + const useCase = getWorkspaceFileReferenceUseCase(operation) + const result = await useCase.execute({ principal, input: { workspaceId, reference } }) + return result.file +} + +export interface ReadWorkspaceFileReferenceInput + extends Omit { + maxBytes: number +} + +const readWorkspaceFileReferenceUseCase = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.readContent, + resolveContext: ({ input }: { input: WorkspaceFileReferenceReadInput }) => + resolveWorkspaceFileReferenceContext({ input }), + async execute({ input, context }): Promise<{ file: WorkspaceFileRecord; content: Buffer }> { + return { + file: context.file, + content: await fetchWorkspaceFileBuffer(context.file, { maxBytes: input.maxBytes }), + } + }, +}) + +/** Resolve one trusted workspace-file reference and read it under the shared file policy. */ +export async function readWorkspaceFileReference({ + principal, + workspaceId, + reference, + maxBytes, +}: ReadWorkspaceFileReferenceInput): Promise<{ file: WorkspaceFileRecord; content: Buffer }> { + return readWorkspaceFileReferenceUseCase.execute({ + principal, + input: { workspaceId, reference, maxBytes }, + }) +} diff --git a/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts new file mode 100644 index 00000000000..99389ff56b8 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts @@ -0,0 +1,82 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadLifecycle: vi.fn(), + restoreStored: vi.fn(), + recordAudit: vi.fn(), + notify: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_RESTORED: 'FILE_RESTORED' }, + AuditResourceType: { FILE: 'FILE' }, + recordAudit: mocks.recordAudit, +})) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mocks.notify })) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + loadWorkspaceFileLifecycleContext: mocks.loadLifecycle, + restoreWorkspaceFile: mocks.restoreStored, +})) + +import { restoreWorkspaceFileOperation } from '@/lib/workspace-files/application/restore-workspace-file' + +const context = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', + deletedAt: new Date('2026-01-01T00:00:00Z'), +} + +describe('restoreWorkspaceFileOperation', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadLifecycle.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.restoreStored.mockResolvedValue(undefined) + mocks.notify.mockResolvedValue(undefined) + }) + + it('authorizes, restores, audits, and notifies once', async () => { + const result = await restoreWorkspaceFileOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + + expect(result).toEqual({ restored: true }) + expect(mocks.resolvePermission).toHaveBeenCalled() + expect(mocks.restoreStored).toHaveBeenCalledWith('workspace-1', 'file-1') + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + actorId: 'user-1', + metadata: expect.objectContaining({ operation: 'files.restore' }), + }) + ) + expect(mocks.notify).toHaveBeenCalledWith('workspace-1') + expect(mocks.recordAudit.mock.invocationCallOrder[0]).toBeLessThan( + mocks.notify.mock.invocationCallOrder[0] + ) + }) + + it('conceals an asserted-workspace mismatch before authorization', async () => { + await expect( + restoreWorkspaceFileOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-2' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.restoreStored).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/restore-workspace-file.ts b/apps/sim/lib/workspace-files/application/restore-workspace-file.ts new file mode 100644 index 00000000000..918fb2c746e --- /dev/null +++ b/apps/sim/lib/workspace-files/application/restore-workspace-file.ts @@ -0,0 +1,54 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { createLogger } from '@sim/logger' +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { + restoreWorkspaceFile, + type WorkspaceFileLifecycleContext, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveWorkspaceFileLifecycleContext } from '@/lib/workspace-files/application/workspace-file-context' + +const logger = createLogger('RestoreWorkspaceFile') + +export interface RestoreWorkspaceFileInput { + fileId: string + assertedWorkspaceId?: string +} + +export interface RestoreWorkspaceFileResult { + restored: true +} + +async function executeRestoreWorkspaceFile({ + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.restore, + RestoreWorkspaceFileInput, + WorkspaceFileLifecycleContext +>): Promise { + await restoreWorkspaceFile(context.workspaceId, context.fileId) + return { restored: true } +} + +export const restoreWorkspaceFileOperation = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.restore, + resolveContext: ({ input }) => resolveWorkspaceFileLifecycleContext(input), + execute: executeRestoreWorkspaceFile, + projectAudit: ({ context }) => ({ + action: AuditAction.FILE_RESTORED, + resourceType: AuditResourceType.FILE, + resourceId: context.fileId, + resourceName: context.fileId, + description: `Restored workspace file ${context.fileId}`, + }), + async afterSuccess({ principal, context }) { + await notifyWorkspaceFilesChanged(context.workspaceId) + logger.info('Restored workspace file', { + workspaceId: context.workspaceId, + fileId: context.fileId, + principalKind: principal.kind, + }) + }, +}) diff --git a/apps/sim/lib/workspace-files/application/share-workspace-file.ts b/apps/sim/lib/workspace-files/application/share-workspace-file.ts new file mode 100644 index 00000000000..599d3c3ed09 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/share-workspace-file.ts @@ -0,0 +1,126 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + getShareForResource, + ShareValidationError, + upsertFileShare, +} from '@/lib/public-shares/share-manager' +import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' +import { + PublicFileSharingNotAllowedError, + validatePublicFileSharing, +} from '@/ee/access-control/utils/permission-check' + +const logger = createLogger('WorkspaceFileShare') + +export interface GetWorkspaceFileShareInput { + fileId: string + assertedWorkspaceId?: string +} + +export interface GetWorkspaceFileShareResult { + share: ShareRecord | null +} + +export interface UpdateWorkspaceFileShareInput { + fileId: string + assertedWorkspaceId?: string + isActive: boolean + authType?: ShareAuthType + password?: string + allowedEmails?: string[] + token?: string + noOpIfInactive?: boolean +} + +export interface UpdateWorkspaceFileShareResult { + share: ShareRecord +} + +export class WorkspaceFileShareNoopError extends Error { + constructor() { + super('Workspace file is not currently shared') + this.name = 'WorkspaceFileShareNoopError' + } +} + +export const getWorkspaceFileShare = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.readShare, + resolveContext: ({ input }: { input: GetWorkspaceFileShareInput }) => + resolveActiveWorkspaceFileContext(input), + async execute({ context }): Promise { + const share = await getShareForResource('file', context.fileId) + return { share } + }, +}) + +export const updateWorkspaceFileShare = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.updateShare, + async resolveContext({ input }: { input: UpdateWorkspaceFileShareInput }) { + const canonical = await resolveActiveWorkspaceFileContext(input) + const file = await getWorkspaceFile(canonical.workspaceId, canonical.fileId, { + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return { ...canonical, file } + }, + async execute({ principal, input, context }): Promise { + const subjectUserId = resolvePrincipalAttribution(principal).attributedUserId + + const existingShare = await getShareForResource('file', context.fileId) + if (input.noOpIfInactive && !input.isActive && !existingShare?.isActive) { + throw new WorkspaceFileShareNoopError() + } + + if (input.isActive) { + const effectiveAuthType = input.authType ?? existingShare?.authType ?? 'public' + try { + await validatePublicFileSharing(subjectUserId, context.workspaceId, effectiveAuthType) + } catch (error) { + if (error instanceof PublicFileSharingNotAllowedError) + throw new OrchestrationError('forbidden', error.message) + throw error + } + } + + let share: ShareRecord + try { + share = await upsertFileShare({ + workspaceId: context.workspaceId, + fileId: context.fileId, + userId: subjectUserId, + isActive: input.isActive, + authType: input.authType, + password: input.password, + allowedEmails: input.allowedEmails, + token: input.token, + }) + } catch (error) { + if (error instanceof ShareValidationError) { + throw new OrchestrationError('validation', error.message) + } + throw error + } + if (!share) throw new Error('Updating workspace file share returned no share') + + logger.info(`${input.isActive ? 'Enabled' : 'Disabled'} share for workspace file`, { + workspaceId: context.workspaceId, + fileId: context.fileId, + principalKind: principal.kind, + }) + return { share } + }, + projectAudit: ({ input, context }) => ({ + action: input.isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED, + resourceType: AuditResourceType.FILE, + resourceId: context.fileId, + resourceName: context.file.name, + description: `${input.isActive ? 'Enabled' : 'Disabled'} public share for "${context.file.name}"`, + }), +}) diff --git a/apps/sim/lib/workspace-files/application/style-workspace-file.ts b/apps/sim/lib/workspace-files/application/style-workspace-file.ts new file mode 100644 index 00000000000..73b3d99a803 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/style-workspace-file.ts @@ -0,0 +1,57 @@ +import type { Principal } from '@sim/auth/principal' +import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' + +const MAX_STYLE_FILE_BYTES = 100 * 1024 * 1024 + +export class StyleExtractionUnsupportedError extends Error { + constructor(message: string) { + super(message) + this.name = 'StyleExtractionUnsupportedError' + } +} + +export interface StyleWorkspaceFileInput { + fileId: string + assertedWorkspaceId?: string +} + +async function executeStyleWorkspaceFile({ + principal, + input, +}: { + principal: Principal + input: StyleWorkspaceFileInput + request?: OrchestrationRequestContext +}) { + const { file } = await readWorkspaceFileMetadata.execute({ principal, input }) + const rawExt = file.name.split('.').pop()?.toLowerCase() + if (rawExt !== 'docx' && rawExt !== 'pptx' && rawExt !== 'pdf') { + throw new StyleExtractionUnsupportedError( + 'Style extraction supports .docx, .pptx, and .pdf files' + ) + } + if (file.size > MAX_STYLE_FILE_BYTES) { + throw new StyleExtractionUnsupportedError( + 'File is too large for style extraction (limit: 100 MB)' + ) + } + const { content } = await readWorkspaceFileContent.execute({ + principal, + input: { ...input, maxBytes: MAX_STYLE_FILE_BYTES }, + }) + const summary = await extractDocumentStyle(content, rawExt) + if (!summary) { + throw new StyleExtractionUnsupportedError( + 'Could not extract style — file may be encrypted, corrupt, image-only, or contain no parseable style information' + ) + } + return summary +} + +export const styleWorkspaceFile = { + operation: readWorkspaceFileContent.operation, + execute: executeStyleWorkspaceFile, +} as const diff --git a/apps/sim/lib/workspace-files/application/update-workspace-file-content.ts b/apps/sim/lib/workspace-files/application/update-workspace-file-content.ts new file mode 100644 index 00000000000..56d096c7a2a --- /dev/null +++ b/apps/sim/lib/workspace-files/application/update-workspace-file-content.ts @@ -0,0 +1,152 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + ContentVersionConflictError, + updateWorkspaceFileContent as updateStoredWorkspaceFileContent, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace' +import { + EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, + type WorkspaceFileSecretProvenance, +} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' +import { MAX_WORKSPACE_FILE_CONTENT_BYTES } from '@/lib/workspace-files/orchestration' + +const logger = createLogger('UpdateWorkspaceFileContent') + +export interface UpdateWorkspaceFileContentInput { + fileId: string + assertedWorkspaceId?: string + content: string + encoding: 'utf-8' | 'base64' + contentType?: string + provenanceMode?: 'replace_empty' | 'preserve' + secretProvenance?: WorkspaceFileSecretProvenance + syncLiveDoc?: boolean + expectedUpdatedAt?: Date +} + +export interface UpdateWorkspaceFileContentResult { + file: WorkspaceFileRecord +} + +export interface UpdateWorkspaceFileContentBufferInput + extends Omit { + content: Buffer +} + +async function updateAuthorizedWorkspaceFileContent({ + principal, + input, + content, + canonical, +}: { + principal: Principal + input: Omit + content: Buffer + canonical: Awaited> +}): Promise { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: canonical.billedAccountUserId, + }) + let file: WorkspaceFileRecord + try { + file = await updateStoredWorkspaceFileContent( + canonical.workspaceId, + canonical.fileId, + attribution.attributedUserId, + content, + input.contentType, + { + ...(input.expectedUpdatedAt ? { expectedUpdatedAt: input.expectedUpdatedAt } : {}), + syncLiveDoc: input.syncLiveDoc, + secretProvenancePolicy: { + ...(input.provenanceMode === 'preserve' + ? { mode: 'preserve' as const } + : { + mode: 'replace' as const, + provenance: input.secretProvenance ?? EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, + }), + }, + } + ) + } catch (error) { + if (error instanceof ContentVersionConflictError) { + throw new OrchestrationError('conflict', error.message) + } + throw error + } + + logger.info('Updated workspace file content', { + workspaceId: canonical.workspaceId, + fileId: canonical.fileId, + size: content.length, + principalKind: principal.kind, + }) + return { file } +} + +function projectUpdateWorkspaceFileContentAudit(result: UpdateWorkspaceFileContentResult) { + return { + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Updated content of file "${result.file.name}"`, + metadata: { contentSize: result.file.size }, + } as const +} + +const admitUpdateWorkspaceFileContentUseCase = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.updateContent, + resolveContext: ({ input }: { input: { fileId: string } }) => + resolveActiveWorkspaceFileContext(input), + async execute() {}, +}) + +export async function admitUpdateWorkspaceFileContent( + principal: Principal, + fileId: string +): Promise { + await admitUpdateWorkspaceFileContentUseCase.execute({ principal, input: { fileId } }) +} + +export const updateWorkspaceFileContent = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.updateContent, + resolveContext: ({ input }: { input: UpdateWorkspaceFileContentInput }) => + resolveActiveWorkspaceFileContext(input), + async execute({ principal, input, context }): Promise { + const content = Buffer.from(input.content, input.encoding === 'base64' ? 'base64' : 'utf-8') + if (content.length > MAX_WORKSPACE_FILE_CONTENT_BYTES) { + throw new OrchestrationError( + 'payload_too_large', + `File size exceeds ${MAX_WORKSPACE_FILE_CONTENT_BYTES / 1024 / 1024}MB limit` + ) + } + return updateAuthorizedWorkspaceFileContent({ + principal, + input, + content, + canonical: context, + }) + }, + projectAudit: ({ result }) => projectUpdateWorkspaceFileContentAudit(result), +}) + +export const updateWorkspaceFileContentFromBuffer = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.updateContent, + resolveContext: ({ input }: { input: UpdateWorkspaceFileContentBufferInput }) => + resolveActiveWorkspaceFileContext(input), + execute: ({ principal, input, context }) => + updateAuthorizedWorkspaceFileContent({ + principal, + input, + content: input.content, + canonical: context, + }), + projectAudit: ({ result }) => projectUpdateWorkspaceFileContentAudit(result), +}) diff --git a/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.test.ts b/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.test.ts new file mode 100644 index 00000000000..5800c0b42c1 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadContext: vi.fn(), + updateDimensions: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + loadActiveWorkspaceFileContext: mocks.loadContext, + updateWorkspaceFileDimensions: mocks.updateDimensions, +})) + +import { updateWorkspaceFileDimensionsOperation } from '@/lib/workspace-files/application/update-workspace-file-dimensions' + +describe('updateWorkspaceFileDimensionsOperation', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue({ + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', + }) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.updateDimensions.mockResolvedValue(false) + }) + + it('preserves a stale-key write as a successful false result', async () => { + const result = await updateWorkspaceFileDimensionsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + fileId: 'file-1', + assertedWorkspaceId: 'workspace-1', + key: 'workspace/workspace-1/current-key', + width: 800, + height: 600, + }, + }) + + expect(result).toEqual({ success: false }) + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { includeDeleted: undefined }) + expect(mocks.updateDimensions).toHaveBeenCalledWith('workspace-1', 'file-1', { + key: 'workspace/workspace-1/current-key', + width: 800, + height: 600, + }) + }) +}) diff --git a/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.ts b/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.ts new file mode 100644 index 00000000000..aff7c3d22e1 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.ts @@ -0,0 +1,42 @@ +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { + type ActiveWorkspaceFileContext, + updateWorkspaceFileDimensions, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' + +export interface UpdateWorkspaceFileDimensionsInput { + fileId: string + assertedWorkspaceId?: string + key: string + width: number + height: number +} + +export interface UpdateWorkspaceFileDimensionsResult { + success: boolean +} + +async function executeUpdateWorkspaceFileDimensions({ + input, + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.updateMetadata, + UpdateWorkspaceFileDimensionsInput, + ActiveWorkspaceFileContext +>): Promise { + const success = await updateWorkspaceFileDimensions(context.workspaceId, context.fileId, { + key: input.key, + width: input.width, + height: input.height, + }) + return { success } +} + +export const updateWorkspaceFileDimensionsOperation = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.updateMetadata, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), + execute: executeUpdateWorkspaceFileDimensions, +}) diff --git a/apps/sim/lib/workspace-files/application/workspace-file-context.ts b/apps/sim/lib/workspace-files/application/workspace-file-context.ts new file mode 100644 index 00000000000..4dba54c77e3 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/workspace-file-context.ts @@ -0,0 +1,41 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + type ActiveWorkspaceFileContext, + loadActiveWorkspaceFileContext, + loadWorkspaceFileLifecycleContext, + type WorkspaceFileLifecycleContext, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' + +export interface WorkspaceFileContextInput { + fileId: string + assertedWorkspaceId?: string + includeDeleted?: boolean +} + +export async function resolveActiveWorkspaceFileContext( + input: WorkspaceFileContextInput +): Promise { + const canonical = await loadActiveWorkspaceFileContext(input.fileId, { + includeDeleted: input.includeDeleted, + }) + if ( + !canonical || + (input.assertedWorkspaceId !== undefined && input.assertedWorkspaceId !== canonical.workspaceId) + ) { + throw new OrchestrationError('not_found', 'File not found') + } + return canonical +} + +export async function resolveWorkspaceFileLifecycleContext( + input: WorkspaceFileContextInput +): Promise { + const canonical = await loadWorkspaceFileLifecycleContext(input.fileId) + if ( + !canonical || + (input.assertedWorkspaceId !== undefined && input.assertedWorkspaceId !== canonical.workspaceId) + ) { + throw new OrchestrationError('not_found', 'File not found') + } + return canonical +} diff --git a/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts b/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts new file mode 100644 index 00000000000..0608deaedbe --- /dev/null +++ b/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts @@ -0,0 +1,174 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + events, + mockLoadContext, + mockResolvePermission, + mockAssertItems, + mockArchive, + mockCreate, + mockRelocate, + mockRestore, + mockAudit, + mockNotify, +} = vi.hoisted(() => ({ + events: [] as string[], + mockLoadContext: vi.fn(), + mockResolvePermission: vi.fn(), + mockAssertItems: vi.fn(), + mockArchive: vi.fn(), + mockCreate: vi.fn(), + mockRelocate: vi.fn(), + mockRestore: vi.fn(), + mockAudit: vi.fn(), + mockNotify: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + assertWorkspaceFileItemsBelongToWorkspace: mockAssertItems, + bulkArchiveWorkspaceFileItems: mockArchive, + createWorkspaceFileFolderAtPath: mockCreate, + loadWorkspaceFileOperationContext: mockLoadContext, + relocateWorkspaceFileFolderByPath: mockRelocate, + restoreWorkspaceFileFolder: mockRestore, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mockResolvePermission, +})) +vi.mock('@sim/audit', () => ({ + AuditAction: { + FOLDER_CREATED: 'folder.created', + FOLDER_DELETED: 'folder.deleted', + FOLDER_MOVED: 'folder.moved', + FOLDER_RESTORED: 'folder.restored', + }, + AuditResourceType: { FOLDER: 'folder' }, + recordAudit: mockAudit, +})) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mockNotify })) + +import { + createWorkspaceFileFolderOperation, + deleteWorkspaceFileFolderOperation, + restoreWorkspaceFileFolderOperation, + updateWorkspaceFileFolderOperation, +} from '@/lib/workspace-files/application/workspace-file-folders' + +const folder = { + id: 'folder-1', + workspaceId: 'ws-1', + userId: 'owner-1', + name: 'Reports', + parentId: null, + sortOrder: 0, + deletedAt: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), +} + +describe('workspace file folder operations', () => { + beforeEach(() => { + vi.clearAllMocks() + events.length = 0 + mockLoadContext.mockImplementation(async () => { + events.push('resolve') + return { + workspaceId: 'ws-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', + } + }) + mockResolvePermission.mockImplementation(async () => { + events.push('authorize') + return 'write' + }) + mockAssertItems.mockResolvedValue(undefined) + mockArchive.mockResolvedValue({ files: 0, folders: 1, fileIds: [], folderIds: ['folder-1'] }) + }) + + it('creates a canonical path folder through the manager primitive', async () => { + mockCreate.mockImplementation(async () => { + events.push('execute') + return { folder, path: '/Reports' } + }) + const result = await createWorkspaceFileFolderOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1', path: '/Reports' }, + }) + + expect(result.folder.path).toBe('/Reports') + expect(events).toEqual(['resolve', 'authorize', 'execute']) + expect(mockCreate).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + userId: 'user-1', + path: '/Reports', + }) + expect(mockAudit).toHaveBeenCalledOnce() + expect(mockNotify).toHaveBeenCalledOnce() + }) + + it('relocates a canonical path folder without invoking legacy orchestration', async () => { + mockRelocate.mockResolvedValue({ folder, path: '/Archive/Reports' }) + const result = await updateWorkspaceFileFolderOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'ws-1', + path: '/Reports', + destinationPath: '/Archive/Reports', + }, + }) + + expect(result.folder.path).toBe('/Archive/Reports') + expect(mockRelocate).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + path: '/Reports', + destinationPath: '/Archive/Reports', + }) + expect(mockAudit).toHaveBeenCalledOnce() + expect(mockNotify).toHaveBeenCalledOnce() + }) + + it('does not audit or notify when a folder archive updates no rows', async () => { + mockArchive.mockResolvedValue({ files: 0, folders: 0, fileIds: [], folderIds: [] }) + + await expect( + deleteWorkspaceFileFolderOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1', folderId: 'folder-1' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mockAudit).not.toHaveBeenCalled() + expect(mockNotify).not.toHaveBeenCalled() + }) + + it('does not authorize a folder restore as though its ID were a delegated file scope', async () => { + const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'ws-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), + resourceScope: { fileId: 'folder-1' }, + } + await expect( + restoreWorkspaceFileFolderOperation.execute({ + principal, + input: { workspaceId: 'ws-1', folderId: 'folder-1' }, + }) + ).rejects.toThrow('Delegated workspace access is no longer valid') + + expect(mockLoadContext).toHaveBeenCalledWith('ws-1') + expect(mockResolvePermission).not.toHaveBeenCalled() + expect(mockRestore).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/workspace-file-folders.ts b/apps/sim/lib/workspace-files/application/workspace-file-folders.ts new file mode 100644 index 00000000000..c8fb8f9d2ff --- /dev/null +++ b/apps/sim/lib/workspace-files/application/workspace-file-folders.ts @@ -0,0 +1,299 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { + assertWorkspaceFileItemsBelongToWorkspace, + bulkArchiveWorkspaceFileItems, + createWorkspaceFileFolder, + createWorkspaceFileFolderAtPath, + deleteWorkspaceFileFolderByPath, + listWorkspaceFileFolders, + loadWorkspaceFileOperationContext, + relocateWorkspaceFileFolderByPath, + restoreWorkspaceFileFolder, + updateWorkspaceFileFolder, + type WorkspaceFileArchiveResult, + type WorkspaceFileFolderRecord, +} from '@/lib/uploads/contexts/workspace' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +const logger = createLogger('WorkspaceFileFolders') + +export interface ListWorkspaceFileFoldersInput { + workspaceId: string + scope?: 'active' | 'archived' | 'all' + parentPath?: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +export interface ListWorkspaceFileFoldersResult { + folders: WorkspaceFileFolderRecord[] +} + +export interface CreateWorkspaceFileFolderInput { + workspaceId: string + name?: string + parentId?: string | null + path?: string +} + +export interface CreateWorkspaceFileFolderResult { + folder: WorkspaceFileFolderRecord +} + +export interface UpdateWorkspaceFileFolderInput { + workspaceId: string + folderId?: string + name?: string + parentId?: string | null + sortOrder?: number + path?: string + destinationPath?: string +} + +export interface UpdateWorkspaceFileFolderResult { + folder: WorkspaceFileFolderRecord +} + +export interface DeleteWorkspaceFileFolderInput { + workspaceId: string + folderId?: string + path?: string + recursive?: boolean +} + +export interface DeleteWorkspaceFileFolderResult { + deletedItems: WorkspaceFileArchiveResult + path?: string +} + +export interface RestoreWorkspaceFileFolderInput { + workspaceId: string + folderId: string +} + +export interface RestoreWorkspaceFileFolderResult { + folder: WorkspaceFileFolderRecord + restoredItems: WorkspaceFileArchiveResult +} + +async function resolveFolderContext({ input }: { input: { workspaceId: string } }) { + const context = await loadWorkspaceFileOperationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context +} + +type FolderOperationContext = Awaited> + +async function executeListWorkspaceFileFolders(args: { + input: ListWorkspaceFileFoldersInput + context: FolderOperationContext +}): Promise { + let folders = await listWorkspaceFileFolders(args.context.workspaceId, { + scope: args.input.scope, + }) + if (args.input.parentPath !== undefined) { + const parentPath = args.input.parentPath === '/' ? '' : args.input.parentPath.replace(/^\//, '') + folders = folders.filter((folder) => { + const parent = folder.path.includes('/') + ? folder.path.slice(0, folder.path.lastIndexOf('/')) + : '' + return parent === parentPath + }) + } + if (args.input.search) { + const search = args.input.search.toLowerCase() + folders = folders.filter((folder) => folder.name.toLowerCase().includes(search)) + } + const sortBy = args.input.sortBy ?? 'name' + const sortOrder = args.input.sortOrder ?? 'asc' + folders.sort((left, right) => { + const leftValue = left[sortBy] + const rightValue = right[sortBy] + const comparison = leftValue < rightValue ? -1 : leftValue > rightValue ? 1 : 0 + return sortOrder === 'asc' ? comparison : -comparison + }) + return { folders } +} + +async function executeCreateWorkspaceFileFolder(args: { + principal: Parameters[0] + input: CreateWorkspaceFileFolderInput + context: FolderOperationContext +}): Promise { + const attribution = resolvePrincipalAttribution(args.principal, { + workspaceBillingOwnerUserId: args.context.billedAccountUserId, + }) + const result = + args.input.path !== undefined + ? await createWorkspaceFileFolderAtPath({ + workspaceId: args.context.workspaceId, + userId: attribution.attributedUserId, + path: args.input.path, + }) + : { + folder: await createWorkspaceFileFolder({ + workspaceId: args.context.workspaceId, + userId: attribution.attributedUserId, + name: args.input.name ?? '', + parentId: args.input.parentId, + }), + } + const folder = 'path' in result ? { ...result.folder, path: result.path } : result.folder + return { folder } +} + +async function executeUpdateWorkspaceFileFolder(args: { + input: UpdateWorkspaceFileFolderInput + context: FolderOperationContext +}): Promise { + let folder: WorkspaceFileFolderRecord + if (args.input.path !== undefined || args.input.destinationPath !== undefined) { + if (!args.input.path || !args.input.destinationPath) { + throw new OrchestrationError('validation', 'path and destinationPath are required') + } + const result = await relocateWorkspaceFileFolderByPath({ + workspaceId: args.context.workspaceId, + path: args.input.path, + destinationPath: args.input.destinationPath, + }) + folder = { ...result.folder, path: result.path } + } else { + if (!args.input.folderId) throw new OrchestrationError('validation', 'Folder ID is required') + folder = await updateWorkspaceFileFolder({ + workspaceId: args.context.workspaceId, + folderId: args.input.folderId, + name: args.input.name, + parentId: args.input.parentId, + sortOrder: args.input.sortOrder, + }) + } + logger.info('Updated workspace file folder', { + workspaceId: args.context.workspaceId, + folderId: folder.id, + }) + return { folder } +} + +async function executeDeleteWorkspaceFileFolder(args: { + input: DeleteWorkspaceFileFolderInput + context: FolderOperationContext +}): Promise { + let deletedItems: WorkspaceFileArchiveResult + if (args.input.path !== undefined) { + deletedItems = await deleteWorkspaceFileFolderByPath({ + workspaceId: args.context.workspaceId, + path: args.input.path, + recursive: args.input.recursive ?? false, + }) + } else { + if (!args.input.folderId) throw new OrchestrationError('validation', 'Folder ID is required') + await assertWorkspaceFileItemsBelongToWorkspace({ + workspaceId: args.context.workspaceId, + folderIds: [args.input.folderId], + }) + const archived = await bulkArchiveWorkspaceFileItems({ + workspaceId: args.context.workspaceId, + folderIds: [args.input.folderId], + }) + deletedItems = { files: archived.fileIds.length, folders: archived.folderIds.length } + } + if (deletedItems.files === 0 && deletedItems.folders === 0) { + throw new OrchestrationError('not_found', 'Folder not found') + } + return { deletedItems, path: args.input.path } +} + +async function executeRestoreWorkspaceFileFolder(args: { + input: RestoreWorkspaceFileFolderInput + context: FolderOperationContext +}): Promise { + return restoreWorkspaceFileFolder(args.context.workspaceId, args.input.folderId) +} + +export const listWorkspaceFileFoldersOperation = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.listFolders, + resolveContext: (args: { input: ListWorkspaceFileFoldersInput }) => resolveFolderContext(args), + execute: executeListWorkspaceFileFolders, +}) + +export const createWorkspaceFileFolderOperation = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.createFolder, + resolveContext: (args: { input: CreateWorkspaceFileFolderInput }) => resolveFolderContext(args), + execute: executeCreateWorkspaceFileFolder, + projectAudit({ result }) { + return { + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Created file folder "${result.folder.name}"`, + } + }, + async afterSuccess({ context }) { + await notifyWorkspaceFilesChanged(context.workspaceId) + }, +}) + +export const updateWorkspaceFileFolderOperation = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.updateFolder, + resolveContext: (args: { input: UpdateWorkspaceFileFolderInput }) => resolveFolderContext(args), + execute: executeUpdateWorkspaceFileFolder, + projectAudit({ input, result }) { + return { + action: input.path !== undefined ? AuditAction.FOLDER_MOVED : AuditAction.FOLDER_UPDATED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Updated file folder "${result.folder.name}"`, + } + }, + async afterSuccess({ context }) { + await notifyWorkspaceFilesChanged(context.workspaceId) + }, +}) + +export const deleteWorkspaceFileFolderOperation = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.deleteFolder, + resolveContext: (args: { input: DeleteWorkspaceFileFolderInput }) => resolveFolderContext(args), + execute: executeDeleteWorkspaceFileFolder, + projectAudit({ input, result }) { + return { + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: input.folderId, + description: 'Deleted file folder', + metadata: { + path: input.path, + deletedItems: result.deletedItems, + }, + } + }, + async afterSuccess({ context }) { + await notifyWorkspaceFilesChanged(context.workspaceId) + }, +}) + +export const restoreWorkspaceFileFolderOperation = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.restoreFolder, + resolveContext: (args: { input: RestoreWorkspaceFileFolderInput }) => resolveFolderContext(args), + execute: executeRestoreWorkspaceFileFolder, + projectAudit({ input, result }) { + return { + action: AuditAction.FOLDER_RESTORED, + resourceType: AuditResourceType.FOLDER, + resourceId: input.folderId, + resourceName: result.folder.name, + description: `Restored file folder "${result.folder.name}"`, + metadata: { restoredItems: result.restoredItems }, + } + }, + async afterSuccess({ context }) { + await notifyWorkspaceFilesChanged(context.workspaceId) + }, +}) diff --git a/apps/sim/lib/workspace-files/application/workspace-operation-context.ts b/apps/sim/lib/workspace-files/application/workspace-operation-context.ts new file mode 100644 index 00000000000..6796c9c70ee --- /dev/null +++ b/apps/sim/lib/workspace-files/application/workspace-operation-context.ts @@ -0,0 +1,31 @@ +import type { Principal } from '@sim/auth/principal' +import type { WorkspaceOperation } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + loadWorkspaceFileOperationContext, + type WorkspaceFileOperationContext, +} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { authorizeWorkspaceFileAccess } from '@/lib/workspace-files/application/authorization' + +export interface AuthorizedWorkspaceOperationContext { + context: WorkspaceFileOperationContext +} + +export async function authorizeWorkspaceFileOperation( + principal: Principal, + operation: WorkspaceOperation, + workspaceId: string, + fileId?: string +): Promise { + const context = await loadWorkspaceFileOperationContext(workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + + await authorizeWorkspaceFileAccess(principal, operation, { + workspaceId: context.workspaceId, + workspaceOrganizationId: context.workspaceOrganizationId, + allowPersonalApiKeys: context.allowPersonalApiKeys, + fileId, + }) + + return { context } +} diff --git a/apps/sim/lib/workspace-files/application/write-workspace-file-by-path.ts b/apps/sim/lib/workspace-files/application/write-workspace-file-by-path.ts new file mode 100644 index 00000000000..51601a3a33c --- /dev/null +++ b/apps/sim/lib/workspace-files/application/write-workspace-file-by-path.ts @@ -0,0 +1,227 @@ +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { loadActiveWorkspaceContext } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { + admitCreateWorkspaceFile, + createWorkspaceFile, + createWorkspaceFileFromBuffer, +} from '@/lib/workspace-files/application/create-workspace-file' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' +import { + updateWorkspaceFileContent, + updateWorkspaceFileContentFromBuffer, +} from '@/lib/workspace-files/application/update-workspace-file-content' +import { parseWorkspaceFileCreatePath } from '@/lib/workspace-files/workspace-file-path' + +export interface WriteWorkspaceFileByPathInput { + workspaceId: string + path: string + content: string + encoding: 'utf-8' | 'base64' + contentType: string + mode: 'create' | 'overwrite' + exactName?: boolean + syncLiveDoc?: boolean + secretProvenance?: WorkspaceFileSecretProvenance +} + +export interface WriteWorkspaceFileBufferByPathInput + extends Omit { + content: Buffer +} + +export interface WriteWorkspaceFileByPathResult { + id: string + name: string + size: number + contentType: string + downloadUrl?: string + vfsPath: string + mode: WriteWorkspaceFileByPathInput['mode'] +} + +function toResult( + file: { + id: string + name: string + size: number + type: string + url?: string + folderPath?: string | null + }, + mode: WriteWorkspaceFileByPathInput['mode'] +): WriteWorkspaceFileByPathResult { + const folderPath = file.folderPath ?? '' + const encodedFolderPath = folderPath + ? folderPath + .split('/') + .filter(Boolean) + .map((segment) => encodeURIComponent(segment)) + .join('/') + : '' + return { + id: file.id, + name: file.name, + size: file.size, + contentType: file.type, + downloadUrl: file.url, + vfsPath: `files/${encodedFolderPath ? `${encodedFolderPath}/` : ''}${encodeURIComponent(file.name)}`, + mode, + } +} + +async function executeCreate({ + principal, + input, +}: { + principal: Principal + input: WriteWorkspaceFileByPathInput +}): Promise { + const parsed = parseWorkspaceFileCreatePath(input.path) + await admitCreateWorkspaceFile(principal, input.workspaceId) + + const folderUserId = await resolveFolderAttributionUserId(principal, input.workspaceId) + + const folderId = await ensureWorkspaceFileFolderPath({ + workspaceId: input.workspaceId, + userId: folderUserId, + pathSegments: parsed.folderSegments, + }) + const result = await createWorkspaceFile.execute({ + principal, + input: { + workspaceId: input.workspaceId, + name: parsed.fileName, + contentType: input.contentType, + content: input.content, + encoding: input.encoding, + folderId, + exactName: input.exactName ?? true, + secretProvenance: input.secretProvenance, + }, + }) + return toResult(result.file, 'create') +} + +async function executeOverwrite({ + principal, + input, +}: { + principal: Principal + input: WriteWorkspaceFileByPathInput +}): Promise { + const existing = await resolveWorkspaceFileReference({ + principal, + operation: fileOperations.updateContent, + workspaceId: input.workspaceId, + reference: input.path, + }) + const result = await updateWorkspaceFileContent.execute({ + principal, + input: { + fileId: existing.id, + assertedWorkspaceId: input.workspaceId, + content: input.content, + encoding: input.encoding, + contentType: input.contentType, + provenanceMode: 'replace_empty', + syncLiveDoc: input.syncLiveDoc, + secretProvenance: input.secretProvenance, + }, + }) + return toResult(result.file, 'overwrite') +} + +async function executeCreateBuffer({ + principal, + input, +}: { + principal: Principal + input: WriteWorkspaceFileBufferByPathInput +}): Promise { + const parsed = parseWorkspaceFileCreatePath(input.path) + await admitCreateWorkspaceFile(principal, input.workspaceId) + const folderUserId = await resolveFolderAttributionUserId(principal, input.workspaceId) + const folderId = await ensureWorkspaceFileFolderPath({ + workspaceId: input.workspaceId, + userId: folderUserId, + pathSegments: parsed.folderSegments, + }) + const result = await createWorkspaceFileFromBuffer.execute({ + principal, + input: { + workspaceId: input.workspaceId, + name: parsed.fileName, + contentType: input.contentType, + content: input.content, + folderId, + exactName: input.exactName ?? true, + secretProvenance: input.secretProvenance, + }, + }) + return toResult(result.file, 'create') +} + +async function executeOverwriteBuffer({ + principal, + input, +}: { + principal: Principal + input: WriteWorkspaceFileBufferByPathInput +}): Promise { + const existing = await resolveWorkspaceFileReference({ + principal, + operation: fileOperations.updateContent, + workspaceId: input.workspaceId, + reference: input.path, + }) + const result = await updateWorkspaceFileContentFromBuffer.execute({ + principal, + input: { + fileId: existing.id, + assertedWorkspaceId: input.workspaceId, + content: input.content, + contentType: input.contentType, + provenanceMode: 'replace_empty', + syncLiveDoc: input.syncLiveDoc, + secretProvenance: input.secretProvenance, + }, + }) + return toResult(result.file, 'overwrite') +} + +async function resolveFolderAttributionUserId( + principal: Principal, + workspaceId: string +): Promise { + let workspaceBillingOwnerUserId: string | undefined + if (principal.kind === 'workspace_api_key') { + const workspace = await loadActiveWorkspaceContext(workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + workspaceBillingOwnerUserId = workspace.billedAccountUserId + } + return resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId }).attributedUserId +} + +export const createWorkspaceFileByPath = { + operation: fileOperations.create, + execute: executeCreate, +} as const + +export const updateWorkspaceFileContentByPath = { + operation: fileOperations.updateContent, + execute: executeOverwrite, +} as const + +export const createWorkspaceFileBufferByPath = { + operation: fileOperations.create, + execute: executeCreateBuffer, +} as const + +export const updateWorkspaceFileContentBufferByPath = { + operation: fileOperations.updateContent, + execute: executeOverwriteBuffer, +} as const diff --git a/apps/sim/lib/workspace-files/limits.ts b/apps/sim/lib/workspace-files/limits.ts new file mode 100644 index 00000000000..5f1f72ff630 --- /dev/null +++ b/apps/sim/lib/workspace-files/limits.ts @@ -0,0 +1,2 @@ +export const MAX_WORKSPACE_FILE_BULK_REQUEST_IDS = 1_000 +export const MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS = 5_000 diff --git a/apps/sim/lib/workspace-files/workspace-file-path.ts b/apps/sim/lib/workspace-files/workspace-file-path.ts new file mode 100644 index 00000000000..f50763cc9fb --- /dev/null +++ b/apps/sim/lib/workspace-files/workspace-file-path.ts @@ -0,0 +1,29 @@ +import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { normalizeWorkspaceFileItemName } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' + +export function parseWorkspaceFileCreatePath(path: string): { + folderSegments: string[] + fileName: string + vfsPath: string +} { + const trimmed = path.trim().replace(/^\/+/, '') + if (!trimmed.startsWith('files/')) { + throw new Error('Workspace file paths must start with "files/"') + } + + const decoded = decodeVfsPathSegments(trimmed.slice('files/'.length)) + if (decoded.length === 0) { + throw new Error('Workspace file path must include a file name') + } + + const fileName = normalizeWorkspaceFileItemName(decoded.at(-1) ?? '', 'File') + const folderSegments = decoded + .slice(0, -1) + .map((segment) => normalizeWorkspaceFileItemName(segment, 'Folder')) + + return { + folderSegments, + fileName, + vfsPath: canonicalWorkspaceFilePath({ folderPath: folderSegments.join('/'), name: fileName }), + } +} diff --git a/packages/auth/package.json b/packages/auth/package.json index b6b88fb8fc8..2a6bbed626b 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -10,6 +10,10 @@ "node": ">=20.0.0" }, "exports": { + "./principal": { + "types": "./src/principal.ts", + "default": "./src/principal.ts" + }, "./verify": { "types": "./src/verify.ts", "default": "./src/verify.ts" diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts new file mode 100644 index 00000000000..18a138fa9a9 --- /dev/null +++ b/packages/auth/src/principal.ts @@ -0,0 +1,132 @@ +export type Principal = + | SessionPrincipal + | PersonalApiKeyPrincipal + | WorkspaceApiKeyPrincipal + | DelegatedPrincipal + +export interface SessionPrincipal { + kind: 'session' + userId: string + sessionId: string +} + +export interface PersonalApiKeyPrincipal { + kind: 'personal_api_key' + userId: string + keyId: string +} + +export interface WorkspaceApiKeyPrincipal { + kind: 'workspace_api_key' + workspaceId: string + keyId: string +} + +export interface DelegatedPrincipal { + kind: 'delegated' + serviceId: 'copilot' | 'executor' | 'realtime' + subjectUserId: string + workspaceId: string + delegationId: string + audience: string + issuedAt: Date + expiresAt: Date + resourceScope?: { + fileId?: string + chatId?: string + executionId?: string + } +} + +export type PrincipalActor = + | { kind: 'session'; userId: string } + | { kind: 'personal_api_key'; keyId: string; userId: string } + | { kind: 'workspace_api_key'; keyId: string; workspaceId: string } + | { + kind: 'delegated' + serviceId: DelegatedPrincipal['serviceId'] + subjectUserId: string + delegationId: string + } + +export interface PrincipalAttribution { + actor: PrincipalActor + attributedUserId: string +} + +/** + * The audit actor for an authenticated operation. + * + * `actorId` is only populated when the principal represents a real user. A + * workspace API key is deliberately actor-less in the audit table: its key and + * workspace identity remain available in `actor`, while `actorName` keeps the + * row readable without pretending the billing owner performed the action. + */ +export interface PrincipalAuditAttribution { + actor: PrincipalActor + actorId: string | null + actorName?: string +} + +export interface PrincipalAttributionContext { + workspaceBillingOwnerUserId?: string +} + +export function toPrincipalActor(principal: Principal): PrincipalActor { + switch (principal.kind) { + case 'session': + return { kind: principal.kind, userId: principal.userId } + case 'personal_api_key': + return { kind: principal.kind, keyId: principal.keyId, userId: principal.userId } + case 'workspace_api_key': + return { + kind: principal.kind, + keyId: principal.keyId, + workspaceId: principal.workspaceId, + } + case 'delegated': + return { + kind: principal.kind, + serviceId: principal.serviceId, + subjectUserId: principal.subjectUserId, + delegationId: principal.delegationId, + } + } +} + +export function resolvePrincipalAuditAttribution(principal: Principal): PrincipalAuditAttribution { + const actor = toPrincipalActor(principal) + + switch (actor.kind) { + case 'session': + return { actor, actorId: actor.userId } + case 'personal_api_key': + return { actor, actorId: actor.userId } + case 'delegated': + return { actor, actorId: actor.subjectUserId } + case 'workspace_api_key': + return { actor, actorId: null, actorName: 'Workspace API key' } + } +} + +export function resolvePrincipalAttribution( + principal: Principal, + context: PrincipalAttributionContext = {} +): PrincipalAttribution { + const actor = toPrincipalActor(principal) + + switch (actor.kind) { + case 'session': + case 'personal_api_key': + return { actor, attributedUserId: actor.userId } + case 'workspace_api_key': { + const attributedUserId = context.workspaceBillingOwnerUserId + if (!attributedUserId) { + throw new Error('Workspace API key attribution requires a workspace billing owner') + } + return { actor, attributedUserId } + } + case 'delegated': + return { actor, attributedUserId: actor.subjectUserId } + } +} diff --git a/packages/platform-authz/src/workspace.ts b/packages/platform-authz/src/workspace.ts index 0e377bd1e35..63d6671aab3 100644 --- a/packages/platform-authz/src/workspace.ts +++ b/packages/platform-authz/src/workspace.ts @@ -25,9 +25,10 @@ export async function resolveEffectiveWorkspacePermission( userId: string, workspaceId: string, workspaceOrganizationId: string | null, - executor: Pick = db + executor: Pick = db, + options?: { forUpdate?: boolean } ): Promise { - const [permissionRow] = await executor + const permissionQuery = executor .select({ permissionType: permissions.permissionType }) .from(permissions) .where( @@ -37,16 +38,20 @@ export async function resolveEffectiveWorkspacePermission( eq(permissions.entityId, workspaceId) ) ) - .limit(1) + const [permissionRow] = options?.forUpdate + ? await permissionQuery.for('update').limit(1) + : await permissionQuery.limit(1) const explicit = (permissionRow?.permissionType as PermissionType | undefined) ?? null if (workspaceOrganizationId && explicit !== 'admin') { - const [memberRow] = await executor + const memberQuery = executor .select({ role: member.role }) .from(member) .where(and(eq(member.userId, userId), eq(member.organizationId, workspaceOrganizationId))) - .limit(1) + const [memberRow] = options?.forUpdate + ? await memberQuery.for('update').limit(1) + : await memberQuery.limit(1) if (isOrgAdminRole(memberRow?.role)) { return 'admin' } diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 156960f1a5a..e27939a569e 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -142,7 +142,6 @@ const RAW_JSON_BASELINE_ROUTES = new Set([ 'apps/sim/app/api/tools/file/manage/route.ts', 'apps/sim/app/api/workspaces/invitations/batch/route.ts', 'apps/sim/app/api/workspaces/[id]/route.ts', - 'apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts', 'apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts', ]) @@ -150,6 +149,10 @@ const CONTRACT_IMPORT_PATTERN = /\bfrom\s+['"]@\/lib\/api\/contracts(?:\/[^'"]*) const PUBLIC_API_ROUTE_HANDLER_IMPORT_PATTERN = /\bimport\s*\{[^}]*\bwithPublicApiRouteHandler\b[^}]*\}\s*from\s*['"]@\/app\/api\/public-api-route-handler['"]/ const PUBLIC_API_ROUTE_HANDLER_USAGE_PATTERN = /\bwithPublicApiRouteHandler\s*\(/ +const DECLARATIVE_ROUTE_BUILDER_IMPORT_PATTERN = + /\bimport\s*\{[^}]*(?:\bdefineInternalJsonRoute\b|\bdefineV2JsonRoute\b|\bdefineInternalBinaryRoute\b|\bdefineV2BinaryRoute\b)[^}]*\}\s*from\s*['"]@\/lib\/api\/server\/routes['"]/ +const DECLARATIVE_ROUTE_BUILDER_USAGE_PATTERN = + /\b(?:defineInternalJsonRoute|defineV2JsonRoute|defineInternalBinaryRoute|defineV2BinaryRoute)\s*\(/ const SERVER_VALIDATION_IMPORT_PATTERN = /\bfrom\s+['"]@\/lib\/api\/server(?:\/validation)?['"]/ const SCHEMA_PARSE_PATTERN = /\b\w+Schema\.(?:safeParse|parse)\(/ const CONTRACT_SERVER_HELPER_PATTERN = /\bparseToolRequest\(/ @@ -727,6 +730,13 @@ function hasZodUsage(relativePath: string, content: string): boolean { ) { return true } + if ( + CONTRACT_IMPORT_PATTERN.test(content) && + DECLARATIVE_ROUTE_BUILDER_IMPORT_PATTERN.test(content) && + DECLARATIVE_ROUTE_BUILDER_USAGE_PATTERN.test(content) + ) { + return true + } if ( CONTRACT_IMPORT_PATTERN.test(content) && (SCHEMA_PARSE_PATTERN.test(content) || CONTRACT_MAP_PARSE_PATTERN.test(content))