Compare commits

...

105 Commits

Author SHA1 Message Date
Vikhyath Mondreti
5d74db53ff v0.3.33: update copilot docs 2025-08-20 09:56:09 -07:00
Siddharth Ganesan
b39bdfd55e feat(copilot-docs): update readme and docs with local hosting instructions (#1043)
* Docs

* Lint
2025-08-20 09:47:50 -07:00
Waleed Latif
6b185be9a4 v0.3.32: loop block max increase, url-encoded API calls, subflow logs, new supabase tools 2025-08-20 00:36:46 -07:00
Waleed Latif
214a0358b6 fix(billing): fix upgrade to team plan (#1045) 2025-08-20 00:28:07 -07:00
Waleed Latif
bbb5e53e43 improvement(supabase): add supabase upsert tool, insert/replace on PK conflict (#1038) 2025-08-19 21:21:09 -07:00
Waleed Latif
79e932fed9 feat(logs): added sub-workflow logs, updated trace spans UI, fix scroll behavior in workflow registry sidebar (#1037)
* added sub-workflow logs

* indent input/output in trace spans display

* better color scheme for workflow logs

* scroll behavior in sidebar updated

* cleanup

* fixed failing tests
2025-08-19 21:21:09 -07:00
Vikhyath Mondreti
9ad36c0e34 fix(oauth-block): race condition for rendering credential selectors and other subblocks + gdrive fixes (#1029)
* fix(oauth-block): race condition for rendering credential selectors and other subblocks

* fix import

* add dependsOn field to track cros-subblock deps

* remove redundant check

* remove redundant checks

* remove misleading comment

* fix

* fix jira

* fix

* fix

* confluence

* fix triggers

* fix

* fix

* make trigger creds collab supported

* fix for backwards compat

* fix trigger modal
2025-08-19 21:21:09 -07:00
Waleed Latif
2771c688ff improvement(supabase): added more verbose error logging for supabase operations (#1035)
* improvement(supabase): added more verbose error logging for supabase operations

* updated docs
2025-08-19 21:21:09 -07:00
Waleed Latif
d58ceb4bce improvement(api): add native support for form-urlencoded inputs into API block (#1033) 2025-08-19 21:21:09 -07:00
Waleed Latif
69773c3174 improvement(console): increase console max entries for larger workflows (#1032)
* improvement(console): increase console max entries for larger workflows

* increase safety limit for infinite loops
2025-08-19 21:21:09 -07:00
Waleed Latif
1619d63f2a v0.3.31: webhook fixes, advanced mode parameter filtering, credentials fixes, UI/UX improvements 2025-08-19 01:01:45 -07:00
Waleed Latif
9aa1fe8037 fix(logger): fixed logger to show prod server-side logs (#1027) 2025-08-19 00:44:24 -07:00
Emir Karabeg
1b7c111c46 Update README.md (#1026)
* Update README.md

* Update README.md
2025-08-18 23:10:18 -07:00
Siddharth Ganesan
bdfb56b262 fix(copilot): streaming (#1023)
* Fix 1

* Fix

* Bugfix

* Make thinking streaming smoother

* Better autoscroll, still not great

* Updates

* Updates

* Updates

* Restore checkpoitn logic

* Fix aborts

* Checkpoitn ui

* Lint

* Fix empty file
2025-08-18 22:48:56 -07:00
Emir Karabeg
4a7de31eee uploaded brandbook (#1024) 2025-08-18 22:04:55 -07:00
Waleed Latif
adfe56c720 improvement(logger): restore server-side logs in prod (#1022) 2025-08-18 21:01:38 -07:00
Emir Karabeg
72e3efa875 improvement(settings): ui/ux (#1021)
* completed general

* completed environment

* completed account; updated general and environment

* fixed skeleton

* finished credentials

* finished privacy; adjusted all colors and styling

* added reset password

* refactor: team and subscription

* finalized subscription settings

* fixed copilot key UI
2025-08-18 20:57:29 -07:00
Vikhyath Mondreti
b40fa3aa6e fix(picker-ui): picker UI confusing when credential not set + Microsoft OAuth Fixes (#1016)
* fix(picker-ui): picker UI confusing when credential not set

* remove comments

* remove chevron down

* fix collaboration oauth

* fix jira"

* fix

* fix ms excel selector

* fix selectors for MS blocks

* fix ms selectors

* fix

* fix ms onedrive and sharepoint

* fix to grey out dropdowns

* fix background fetches

* fix planner

* fix confluence

* fix

* fix confluence realtime sharing

* fix outlook folder selector

* check outlook folder

* make shared hook

---------

Co-authored-by: waleedlatif1 <walif6@gmail.com>
2025-08-18 20:21:23 -07:00
Waleed Latif
f924edde3a improvement(console): redact api keys from console store (#1020) 2025-08-18 16:36:33 -07:00
Waleed Latif
073030bfaa improvement(serializer): filter out advanced mode fields when executing in basic mode, persist the values but don't include them in serialized block for execution (#1018)
* improvement(serializer): filter out advanced mode fields when executing in basic mode, persist the values but don't include them in serialized block for execution

* fix serializer exclusion logic
2025-08-18 16:34:53 -07:00
Siddharth Ganesan
871f4e8e18 fix(copilot): env key validation (#1017)
* Fix v1

* Use env var

* Lint

* Fix env key validation

* Remove logger

* Fix agent url

* Fix tests
2025-08-18 16:00:56 -07:00
Siddharth Ganesan
091343a132 fix(copilot): fix origin (#1015)
* Fix v1

* Use env var

* Lint
2025-08-18 13:57:31 -07:00
Waleed Latif
63c66bfc31 fix(webhook): pin webhook URL when creating/saving generic webhook trigger (#1014)
* fix(webhook): pin webhook URL when creating a new generic webhook trigger

* change instructions copy

* remove unrelated scripts

* added optional API key for webhooks, validation tests

* remove extraneous logs
2025-08-18 13:39:49 -07:00
Waleed Latif
445ca78395 fix(export): swap upload & download icons (#1013) 2025-08-18 10:22:55 -07:00
Waleed Latif
d75cc1ed84 v0.3.30: duplication, control bar fixes 2025-08-18 08:57:26 -07:00
Waleed Latif
5a8a703ecb fix(duplicate): fixed detached state on duplication (#1011) 2025-08-18 08:51:18 -07:00
Waleed Latif
6f64188b8d fix(control-bar): fix icons styling in disabled state (#1010) 2025-08-18 08:22:06 -07:00
Vikhyath Mondreti
60a9a25553 Merge pull request #1009 from simstudioai/staging
update migration file for notekeeping purpose
2025-08-18 01:59:02 -07:00
Vikhyath Mondreti
52fa388f81 update migration file for notekeeping purpose 2025-08-18 01:56:34 -07:00
Vikhyath Mondreti
5c56cbd558 Merge pull request #1008 from simstudioai/staging
reduce batch size to prevent timeouts
2025-08-18 01:11:49 -07:00
Vikhyath Mondreti
dc19525a6f reduce batch size to prevent timeouts 2025-08-18 01:10:47 -07:00
Vikhyath Mondreti
3873f44875 Merge pull request #1007 from simstudioai/staging
syntax issue in migration
2025-08-18 00:59:53 -07:00
Vikhyath Mondreti
09b95f41ea syntax issue in migration 2025-08-18 00:58:09 -07:00
Vikhyath Mondreti
af60ccd188 fix: migration mem issues bypass
fix: migration mem issues bypass
2025-08-18 00:50:20 -07:00
Vikhyath Mondreti
eb75afd115 make logs migration batched to prevent mem issues (#1005) 2025-08-18 00:42:38 -07:00
Waleed Latif
fdb8256468 fix(subflow): remove all edges when removing a block from a subflow (#1003) 2025-08-18 00:21:26 -07:00
Vikhyath Mondreti
570c07bf2a Merge pull request #1004 from simstudioai/staging
v0.3.29: copilot fixes, remove block from subflow, code cleanups
2025-08-18 00:18:44 -07:00
Adam Gough
5c16e7d390 fix(subflow): add ability to remove block from subflow and refactor to consolidate subflow code (#983)
* added logic to remove blocks from subflows

* refactored logic into just subflow-node

* bun run lint

* added subflow test

* added a safety check for data.parentId

* added state update logic

* bun run lint

* removed old logic

* removed any

* added tests

* added type safety

* removed test script

* type safety

---------

Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
Co-authored-by: waleedlatif1 <walif6@gmail.com>
2025-08-17 22:25:31 -07:00
Waleed Latif
bd38062705 fix(workflow-error): allow users to delete workflows with invalid configs/state (#1000)
* fix(workflow-error): allow users to delete workflows with invalid configs/state

* cleanup
2025-08-17 22:23:41 -07:00
Siddharth Ganesan
d7fd4a9618 feat(copilot): diff improvements (#1002)
* Fix abort

* Cred updates

* Updates

* Fix sheet id showing up in diff view

* Update diff view

* Text overflow

* Optimistic accept

* Serialization catching

* Depth 0 fix

* Fix icons

* Updates

* Lint
2025-08-16 15:09:48 -07:00
Vikhyath Mondreti
d972bab206 fix(logs-sidebar): remove message and fix race condition for quickly switching b/w logs (#1001) 2025-08-16 15:05:39 -07:00
Vikhyath Mondreti
f254d70624 improvement(logs): cleanup code (#999) 2025-08-16 13:44:00 -07:00
Waleed Latif
8748e1d5f9 improvement(db): remove deprecated 'state' column from workflow table (#994)
* improvement(db): remove deprecated  column from workflow table

* removed extraneous logs

* update sockets envvar
2025-08-16 13:04:49 -07:00
Siddharth Ganesan
133a32e6d3 Fix abort (#998) 2025-08-16 11:10:09 -07:00
Waleed Latif
97b6bcc43d v0.3.28: autolayout, export, copilot, kb ui improvements 2025-08-16 09:12:17 -07:00
Waleed Latif
42917ce641 fix(agent): stringify input into user prompt for agent (#984) 2025-08-15 19:36:49 -07:00
Waleed Latif
5f6d219223 fix(kb-ui): fixed upload files modal ui, processing ui to match the rest of the kb (#991)
* fix(kb-ui): fixed upload files modal, processing ui to match the rest of the kb

* more ui fixes

* ack PR comments

* fix help modal
2025-08-15 19:35:50 -07:00
Siddharth Ganesan
bab74307f4 fix(ishosted): make ishosted true on staging (#993)
* Add staging to ishosted

* www
2025-08-15 18:36:32 -07:00
Siddharth Ganesan
16aaa37dad improvement(agent): enable autolayout, export, copilot (#992)
* Enable autolayout, export, and copilot in dev

* Updates
2025-08-15 18:29:34 -07:00
Siddharth Ganesan
c6166a9483 feat(copilot): generate agent api key (#989)
* Add skeleton copilot to settings modal and add migration for copilot api keys

* Add hash index on encrypted key

* Security 1

* Remove sim agent api key

* Fix api key stuff

* Auth

* Status code handling

* Update env key

* Copilot api key ui

* Update copilot costs

* Add copilot stats

* Lint

* Remove logs

* Remove migrations

* Remove another migration

* Updates

* Hide if hosted

* Fix test

* Lint

* Lint

* Fixes

* Lint

---------

Co-authored-by: Waleed Latif <walif6@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
2025-08-15 18:05:54 -07:00
Waleed Latif
0258a1b4ce fix(loading): fix workflow detached on first load (#987) 2025-08-15 17:26:47 -07:00
Vikhyath Mondreti
4d4aefa346 fix(envvar): clear separation between server-side and client-side billing envvar (#988) 2025-08-15 16:41:02 -07:00
Vikhyath Mondreti
a0cf003abf Merge pull request #986 from simstudioai/staging
attempt to fix build issues (#985)
2025-08-15 15:22:26 -07:00
Vikhyath Mondreti
2e027dd77d attempt to fix build issues (#985) 2025-08-15 15:21:34 -07:00
Vikhyath Mondreti
6133db53d0 v0.3.27: oauth/webhook fixes, whitelabel fixes, code cleanups
v0.3.27: oauth/webhook fixes, whitelabel fixes, code cleanups
2025-08-15 13:33:55 -07:00
Waleed Latif
03bb437e09 fix(chat-deploy): fixed chat-deploy (#981) 2025-08-15 13:07:54 -07:00
Vikhyath Mondreti
9f02f88bf5 fix(oauth): webhook + oauthblocks in workflow (#979)
* fix(oauth): webhook + oauthblocks in workflow

* propagate workflow id

* requireWorkflowId for internal can be false
2025-08-15 13:07:46 -07:00
Waleed Latif
7a1711282e improvement/function: remove unused function execution logic in favor of vm, update turborepo (#980)
* improvement(function): remove freestyle in favor of vm exec

* update imports

* remove unused test suite

* update turborepo
2025-08-15 12:51:27 -07:00
Waleed Latif
58613888b0 improvement(redirects): move redirects to middleware, push to login if no session and workspace if session exists, remove telemetry consent dialog (#976)
* improvement(redirects): move redirects to middleware, push to login if no session and workspace if session exists

* remove telemetry consent dialog

* remove migrations

* rerun migrations
2025-08-15 12:36:34 -07:00
Waleed Latif
f1fe2f52cc improvement(billing): add billing enforcement for webhook executions, consolidate helpers (#975)
* fix(billing): clinet-side envvar for billing

* remove unrelated files

* fix(billing): add billing enforcement for webhook executions, consolidate implementation

* cleanup

* add back server envvar
2025-08-15 12:28:34 -07:00
Waleed Latif
7d05999a70 fix(force-dynamic): revert force-dynamic for the 38 routes that we previously added it to (#971) 2025-08-15 12:05:51 -07:00
Siddharth Ganesan
bf07240cfa Fix user message color (#978) 2025-08-15 11:59:28 -07:00
Siddharth Ganesan
0c7a8efc8d feat(copilot): add depths (#974)
* Checkpont

* can edit names and types

* Add reasoning and thinking

* Update agent max

* Max mode v1

* Add best practices

* Todo list shows up

* Todolist works

* Updates to todo

* Updates

* Updates

* Checkpoitn

* Yaml export updates

* Updates

* Checkpoint fr

* Fix diff veiw on new workflow

* Subflow autolayout fix v1

* Autolayout fixes 2

* Gdrive list files

* Get oauth credential (email)

* Gdrive file picker

* Gdrive file access prompt

* Api request

* Copilot ui for some tool calls

* Updates

* Fix overflow

* Openai

* Streaming

* Checkpoint

* Update

* Openai responses api

* Depth skeleton

* Depth tooltips

* Mode selector tool tips

* Update ui

* Update ordering

* Lint

* Remove migrations

* Add migrations back

* Lint

* Fix isdev

* Fix tests

* Comments

---------

Co-authored-by: Waleed Latif <walif6@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
2025-08-15 11:37:58 -07:00
Vikhyath Mondreti
f081f5a73c Revert 1a7de84 except tag dropdown changes (keep apps/sim/components/ui/tag-dropdown.tsx) (#972) 2025-08-15 00:37:16 -07:00
Waleed Latif
72c07e8ad2 fix(whitelabel): fix privacy policy & terms, remove unused/unnecessary envvars for whitelabeling (#969)
* fix(whitelabel): fix privacy policy & terms for whitelabeling

* remove unused hide branding url

* removed support email envvar, remove landing page except for hosted version

* remove unnecessary comments

* removed primary, secondary, accent color envvars and standardized usage of brand colors in css file

* fix primaryColor refernce

* fix invalid css
2025-08-14 20:03:01 -07:00
Vikhyath Mondreti
e1f04f42f8 v0.3.26: fix billing, bubble up workflow block errors, credentials security improvements
v0.3.26: fix billing, bubble up workflow block errors, credentials security improvements
2025-08-14 14:17:25 -05:00
Vikhyath Mondreti
fd9e61f85a improvement(credentials-security): use clear credentials sharing helper, fix google sheets block url split bug (#968)
* improvement(credentials-sharing-security): cleanup and reuse helper to determine credential access

* few more routes

* fix google sheets block

* fix test mocks

* fix calendar route
2025-08-14 14:13:18 -05:00
Waleed Latif
f1934fe76b fix(billing): separate client side and server side envvars for billing (#966) 2025-08-14 11:29:02 -07:00
Vikhyath Mondreti
ac41bf8c17 Revert "fix(workflow-block): revert change bubbling up error for workflow block" (#965)
* Revert "fix(workflow-block): revert change bubbling up error for workflow blo…"

This reverts commit 9f0993ed57.

* revert test changes
2025-08-14 12:18:47 -05:00
Vikhyath Mondreti
56ffb538a0 Merge pull request #964 from simstudioai/staging
v0.3.25: oauth credentials sharing mechanism, workflow block error handling changes
2025-08-14 02:36:19 -05:00
Vikhyath Mondreti
2e8f051e58 fix workflow block test 2025-08-14 02:28:17 -05:00
Vikhyath Mondreti
9f0993ed57 fix(workflow-block): revert change bubbling up error for workflow block (#963) 2025-08-14 02:18:18 -05:00
Waleed Latif
472a22cc94 improvement(helm): added template for external db secret (#957) 2025-08-13 21:21:46 -07:00
Waleed Latif
da04ea0e9f fix(subflows): added change detection for parallels, updated deploy and status schemas to match parallel/loop (#956) 2025-08-13 21:18:07 -07:00
Waleed Latif
d4f412af92 fix(api): fix api post and get without stringifying (#955) 2025-08-13 18:49:22 -05:00
Siddharth Ganesan
70fa628a2a improvement(uploads): add multipart upload + batching + retries (#938)
* File upload retries + multipart uploads

* Lint

* FIle uploads

* File uploads 2

* Lint

* Fix file uploads

* Add auth to file upload routes

* Lint
2025-08-13 15:18:14 -07:00
Vikhyath Mondreti
b159d63fbb improvement(oauth): credentials sharing for workflows (#939)
* improvement(oauth): credential UX while sharing workflows

* fix tests

* address greptile comments

* fix linear, jira, folder selectors

* fix routes

* fix linear

* jira fix attempt

* jira fix attempt

* jira fixes

* fix

* fix

* fix jira

* fix selector disable behaviour

* minor fixes

* clear selectors correctly

* fix project selector jira

* fix gdrive

* fix labels dropdown

* fix webhook realtime collab

* fix

* fix webhooks persistence

* fix folders route

* fix lint

* test webhook intermittent error

* fix

* fix display
2025-08-13 16:51:46 -05:00
Adam Gough
5dfe9330bb added file for microsoft verification (#946)
Co-authored-by: Adam Gough <adamgough@Adams-MacBook-Pro.local>
2025-08-13 12:18:31 -05:00
Waleed Latif
4107948554 Merge pull request #954 from simstudioai/staging
fix
2025-08-12 21:12:18 -07:00
Vikhyath Mondreti
7ebc87564d fix(double-read): API Block (#950)
* fix(double-read-http): double reading body json

* fix

* fix tests
2025-08-12 23:08:31 -05:00
Vikhyath Mondreti
8aa0ed19f1 Revert "fix(api): fix api block (#951)" (#953)
This reverts commit 8016af60f4.
2025-08-12 23:05:08 -05:00
Waleed Latif
f7573fadb1 v0.3.24: api block fixes 2025-08-12 20:35:07 -07:00
Waleed Latif
8016af60f4 fix(api): fix api block (#951) 2025-08-12 20:31:41 -07:00
Vikhyath Mondreti
8fccd5c20d Merge pull request #948 from simstudioai/staging
v0.3.24: revert redis session management change
2025-08-12 17:56:16 -05:00
Vikhyath Mondreti
8de06b63d1 Revert "improvement(performance): use redis for session data (#934)" (#947)
This reverts commit 3c7b3e1a4b.
2025-08-12 17:30:21 -05:00
Vikhyath Mondreti
1c818b2e3e v0.3.23: multiplayer variables, api key fixes, kb improvements, triggers fixes
v0.3.23: multiplayer variables, api key fixes, kb improvements, triggers fixes
2025-08-12 15:23:09 -05:00
Vikhyath Mondreti
1a7de84c7a fix(tag-dropdown): last char dropped bug (#945) 2025-08-12 11:48:34 -05:00
Waleed Latif
a2dea384a4 fix(kb): kb-level deletion should reflect in doc level kb tags sidebar registry (#944) 2025-08-12 09:26:28 -07:00
Waleed Latif
1c3e923f1b fix(kb-ui): fixed tags hover effect (#942) 2025-08-12 08:49:19 -07:00
Waleed Latif
e1d5e38528 fix(chunks): instantaneous search + server side searching instead of client-side (#940)
* fix(chunks): instantaneous search + server side searching instead of client-side

* add knowledge tags component to sidebar, replace old knowledge tags UI

* add types, remove extraneous comments

* added knowledge-base level tag definitions viewer, ability to create/delete slots in sidebar and respective routes

* ui

* fix stale tag issue

* use logger
2025-08-12 01:53:47 -07:00
Waleed Latif
3c7b3e1a4b improvement(performance): use redis for session data (#934) 2025-08-11 22:42:22 -05:00
Waleed Latif
bc455d5bf4 feat(variables): multiplayer variables through sockets, persist server side (#933)
* feat(variables): multiplayer variables through sockets, persist server side

* remove extraneous comments

* breakout variables handler in sockets
2025-08-11 18:32:21 -05:00
Waleed Latif
2a333c7cf7 fix(kb): added proper pagination for documents in kb (#937) 2025-08-11 14:16:15 -07:00
Adam Gough
41cc0cdadc fix(webhooks): fixed all webhook structures (#935)
* fix for variable format + trig

* fixed slack variable

* microsoft teams working

* fixed outlook, plus added other minor documentation changes and fixed subblock

* removed discord webhook logic

* added airtable logic

* bun run lint

* test

* test again

* test again 2

* test again 3

* test again 4

* test again 4

* test again 4

* bun run lint

* test 5

* test 6

* test 7

* test 7

* test 7

* test 7

* test 7

* test 7

* test 8

* test 9

* test 9

* test 9

* test 10

* test 10

* bun run lint, plus github fixed

* removed some debug statements #935

* testing resolver removing

* testing trig

---------

Co-authored-by: Adam Gough <adamgough@Adams-MacBook-Pro.local>
Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
2025-08-11 12:50:55 -07:00
Waleed Latif
70aeb0c298 fix(sidebar-ui): fix small ui bug to close gap when creating new workflow (#932) 2025-08-10 18:33:01 -07:00
Emir Karabeg
83f113984d feat(usage-indicator): added ability to see current usage (#925)
* feat(usage-indicator): added ability to see current usage

* feat(billing): added billing ennabled flag for usage indicator, enforcement of billing usage

---------

Co-authored-by: waleedlatif1 <walif6@gmail.com>
2025-08-10 17:20:53 -07:00
Waleed Latif
56ede1c980 improvement(tools): removed transformError, isInternalRoute, directExecution (#928)
* standardized response format for transformError

* removed trasnformError, moved error handling to executeTool for all different error formats

* remove isInternalRoute, make it implicit in executeTool

* removed directExecution, everything on the server nothing on the client

* fix supabase

* fix(tag-dropdown): fix values for parallel & loop blocks (#929)

* fix(search-modal): add parallel and loop blocks to search modal

* reordered tool params

* update docs
2025-08-10 17:19:46 -07:00
Waleed Latif
df16382a19 improvement(subflow): consolidated parallel/loop tags and collaborativeUpdate (#931)
* fix(console): fix typo

* improvement(subflows): consolidated subflows tags
2025-08-10 17:19:21 -07:00
Waleed Latif
e271ed86b6 improvement(console): added iteration info to console entry for parallel/loop (#930) 2025-08-10 16:27:39 -07:00
Waleed Latif
785b86a32e fix(tag-dropdown): fix values for parallel & loop blocks (#929) 2025-08-10 11:55:56 -07:00
Waleed Latif
e5e8082de4 fix(workflow-block): improvements to pulsing effect, active execution state, and running workflow blocks in parallel (#927)
* fix: same child workflow executing in parallel with workflow block

* fixed run button prematurely showing completion before child workflows completed

* prevent child worklfows from touching the activeBlocks & layer logic in the parent executor

* surface child workflow errors to main workfow

* ack PR comments
2025-08-09 16:57:56 -07:00
Waleed Latif
8a08afd733 improvement(control-bar): standardize styling across all control bar buttons (#926) 2025-08-09 12:32:37 -07:00
Vikhyath Mondreti
ebb25469ab fix(apikeys): pinned api key to track API key a workflow is deployed with (#924)
* fix(apikeys): pinned api key to track API key a workflow is deployed with

* remove deprecated behaviour tests
2025-08-09 01:37:27 -05:00
Waleed Latif
a2040322e7 fix(chat): fix chat attachments style in dark mode (#923) 2025-08-08 20:12:30 -07:00
Waleed Latif
a8be7e9fb3 fix(help): fix email for help route (#922) 2025-08-08 20:06:19 -07:00
657 changed files with 61182 additions and 19677 deletions

View File

@@ -416,8 +416,8 @@ In addition, you will need to update the registries:
Your tool should export a constant with a naming convention of `{toolName}Tool`. The tool ID should follow the format `{provider}_{tool_name}`. For example:
```typescript:/apps/sim/tools/pinecone/fetch.ts
import { ToolConfig, ToolResponse } from '../types'
import { PineconeParams, PineconeResponse } from './types'
import { ToolConfig, ToolResponse } from '@/tools/types'
import { PineconeParams, PineconeResponse } from '@/tools/pinecone/types'
export const fetchTool: ToolConfig<PineconeParams, PineconeResponse> = {
id: 'pinecone_fetch', // Follow the {provider}_{tool_name} format
@@ -448,9 +448,6 @@ In addition, you will need to update the registries:
transformResponse: async (response: Response) => {
// Transform response
},
transformError: (error) => {
// Handle errors
},
}
```
@@ -458,7 +455,7 @@ In addition, you will need to update the registries:
Update the tools registry in `/apps/sim/tools/index.ts` to include your new tool:
```typescript:/apps/sim/tools/index.ts
import { fetchTool, generateEmbeddingsTool, searchTextTool } from './pinecone'
import { fetchTool, generateEmbeddingsTool, searchTextTool } from '/@tools/pinecone'
// ... other imports
export const tools: Record<string, ToolConfig> = {

View File

@@ -1,50 +1,46 @@
<p align="center">
<img src="apps/sim/public/static/sim.png" alt="Sim Logo" width="500"/>
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer">
<img src="apps/sim/public/logo/reverse/text/large.png" alt="Sim Logo" width="500"/>
</a>
</p>
<p align="center">
<a href="https://www.apache.org/licenses/LICENSE-2.0"><img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg" alt="License: Apache-2.0"></a>
<a href="https://discord.gg/Hr4UWYEcTT"><img src="https://img.shields.io/badge/Discord-Join%20Server-7289DA?logo=discord&logoColor=white" alt="Discord"></a>
<a href="https://x.com/simdotai"><img src="https://img.shields.io/twitter/follow/simstudioai?style=social" alt="Twitter"></a>
<a href="https://github.com/simstudioai/sim/pulls"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs welcome"></a>
<a href="https://docs.sim.ai"><img src="https://img.shields.io/badge/Docs-visit%20documentation-blue.svg" alt="Documentation"></a>
</p>
<p align="center">Build and deploy AI agent workflows in minutes.</p>
<p align="center">
<strong>Sim</strong> is a lightweight, user-friendly platform for building AI agent workflows.
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA" alt="Sim.ai"></a>
<a href="https://discord.gg/Hr4UWYEcTT" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white" alt="Discord"></a>
<a href="https://x.com/simdotai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/twitter/follow/simstudioai?style=social" alt="Twitter"></a>
<a href="https://docs.sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Docs-6F3DFA.svg" alt="Documentation"></a>
</p>
<p align="center">
<img src="apps/sim/public/static/demo.gif" alt="Sim Demo" width="800"/>
</p>
## Getting Started
## Quickstart
1. Use our [cloud-hosted version](https://sim.ai)
2. Self-host using one of the methods below
### Cloud-hosted: [sim.ai](https://sim.ai)
## Self-Hosting Options
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iNjE2IiBoZWlnaHQ9IjYxNiIgdmlld0JveD0iMCAwIDYxNiA2MTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMF8xMTU5XzMxMykiPgo8cGF0aCBkPSJNNjE2IDBIMFY2MTZINjE2VjBaIiBmaWxsPSIjNkYzREZBIi8+CjxwYXRoIGQ9Ik04MyAzNjUuNTY3SDExM0MxMTMgMzczLjgwNSAxMTYgMzgwLjM3MyAxMjIgMzg1LjI3MkMxMjggMzg5Ljk0OCAxMzYuMTExIDM5Mi4yODUgMTQ2LjMzMyAzOTIuMjg1QzE1Ny40NDQgMzkyLjI4NSAxNjYgMzkwLjE3MSAxNzIgMzg1LjkzOUMxNzcuOTk5IDM4MS40ODcgMTgxIDM3NS41ODYgMTgxIDM2OC4yMzlDMTgxIDM2Mi44OTUgMTc5LjMzMyAzNTguNDQyIDE3NiAzNTQuODhDMTcyLjg4OSAzNTEuMzE4IDE2Ny4xMTEgMzQ4LjQyMiAxNTguNjY3IDM0Ni4xOTZMMTMwIDMzOS41MTdDMTE1LjU1NSAzMzUuOTU1IDEwNC43NzggMzMwLjQ5OSA5Ny42NjY1IDMyMy4xNTFDOTAuNzc3NSAzMTUuODA0IDg3LjMzMzQgMzA2LjExOSA4Ny4zMzM0IDI5NC4wOTZDODcuMzMzNCAyODQuMDc2IDg5Ljg4OSAyNzUuMzkyIDk0Ljk5OTYgMjY4LjA0NUMxMDAuMzMzIDI2MC42OTcgMTA3LjU1NSAyNTUuMDIgMTE2LjY2NiAyNTEuMDEyQzEyNiAyNDcuMDA0IDEzNi42NjcgMjQ1IDE0OC42NjYgMjQ1QzE2MC42NjcgMjQ1IDE3MSAyNDcuMTE2IDE3OS42NjcgMjUxLjM0NkMxODguNTU1IDI1NS41NzYgMTk1LjQ0NCAyNjEuNDc3IDIwMC4zMzMgMjY5LjA0N0MyMDUuNDQ0IDI3Ni42MTcgMjA4LjExMSAyODUuNjM0IDIwOC4zMzMgMjk2LjA5OUgxNzguMzMzQzE3OC4xMTEgMjg3LjYzOCAxNzUuMzMzIDI4MS4wNyAxNjkuOTk5IDI3Ni4zOTRDMTY0LjY2NiAyNzEuNzE5IDE1Ny4yMjIgMjY5LjM4MSAxNDcuNjY3IDI2OS4zODFDMTM3Ljg4OSAyNjkuMzgxIDEzMC4zMzMgMjcxLjQ5NiAxMjUgMjc1LjcyNkMxMTkuNjY2IDI3OS45NTcgMTE3IDI4NS43NDYgMTE3IDI5My4wOTNDMTE3IDMwNC4wMDMgMTI1IDMxMS40NjIgMTQxIDMxNS40N0wxNjkuNjY3IDMyMi40ODNDMTgzLjQ0NSAzMjUuNiAxOTMuNzc4IDMzMC43MjIgMjAwLjY2NyAzMzcuODQ3QzIwNy41NTUgMzQ0Ljc0OSAyMTEgMzU0LjIxMiAyMTEgMzY2LjIzNUMyMTEgMzc2LjQ3NyAyMDguMjIyIDM4NS40OTQgMjAyLjY2NiAzOTMuMjg3QzE5Ny4xMTEgNDAwLjg1NyAxODkuNDQ0IDQwNi43NTggMTc5LjY2NyA0MTAuOTg5QzE3MC4xMTEgNDE0Ljk5NiAxNTguNzc4IDQxNyAxNDUuNjY3IDQxN0MxMjYuNTU1IDQxNyAxMTEuMzMzIDQxMi4zMjUgOTkuOTk5NyA0MDIuOTczQzg4LjY2NjggMzkzLjYyMSA4MyAzODEuMTUzIDgzIDM2NS41NjdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNMjMyLjI5MSA0MTNWMjUwLjA4MkMyNDQuNjg0IDI1NC42MTQgMjUwLjE0OCAyNTQuNjE0IDI2My4zNzEgMjUwLjA4MlY0MTNIMjMyLjI5MVpNMjQ3LjUgMjM5LjMxM0MyNDEuOTkgMjM5LjMxMyAyMzcuMTQgMjM3LjMxMyAyMzIuOTUyIDIzMy4zMTZDMjI4Ljk4NCAyMjkuMDk1IDIyNyAyMjQuMjA5IDIyNyAyMTguNjU2QzIyNyAyMTIuODgyIDIyOC45ODQgMjA3Ljk5NSAyMzIuOTUyIDIwMy45OTdDMjM3LjE0IDE5OS45OTkgMjQxLjk5IDE5OCAyNDcuNSAxOThDMjUzLjIzMSAxOTggMjU4LjA4IDE5OS45OTkgMjYyLjA0OSAyMDMuOTk3QzI2Ni4wMTYgMjA3Ljk5NSAyNjggMjEyLjg4MiAyNjggMjE4LjY1NkMyNjggMjI0LjIwOSAyNjYuMDE2IDIyOS4wOTUgMjYyLjA0OSAyMzMuMzE2QzI1OC4wOCAyMzcuMzEzIDI1My4yMzEgMjM5LjMxMyAyNDcuNSAyMzkuMzEzWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTMxOS4zMzMgNDEzSDI4OFYyNDkuNjc2SDMxNlYyNzcuMjMzQzMxOS4zMzMgMjY4LjEwNCAzMjUuNzc4IDI2MC4zNjQgMzM0LjY2NyAyNTQuMzUyQzM0My43NzggMjQ4LjExNyAzNTQuNzc4IDI0NSAzNjcuNjY3IDI0NUMzODIuMTExIDI0NSAzOTQuMTEyIDI0OC44OTcgNDAzLjY2NyAyNTYuNjlDNDEzLjIyMiAyNjQuNDg0IDQxOS40NDQgMjc0LjgzNyA0MjIuMzM0IDI4Ny43NTJINDE2LjY2N0M0MTguODg5IDI3NC44MzcgNDI1IDI2NC40ODQgNDM1IDI1Ni42OUM0NDUgMjQ4Ljg5NyA0NTcuMzM0IDI0NSA0NzIgMjQ1QzQ5MC42NjYgMjQ1IDUwNS4zMzQgMjUwLjQ1NSA1MTYgMjYxLjM2NkM1MjYuNjY3IDI3Mi4yNzYgNTMyIDI4Ny4xOTUgNTMyIDMwNi4xMjFWNDEzSDUwMS4zMzNWMzEzLjgwNEM1MDEuMzMzIDMwMC44ODkgNDk4IDI5MC45ODEgNDkxLjMzMyAyODQuMDc4QzQ4NC44ODkgMjc2Ljk1MiA0NzYuMTExIDI3My4zOSA0NjUgMjczLjM5QzQ1Ny4yMjIgMjczLjM5IDQ1MC4zMzMgMjc1LjE3MSA0NDQuMzM0IDI3OC43MzRDNDM4LjU1NiAyODIuMDc0IDQzNCAyODYuOTcyIDQzMC42NjcgMjkzLjQzQzQyNy4zMzMgMjk5Ljg4NyA0MjUuNjY3IDMwNy40NTcgNDI1LjY2NyAzMTYuMTQxVjQxM0gzOTQuNjY3VjMxMy40NjlDMzk0LjY2NyAzMDAuNTU1IDM5MS40NDUgMjkwLjc1OCAzODUgMjg0LjA3OEMzNzguNTU2IDI3Ny4xNzUgMzY5Ljc3OCAyNzMuNzI0IDM1OC42NjcgMjczLjcyNEMzNTAuODg5IDI3My43MjQgMzQ0IDI3NS41MDUgMzM4IDI3OS4wNjhDMzMyLjIyMiAyODIuNDA4IDMyNy42NjcgMjg3LjMwNyAzMjQuMzMzIDI5My43NjNDMzIxIDI5OS45OTggMzE5LjMzMyAzMDcuNDU3IDMxOS4zMzMgMzE2LjE0MVY0MTNaIiBmaWxsPSJ3aGl0ZSIvPgo8L2c+CjxkZWZzPgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzExNTlfMzEzIj4KPHJlY3Qgd2lkdGg9IjYxNiIgaGVpZ2h0PSI2MTYiIGZpbGw9IndoaXRlIi8+CjwvY2xpcFBhdGg+CjwvZGVmcz4KPC9zdmc+Cg==&logoColor=white" alt="Sim.ai"></a>
### Option 1: NPM Package (Simplest)
The easiest way to run Sim locally is using our [NPM package](https://www.npmjs.com/package/simstudio?activeTab=readme):
### Self-hosted: NPM Package
```bash
npx simstudio
```
→ http://localhost:3000
After running these commands, open [http://localhost:3000/](http://localhost:3000/) in your browser.
#### Note
Docker must be installed and running on your machine.
#### Options
- `-p, --port <port>`: Specify the port to run Sim on (default: 3000)
- `--no-pull`: Skip pulling the latest Docker images
| Flag | Description |
|------|-------------|
| `-p, --port <port>` | Port to run Sim on (default `3000`) |
| `--no-pull` | Skip pulling latest Docker images |
#### Requirements
- Docker must be installed and running on your machine
### Option 2: Docker Compose
### Self-hosted: Docker Compose
```bash
# Clone the repository
@@ -76,14 +72,14 @@ Wait for the model to download, then visit [http://localhost:3000](http://localh
docker compose -f docker-compose.ollama.yml exec ollama ollama pull llama3.1:8b
```
### Option 3: Dev Containers
### Self-hosted: Dev Containers
1. Open VS Code with the [Remote - Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
2. Open the project and click "Reopen in Container" when prompted
3. Run `bun run dev:full` in the terminal or use the `sim-start` alias
- This starts both the main application and the realtime socket server
### Option 4: Manual Setup
### Self-hosted: Manual Setup
**Requirements:**
- [Bun](https://bun.sh/) runtime
@@ -158,6 +154,14 @@ cd apps/sim
bun run dev:sockets
```
## Copilot API Keys
Copilot is a Sim-managed service. To use Copilot on a self-hosted instance:
- Go to https://sim.ai → Settings → Copilot and generate a Copilot API key
- Set `COPILOT_API_KEY` in your self-hosted environment to that value
- Host Sim on a publicly available DNS and set NEXT_PUBLIC_APP_URL and BETTER_AUTH_URL to that value ([ngrok](https://ngrok.com/))
## Tech Stack
- **Framework**: [Next.js](https://nextjs.org/) (App Router)
@@ -180,4 +184,4 @@ We welcome contributions! Please see our [Contributing Guide](.github/CONTRIBUTI
This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
<p align="center">Made with ❤️ by the Sim Team</p>
<p align="center">Made with ❤️ by the Sim Team</p>

View File

@@ -0,0 +1,97 @@
---
title: Copilot
description: Build and edit workflows with Sim Copilot
---
import { Callout } from 'fumadocs-ui/components/callout'
import { Card, Cards } from 'fumadocs-ui/components/card'
import { MessageCircle, Package, Zap, Infinity as InfinityIcon, Brain, BrainCircuit } from 'lucide-react'
## What is Copilot
Copilot is your in-editor assistant that helps you build, understand, and improve workflows. It can:
- **Explain**: Answer questions about Sim and your current workflow
- **Guide**: Suggest edits and best practices
- **Edit**: Make changes to blocks, connections, and settings when you approve
<Callout type="info">
Copilot is a Sim-managed service. For self-hosted deployments, generate a Copilot API key in the hosted app (sim.ai → Settings → Copilot)
1. Go to [sim.ai](https://sim.ai) → Settings → Copilot and generate a Copilot API key
2. Set `COPILOT_API_KEY` in your self-hosted environment to that value
3. Host Sim on a publicly available DNS and set `NEXT_PUBLIC_APP_URL` and `BETTER_AUTH_URL` to that value (e.g., using ngrok)
</Callout>
## Modes
<Cards>
<Card title="Ask">
<div className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md border border-border/50 bg-muted/60">
<MessageCircle className="h-4 w-4 text-muted-foreground" />
</span>
<div>
<p className="m-0 text-sm">
Q&A mode for explanations, guidance, and suggestions without making changes to your workflow.
</p>
</div>
</div>
</Card>
<Card title="Agent">
<div className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md border border-border/50 bg-muted/60">
<Package className="h-4 w-4 text-muted-foreground" />
</span>
<div>
<p className="m-0 text-sm">
Build-and-edit mode. Copilot proposes specific edits (add blocks, wire variables, tweak settings) and applies them when you approve.
</p>
</div>
</div>
</Card>
</Cards>
## Depth Levels
<Cards>
<Card title="Fast">
<div className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md border border-border/50 bg-muted/60">
<Zap className="h-4 w-4 text-muted-foreground" />
</span>
<div>
<p className="m-0 text-sm">Quickest and cheapest. Best for small edits, simple workflows, and minor tweaks.</p>
</div>
</div>
</Card>
<Card title="Auto">
<div className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md border border-border/50 bg-muted/60">
<InfinityIcon className="h-4 w-4 text-muted-foreground" />
</span>
<div>
<p className="m-0 text-sm">Balanced speed and reasoning. Recommended default for most tasks.</p>
</div>
</div>
</Card>
<Card title="Pro">
<div className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md border border-border/50 bg-muted/60">
<Brain className="h-4 w-4 text-muted-foreground" />
</span>
<div>
<p className="m-0 text-sm">More reasoning for larger workflows and complex edits while staying performant.</p>
</div>
</div>
</Card>
<Card title="Max">
<div className="flex items-start gap-3">
<span className="mt-0.5 inline-flex h-8 w-8 items-center justify-center rounded-md border border-border/50 bg-muted/60">
<BrainCircuit className="h-4 w-4 text-muted-foreground" />
</span>
<div>
<p className="m-0 text-sm">Maximum reasoning for deep planning, debugging, and complex architectural changes.</p>
</div>
</div>
</Card>
</Cards>

View File

@@ -0,0 +1,4 @@
{
"title": "Copilot",
"pages": ["index"]
}

View File

@@ -12,6 +12,8 @@
"connections",
"---Execution---",
"execution",
"---Copilot---",
"copilot",
"---Advanced---",
"./variables/index",
"yaml",

View File

@@ -151,8 +151,6 @@ Update multiple existing records in an Airtable table
| `baseId` | string | Yes | ID of the Airtable base |
| `tableId` | string | Yes | ID or name of the table |
| `records` | json | Yes | Array of records to update, each with an `id` and a `fields` object |
| `fields` | string | No | No description |
| `fields` | string | No | No description |
#### Output

View File

@@ -82,9 +82,10 @@ Runs a browser automation task using BrowserUse
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Operation success status |
| `output` | json | Browser automation task results including task ID, success status, output data, and execution steps |
| `error` | string | Error message if the operation failed |
| `id` | string | Task execution identifier |
| `success` | boolean | Task completion status |
| `output` | json | Task output data |
| `steps` | json | Execution steps taken |

View File

@@ -62,7 +62,7 @@ Convert TTS using ElevenLabs voices
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `audioUrl` | string | Generated audio URL |
| `audioUrl` | string | The URL of the generated audio |

View File

@@ -71,8 +71,8 @@ Parse one or more uploaded files or files from URLs (text, PDF, CSV, images, etc
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `files` | json | Array of parsed file objects with content, metadata, and file properties |
| `combinedContent` | string | All file contents merged into a single text string |
| `files` | array | Array of parsed files |
| `combinedContent` | string | Combined content of all parsed files |

View File

@@ -101,8 +101,8 @@ Query data from a Supabase table
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Operation success status |
| `output` | object | Query operation results |
| `message` | string | Operation status message |
| `results` | array | Array of records returned from the query |
### `supabase_insert`
@@ -121,8 +121,8 @@ Insert data into a Supabase table
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Operation success status |
| `output` | object | Insert operation results |
| `message` | string | Operation status message |
| `results` | array | Array of inserted records |
### `supabase_get_row`
@@ -141,8 +141,8 @@ Get a single row from a Supabase table based on filter criteria
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Operation success status |
| `output` | object | Get row operation results |
| `message` | string | Operation status message |
| `results` | array | Array containing the row data if found, empty array if not found |
### `supabase_update`
@@ -162,8 +162,8 @@ Update rows in a Supabase table based on filter criteria
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Operation success status |
| `output` | object | Update operation results |
| `message` | string | Operation status message |
| `results` | array | Array of updated records |
### `supabase_delete`
@@ -182,8 +182,28 @@ Delete rows from a Supabase table based on filter criteria
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Operation success status |
| `output` | object | Delete operation results |
| `message` | string | Operation status message |
| `results` | array | Array of deleted records |
### `supabase_upsert`
Insert or update data in a Supabase table (upsert operation)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `projectId` | string | Yes | Your Supabase project ID \(e.g., jdrkgepadsdopsntdlom\) |
| `table` | string | Yes | The name of the Supabase table to upsert data into |
| `data` | any | Yes | The data to upsert \(insert or update\) |
| `apiKey` | string | Yes | Your Supabase service role secret key |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `message` | string | Operation status message |
| `results` | array | Array of upserted records |

View File

@@ -9,7 +9,7 @@ export default function AuthLayout({ children }: { children: React.ReactNode })
const brand = useBrandConfig()
return (
<main className='relative flex min-h-screen flex-col bg-[#0C0C0C] font-geist-sans text-white'>
<main className='relative flex min-h-screen flex-col bg-[var(--brand-background-hex)] font-geist-sans text-white'>
{/* Background pattern */}
<GridPattern
x={-5}

View File

@@ -456,7 +456,7 @@ export default function LoginPage({
<Button
type='submit'
className='flex h-11 w-full items-center justify-center gap-2 bg-[#701ffc] font-medium text-base text-white shadow-[#701ffc]/20 shadow-lg transition-colors duration-200 hover:bg-[#802FFF]'
className='flex h-11 w-full items-center justify-center gap-2 bg-brand-primary font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-brand-primary-hover'
disabled={isLoading}
>
{isLoading ? 'Signing in...' : 'Sign In'}
@@ -468,7 +468,7 @@ export default function LoginPage({
<span className='text-neutral-400'>Don't have an account? </span>
<Link
href={isInviteFlow ? `/signup?invite_flow=true&callbackUrl=${callbackUrl}` : '/signup'}
className='font-medium text-[#9D54FF] underline-offset-4 transition hover:text-[#a66fff] hover:underline'
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
>
Sign up
</Link>
@@ -497,7 +497,7 @@ export default function LoginPage({
placeholder='Enter your email'
required
type='email'
className='border-neutral-700/80 bg-neutral-900 text-white placeholder:text-white/60 focus:border-[#802FFF]/70 focus:ring-[#802FFF]/20'
className='border-neutral-700/80 bg-neutral-900 text-white placeholder:text-white/60 focus:border-[var(--brand-primary-hover-hex)]/70 focus:ring-[var(--brand-primary-hover-hex)]/20'
/>
</div>
{resetStatus.type && (
@@ -512,7 +512,7 @@ export default function LoginPage({
<Button
type='button'
onClick={handleForgotPassword}
className='h-11 w-full bg-[#701ffc] font-medium text-base text-white shadow-[#701ffc]/20 shadow-lg transition-colors duration-200 hover:bg-[#802FFF]'
className='h-11 w-full bg-[var(--brand-primary-hex)] font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
disabled={isSubmittingReset}
>
{isSubmittingReset ? 'Sending...' : 'Send Reset Link'}

View File

@@ -488,7 +488,7 @@ function SignupFormContent({
<Button
type='submit'
className='flex h-11 w-full items-center justify-center gap-2 bg-[#701ffc] font-medium text-base text-white shadow-[#701ffc]/20 shadow-lg transition-colors duration-200 hover:bg-[#802FFF]'
className='flex h-11 w-full items-center justify-center gap-2 bg-[var(--brand-primary-hex)] font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
disabled={isLoading}
>
{isLoading ? 'Creating account...' : 'Create Account'}
@@ -500,7 +500,7 @@ function SignupFormContent({
<span className='text-neutral-400'>Already have an account? </span>
<Link
href={isInviteFlow ? `/login?invite_flow=true&callbackUrl=${redirectUrl}` : '/login'}
className='font-medium text-[#9D54FF] underline-offset-4 transition hover:text-[#a66fff] hover:underline'
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
>
Sign in
</Link>

View File

@@ -124,7 +124,7 @@ function VerificationForm({
<Button
onClick={verifyCode}
className='h-11 w-full bg-[#701ffc] font-medium text-base text-white shadow-[#701ffc]/20 shadow-lg transition-colors duration-200 hover:bg-[#802FFF]'
className='h-11 w-full bg-[var(--brand-primary-hex)] font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
disabled={!isOtpComplete || isLoading}
>
{isLoading ? 'Verifying...' : 'Verify Email'}
@@ -140,7 +140,7 @@ function VerificationForm({
</span>
) : (
<button
className='font-medium text-[#9D54FF] underline-offset-4 transition hover:text-[#a66fff] hover:underline'
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
onClick={handleResend}
disabled={isLoading || isResendDisabled}
>

View File

@@ -35,7 +35,7 @@ export const BlogCard = ({
}: BlogCardProps) => {
return (
<Link href={href}>
<div className='flex flex-col rounded-3xl border border-[#606060]/40 bg-[#101010] p-8 transition-all duration-500 hover:bg-[#202020]'>
<div className='flex flex-col rounded-3xl border border-[#606060]/40 bg-[#101010] p-8 transition-all duration-500 hover:bg-[var(--surface-elevated)]'>
{image ? (
<Image
src={image}

View File

@@ -245,7 +245,7 @@ export default function NavClient({
target='_blank'
rel='noopener noreferrer'
>
<Button className='h-[43px] bg-[#701ffc] px-6 py-2 font-geist-sans font-medium text-base text-neutral-100 transition-colors duration-200 hover:bg-[#802FFF]'>
<Button className='h-[43px] bg-[var(--brand-primary-hex)] px-6 py-2 font-geist-sans font-medium text-base text-neutral-100 transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'>
Contact
</Button>
</Link>
@@ -277,7 +277,7 @@ export default function NavClient({
>
<SheetContent
side='right'
className='flex h-full w-[280px] flex-col border-[#181818] border-l bg-[#0C0C0C] p-6 pt-6 text-white shadow-xl sm:w-[320px] [&>button]:hidden'
className='flex h-full w-[280px] flex-col border-[#181818] border-l bg-[var(--brand-background-hex)] p-6 pt-6 text-white shadow-xl sm:w-[320px] [&>button]:hidden'
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
>
@@ -311,7 +311,7 @@ export default function NavClient({
target='_blank'
rel='noopener noreferrer'
>
<Button className='w-full bg-[#701ffc] py-6 font-medium text-base text-white shadow-[#701ffc]/20 shadow-lg transition-colors duration-200 hover:bg-[#802FFF]'>
<Button className='w-full bg-[var(--brand-primary-hex)] py-6 font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'>
Contact
</Button>
</Link>

View File

@@ -62,7 +62,7 @@ function Hero() {
<Button
variant={'secondary'}
onClick={handleNavigate}
className='animate-fade-in items-center bg-[#701ffc] px-7 py-6 font-[420] font-geist-sans text-lg text-neutral-100 tracking-normal shadow-[#701ffc]/30 shadow-lg hover:bg-[#802FFF]'
className='animate-fade-in items-center bg-[var(--brand-primary-hex)] px-7 py-6 font-[420] font-geist-sans text-lg text-neutral-100 tracking-normal shadow-[var(--brand-primary-hex)]/30 shadow-lg hover:bg-[var(--brand-primary-hover-hex)]'
aria-label='Start using the platform'
>
<div className='text-[1.15rem]'>Start now</div>
@@ -104,7 +104,7 @@ function Hero() {
className='aspect-[5/3] h-auto md:aspect-auto'
>
<g filter='url(#filter0_b_0_1)'>
<ellipse cx='300' cy='240' rx='290' ry='220' fill='#0C0C0C' />
<ellipse cx='300' cy='240' rx='290' ry='220' fill='var(--brand-background-hex)' />
</g>
<defs>
<filter

View File

@@ -151,7 +151,7 @@ export default function ContributorsPage() {
)
return (
<main className='relative min-h-screen bg-[#0C0C0C] font-geist-sans text-white'>
<main className='relative min-h-screen bg-[var(--brand-background-hex)] font-geist-sans text-white'>
{/* Grid pattern background */}
<div className='absolute inset-0 bottom-[400px] z-0'>
<GridPattern
@@ -239,7 +239,7 @@ export default function ContributorsPage() {
<div className='mb-6 grid grid-cols-1 gap-3 sm:mb-8 sm:grid-cols-2 sm:gap-4 lg:grid-cols-5'>
<div className='rounded-lg border border-[#606060]/20 bg-neutral-800/30 p-3 text-center sm:rounded-xl sm:p-4'>
<div className='mb-1 flex items-center justify-center sm:mb-2'>
<Star className='h-4 w-4 text-[#701ffc] sm:h-5 sm:w-5' />
<Star className='h-4 w-4 text-[var(--brand-primary-hex)] sm:h-5 sm:w-5' />
</div>
<div className='font-bold text-lg text-white sm:text-xl'>{repoStats.stars}</div>
<div className='text-neutral-400 text-xs'>Stars</div>
@@ -247,7 +247,7 @@ export default function ContributorsPage() {
<div className='rounded-lg border border-[#606060]/20 bg-neutral-800/30 p-3 text-center sm:rounded-xl sm:p-4'>
<div className='mb-1 flex items-center justify-center sm:mb-2'>
<GitFork className='h-4 w-4 text-[#701ffc] sm:h-5 sm:w-5' />
<GitFork className='h-4 w-4 text-[var(--brand-primary-hex)] sm:h-5 sm:w-5' />
</div>
<div className='font-bold text-lg text-white sm:text-xl'>{repoStats.forks}</div>
<div className='text-neutral-400 text-xs'>Forks</div>
@@ -255,7 +255,7 @@ export default function ContributorsPage() {
<div className='rounded-lg border border-[#606060]/20 bg-neutral-800/30 p-3 text-center sm:rounded-xl sm:p-4'>
<div className='mb-1 flex items-center justify-center sm:mb-2'>
<GitGraph className='h-4 w-4 text-[#701ffc] sm:h-5 sm:w-5' />
<GitGraph className='h-4 w-4 text-[var(--brand-primary-hex)] sm:h-5 sm:w-5' />
</div>
<div className='font-bold text-lg text-white sm:text-xl'>
{filteredContributors?.length || 0}
@@ -265,7 +265,7 @@ export default function ContributorsPage() {
<div className='rounded-lg border border-[#606060]/20 bg-neutral-800/30 p-3 text-center sm:rounded-xl sm:p-4'>
<div className='mb-1 flex items-center justify-center sm:mb-2'>
<MessageCircle className='h-4 w-4 text-[#701ffc] sm:h-5 sm:w-5' />
<MessageCircle className='h-4 w-4 text-[var(--brand-primary-hex)] sm:h-5 sm:w-5' />
</div>
<div className='font-bold text-lg text-white sm:text-xl'>
{repoStats.openIssues}
@@ -275,7 +275,7 @@ export default function ContributorsPage() {
<div className='rounded-lg border border-[#606060]/20 bg-neutral-800/30 p-3 text-center sm:rounded-xl sm:p-4'>
<div className='mb-1 flex items-center justify-center sm:mb-2'>
<GitPullRequest className='h-4 w-4 text-[#701ffc] sm:h-5 sm:w-5' />
<GitPullRequest className='h-4 w-4 text-[var(--brand-primary-hex)] sm:h-5 sm:w-5' />
</div>
<div className='font-bold text-lg text-white sm:text-xl'>{repoStats.openPRs}</div>
<div className='text-neutral-400 text-xs'>Pull Requests</div>
@@ -291,8 +291,8 @@ export default function ContributorsPage() {
<AreaChart data={timelineData} className='-mx-2 sm:-mx-5 mt-1 sm:mt-2'>
<defs>
<linearGradient id='commits' x1='0' y1='0' x2='0' y2='1'>
<stop offset='5%' stopColor='#701ffc' stopOpacity={0.3} />
<stop offset='95%' stopColor='#701ffc' stopOpacity={0} />
<stop offset='5%' stopColor='var(--brand-primary-hex)' stopOpacity={0.3} />
<stop offset='95%' stopColor='var(--brand-primary-hex)' stopOpacity={0} />
</linearGradient>
</defs>
<XAxis
@@ -320,7 +320,7 @@ export default function ContributorsPage() {
<div className='rounded-lg border border-[#606060]/30 bg-[#0f0f0f] p-2 shadow-lg backdrop-blur-sm sm:p-3'>
<div className='grid gap-1 sm:gap-2'>
<div className='flex items-center gap-1 sm:gap-2'>
<GitGraph className='h-3 w-3 text-[#701ffc] sm:h-4 sm:w-4' />
<GitGraph className='h-3 w-3 text-[var(--brand-primary-hex)] sm:h-4 sm:w-4' />
<span className='text-neutral-400 text-xs sm:text-sm'>
Commits:
</span>
@@ -338,7 +338,7 @@ export default function ContributorsPage() {
<Area
type='monotone'
dataKey='commits'
stroke='#701ffc'
stroke='var(--brand-primary-hex)'
strokeWidth={2}
fill='url(#commits)'
/>
@@ -393,7 +393,7 @@ export default function ContributorsPage() {
animate={{ opacity: 1, y: 0 }}
style={{ animationDelay: `${index * 50}ms` }}
>
<Avatar className='h-12 w-12 ring-2 ring-[#606060]/30 transition-transform group-hover:scale-105 group-hover:ring-[#701ffc]/60 sm:h-16 sm:w-16'>
<Avatar className='h-12 w-12 ring-2 ring-[#606060]/30 transition-transform group-hover:scale-105 group-hover:ring-[var(--brand-primary-hex)]/60 sm:h-16 sm:w-16'>
<AvatarImage
src={contributor.avatar_url}
alt={contributor.login}
@@ -405,13 +405,13 @@ export default function ContributorsPage() {
</Avatar>
<div className='mt-2 text-center sm:mt-3'>
<span className='block font-medium text-white text-xs transition-colors group-hover:text-[#701ffc] sm:text-sm'>
<span className='block font-medium text-white text-xs transition-colors group-hover:text-[var(--brand-primary-hex)] sm:text-sm'>
{contributor.login.length > 12
? `${contributor.login.slice(0, 12)}...`
: contributor.login}
</span>
<div className='mt-1 flex items-center justify-center gap-1 sm:mt-2'>
<GitGraph className='h-2 w-2 text-neutral-400 transition-colors group-hover:text-[#701ffc] sm:h-3 sm:w-3' />
<GitGraph className='h-2 w-2 text-neutral-400 transition-colors group-hover:text-[var(--brand-primary-hex)] sm:h-3 sm:w-3' />
<span className='font-medium text-neutral-300 text-xs transition-colors group-hover:text-white sm:text-sm'>
{contributor.contributions}
</span>
@@ -508,7 +508,7 @@ export default function ContributorsPage() {
/>
<Bar
dataKey='contributions'
className='fill-[#701ffc]'
className='fill-[var(--brand-primary-hex)]'
radius={[4, 4, 0, 0]}
/>
</BarChart>
@@ -532,7 +532,7 @@ export default function ContributorsPage() {
>
<div className='relative p-6 sm:p-8 md:p-12 lg:p-16'>
<div className='text-center'>
<div className='mb-4 inline-flex items-center rounded-full border border-[#701ffc]/20 bg-[#701ffc]/10 px-3 py-1 font-medium text-[#701ffc] text-xs sm:mb-6 sm:px-4 sm:py-2 sm:text-sm'>
<div className='mb-4 inline-flex items-center rounded-full border border-[var(--brand-primary-hex)]/20 bg-[var(--brand-primary-hex)]/10 px-3 py-1 font-medium text-[var(--brand-primary-hex)] text-xs sm:mb-6 sm:px-4 sm:py-2 sm:text-sm'>
<Github className='mr-1 h-3 w-3 sm:mr-2 sm:h-4 sm:w-4' />
Apache-2.0 Licensed
</div>
@@ -550,7 +550,7 @@ export default function ContributorsPage() {
<Button
asChild
size='lg'
className='bg-[#701ffc] text-white transition-colors duration-500 hover:bg-[#802FFF]'
className='bg-[var(--brand-primary-hex)] text-white transition-colors duration-500 hover:bg-[var(--brand-primary-hover-hex)]'
>
<a
href='https://github.com/simstudioai/sim/blob/main/.github/CONTRIBUTING.md'

View File

@@ -12,7 +12,7 @@ export default function Landing() {
}
return (
<main className='relative min-h-screen bg-[#0C0C0C] font-geist-sans'>
<main className='relative min-h-screen bg-[var(--brand-background-hex)] font-geist-sans'>
<NavWrapper onOpenTypeformLink={handleOpenTypeformLink} />
<Hero />

View File

@@ -11,7 +11,7 @@ export default function PrivacyPolicy() {
}
return (
<main className='relative min-h-screen overflow-hidden bg-[#0C0C0C] text-white'>
<main className='relative min-h-screen overflow-hidden bg-[var(--brand-background-hex)] text-white'>
{/* Grid pattern background - only covers content area */}
<div className='absolute inset-0 bottom-[400px] z-0 overflow-hidden'>
<GridPattern
@@ -42,7 +42,7 @@ export default function PrivacyPolicy() {
className='h-full w-full'
>
<g filter='url(#filter0_b_privacy)'>
<rect width='600' height='1600' rx='0' fill='#0C0C0C' />
<rect width='600' height='1600' rx='0' fill='var(--brand-background-hex)' />
</g>
<defs>
<filter
@@ -391,7 +391,7 @@ export default function PrivacyPolicy() {
Privacy & Terms web page:{' '}
<Link
href='https://policies.google.com/privacy?hl=en'
className='text-[#B5A1D4] hover:text-[#701ffc]'
className='text-[#B5A1D4] hover:text-[var(--brand-primary-hex)]'
target='_blank'
rel='noopener noreferrer'
>
@@ -569,7 +569,7 @@ export default function PrivacyPolicy() {
Please note that we may ask you to verify your identity before responding to such
requests.
</p>
<p className='mb-4 border-[#701ffc] border-l-4 bg-[#701ffc]/10 p-3'>
<p className='mb-4 border-[var(--brand-primary-hex)] border-l-4 bg-[var(--brand-primary-hex)]/10 p-3'>
You have the right to complain to a Data Protection Authority about our collection
and use of your Personal Information. For more information, please contact your
local data protection authority in the European Economic Area (EEA).
@@ -661,7 +661,7 @@ export default function PrivacyPolicy() {
policy (if any). Before beginning your inquiry, email us at{' '}
<Link
href='mailto:security@sim.ai'
className='text-[#B5A1D4] hover:text-[#701ffc]'
className='text-[#B5A1D4] hover:text-[var(--brand-primary-hex)]'
>
security@sim.ai
</Link>{' '}
@@ -686,7 +686,7 @@ export default function PrivacyPolicy() {
To report any security flaws, send an email to{' '}
<Link
href='mailto:security@sim.ai'
className='text-[#B5A1D4] hover:text-[#701ffc]'
className='text-[#B5A1D4] hover:text-[var(--brand-primary-hex)]'
>
security@sim.ai
</Link>
@@ -726,7 +726,7 @@ export default function PrivacyPolicy() {
If you have any questions about this Privacy Policy, please contact us at:{' '}
<Link
href='mailto:privacy@sim.ai'
className='text-[#B5A1D4] hover:text-[#701ffc]'
className='text-[#B5A1D4] hover:text-[var(--brand-primary-hex)]'
>
privacy@sim.ai
</Link>

View File

@@ -11,7 +11,7 @@ export default function TermsOfService() {
}
return (
<main className='relative min-h-screen overflow-hidden bg-[#0C0C0C] text-white'>
<main className='relative min-h-screen overflow-hidden bg-[var(--brand-background-hex)] text-white'>
{/* Grid pattern background */}
<div className='absolute inset-0 bottom-[400px] z-0 overflow-hidden'>
<GridPattern
@@ -42,7 +42,7 @@ export default function TermsOfService() {
className='h-full w-full'
>
<g filter='url(#filter0_b_terms)'>
<rect width='600' height='1600' rx='0' fill='#0C0C0C' />
<rect width='600' height='1600' rx='0' fill='var(--brand-background-hex)' />
</g>
<defs>
<filter
@@ -268,7 +268,7 @@ export default function TermsOfService() {
Arbitration Agreement. The arbitration will be conducted by JAMS, an established
alternative dispute resolution provider.
</p>
<p className='mb-4 border-[#701ffc] border-l-4 bg-[#701ffc]/10 p-3'>
<p className='mb-4 border-[var(--brand-primary-hex)] border-l-4 bg-[var(--brand-primary-hex)]/10 p-3'>
YOU AND COMPANY AGREE THAT EACH OF US MAY BRING CLAIMS AGAINST THE OTHER ONLY ON
AN INDIVIDUAL BASIS AND NOT ON A CLASS, REPRESENTATIVE, OR COLLECTIVE BASIS. ONLY
INDIVIDUAL RELIEF IS AVAILABLE, AND DISPUTES OF MORE THAN ONE CUSTOMER OR USER
@@ -277,7 +277,10 @@ export default function TermsOfService() {
<p className='mb-4'>
You have the right to opt out of the provisions of this Arbitration Agreement by
sending a timely written notice of your decision to opt out to:{' '}
<Link href='mailto:legal@sim.ai' className='text-[#B5A1D4] hover:text-[#701ffc]'>
<Link
href='mailto:legal@sim.ai'
className='text-[#B5A1D4] hover:text-[var(--brand-primary-hex)]'
>
legal@sim.ai{' '}
</Link>
within 30 days after first becoming subject to this Arbitration Agreement.
@@ -330,7 +333,7 @@ export default function TermsOfService() {
Our Copyright Agent can be reached at:{' '}
<Link
href='mailto:copyright@sim.ai'
className='text-[#B5A1D4] hover:text-[#701ffc]'
className='text-[#B5A1D4] hover:text-[var(--brand-primary-hex)]'
>
copyright@sim.ai
</Link>
@@ -341,7 +344,10 @@ export default function TermsOfService() {
<h2 className='mb-4 font-semibold text-2xl text-white'>12. Contact Us</h2>
<p>
If you have any questions about these Terms, please contact us at:{' '}
<Link href='mailto:legal@sim.ai' className='text-[#B5A1D4] hover:text-[#701ffc]'>
<Link
href='mailto:legal@sim.ai'
className='text-[#B5A1D4] hover:text-[var(--brand-primary-hex)]'
>
legal@sim.ai
</Link>
</p>

View File

@@ -125,7 +125,7 @@ describe('OAuth Credentials API Route', () => {
})
expect(data.credentials[1]).toMatchObject({
id: 'credential-2',
provider: 'google-email',
provider: 'google-default',
isDefault: true,
})
})
@@ -158,7 +158,7 @@ describe('OAuth Credentials API Route', () => {
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('Provider is required')
expect(data.error).toBe('Provider or credentialId is required')
expect(mockLogger.warn).toHaveBeenCalled()
})

View File

@@ -1,12 +1,13 @@
import { and, eq } from 'drizzle-orm'
import { jwtDecode } from 'jwt-decode'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createLogger } from '@/lib/logs/console/logger'
import type { OAuthService } from '@/lib/oauth/oauth'
import { parseProvider } from '@/lib/oauth/oauth'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { db } from '@/db'
import { account, user } from '@/db/schema'
import { account, user, workflow } from '@/db/schema'
export const dynamic = 'force-dynamic'
@@ -25,36 +26,96 @@ export async function GET(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
// Get the session
const session = await getSession()
// Get query params
const { searchParams } = new URL(request.url)
const providerParam = searchParams.get('provider') as OAuthService | null
const workflowId = searchParams.get('workflowId')
const credentialId = searchParams.get('credentialId')
// Check if the user is authenticated
if (!session?.user?.id) {
// Authenticate requester (supports session, API key, internal JWT)
const authResult = await checkHybridAuth(request)
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthenticated credentials request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
const requesterUserId = authResult.userId
// Get the provider from the query params
const { searchParams } = new URL(request.url)
const provider = searchParams.get('provider') as OAuthService | null
// Resolve effective user id: workflow owner if workflowId provided (with access check); else requester
let effectiveUserId: string
if (workflowId) {
// Load workflow owner and workspace for access control
const rows = await db
.select({ userId: workflow.userId, workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!provider) {
logger.warn(`[${requestId}] Missing provider parameter`)
return NextResponse.json({ error: 'Provider is required' }, { status: 400 })
if (!rows.length) {
logger.warn(`[${requestId}] Workflow not found for credentials request`, { workflowId })
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
const wf = rows[0]
if (requesterUserId !== wf.userId) {
if (!wf.workspaceId) {
logger.warn(
`[${requestId}] Forbidden - workflow has no workspace and requester is not owner`,
{
requesterUserId,
}
)
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const perm = await getUserEntityPermissions(requesterUserId, 'workspace', wf.workspaceId)
if (perm === null) {
logger.warn(`[${requestId}] Forbidden credentials request - no workspace access`, {
requesterUserId,
workspaceId: wf.workspaceId,
})
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
effectiveUserId = wf.userId
} else {
effectiveUserId = requesterUserId
}
// Parse the provider to get base provider and feature type
const { baseProvider } = parseProvider(provider)
if (!providerParam && !credentialId) {
logger.warn(`[${requestId}] Missing provider parameter`)
return NextResponse.json({ error: 'Provider or credentialId is required' }, { status: 400 })
}
// Get all accounts for this user and provider
const accounts = await db
.select()
.from(account)
.where(and(eq(account.userId, session.user.id), eq(account.providerId, provider)))
// Parse the provider to get base provider and feature type (if provider is present)
const { baseProvider } = parseProvider(providerParam || 'google-default')
let accountsData
if (credentialId) {
// Foreign-aware lookup for a specific credential by id
// If workflowId is provided and requester has access (checked above), allow fetching by id only
if (workflowId) {
accountsData = await db.select().from(account).where(eq(account.id, credentialId))
} else {
// Fallback: constrain to requester's own credentials when not in a workflow context
accountsData = await db
.select()
.from(account)
.where(and(eq(account.userId, effectiveUserId), eq(account.id, credentialId)))
}
} else {
// Fetch all credentials for provider and effective user
accountsData = await db
.select()
.from(account)
.where(and(eq(account.userId, effectiveUserId), eq(account.providerId, providerParam!)))
}
// Transform accounts into credentials
const credentials = await Promise.all(
accounts.map(async (acc) => {
accountsData.map(async (acc) => {
// Extract the feature type from providerId (e.g., 'google-default' -> 'default')
const [_, featureType = 'default'] = acc.providerId.split('-')
@@ -109,7 +170,7 @@ export async function GET(request: NextRequest) {
return {
id: acc.id,
name: displayName,
provider,
provider: acc.providerId,
lastUsed: acc.updatedAt.toISOString(),
isDefault: featureType === 'default',
}

View File

@@ -10,6 +10,8 @@ describe('OAuth Token API Routes', () => {
const mockGetUserId = vi.fn()
const mockGetCredential = vi.fn()
const mockRefreshTokenIfNeeded = vi.fn()
const mockAuthorizeCredentialUse = vi.fn()
const mockCheckHybridAuth = vi.fn()
const mockLogger = {
info: vi.fn(),
@@ -37,6 +39,14 @@ describe('OAuth Token API Routes', () => {
vi.doMock('@/lib/logs/console/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
vi.doMock('@/lib/auth/credential-access', () => ({
authorizeCredentialUse: mockAuthorizeCredentialUse,
}))
vi.doMock('@/lib/auth/hybrid', () => ({
checkHybridAuth: mockCheckHybridAuth,
}))
})
afterEach(() => {
@@ -48,7 +58,12 @@ describe('OAuth Token API Routes', () => {
*/
describe('POST handler', () => {
it('should return access token successfully', async () => {
mockGetUserId.mockResolvedValueOnce('test-user-id')
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'owner-user-id',
})
mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
@@ -78,13 +93,18 @@ describe('OAuth Token API Routes', () => {
expect(data).toHaveProperty('accessToken', 'fresh-token')
// Verify mocks were called correctly
expect(mockGetUserId).toHaveBeenCalledWith(mockRequestId, undefined)
expect(mockGetCredential).toHaveBeenCalledWith(mockRequestId, 'credential-id', 'test-user-id')
expect(mockAuthorizeCredentialUse).toHaveBeenCalled()
expect(mockGetCredential).toHaveBeenCalled()
expect(mockRefreshTokenIfNeeded).toHaveBeenCalled()
})
it('should handle workflowId for server-side authentication', async () => {
mockGetUserId.mockResolvedValueOnce('workflow-owner-id')
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'internal_jwt',
requesterUserId: 'workflow-owner-id',
credentialOwnerUserId: 'workflow-owner-id',
})
mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
@@ -110,12 +130,8 @@ describe('OAuth Token API Routes', () => {
expect(response.status).toBe(200)
expect(data).toHaveProperty('accessToken', 'fresh-token')
expect(mockGetUserId).toHaveBeenCalledWith(mockRequestId, 'workflow-id')
expect(mockGetCredential).toHaveBeenCalledWith(
mockRequestId,
'credential-id',
'workflow-owner-id'
)
expect(mockAuthorizeCredentialUse).toHaveBeenCalled()
expect(mockGetCredential).toHaveBeenCalled()
})
it('should handle missing credentialId', async () => {
@@ -132,7 +148,10 @@ describe('OAuth Token API Routes', () => {
})
it('should handle authentication failure', async () => {
mockGetUserId.mockResolvedValueOnce(undefined)
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: false,
error: 'Authentication required',
})
const req = createMockRequest('POST', {
credentialId: 'credential-id',
@@ -143,12 +162,12 @@ describe('OAuth Token API Routes', () => {
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'User not authenticated')
expect(response.status).toBe(403)
expect(data).toHaveProperty('error')
})
it('should handle workflow not found', async () => {
mockGetUserId.mockResolvedValueOnce(undefined)
mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: false, error: 'Workflow not found' })
const req = createMockRequest('POST', {
credentialId: 'credential-id',
@@ -160,12 +179,16 @@ describe('OAuth Token API Routes', () => {
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(404)
expect(data).toHaveProperty('error', 'Workflow not found')
expect(response.status).toBe(403)
})
it('should handle credential not found', async () => {
mockGetUserId.mockResolvedValueOnce('test-user-id')
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'owner-user-id',
})
mockGetCredential.mockResolvedValueOnce(undefined)
const req = createMockRequest('POST', {
@@ -177,12 +200,17 @@ describe('OAuth Token API Routes', () => {
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(404)
expect(data).toHaveProperty('error', 'Credential not found')
expect(response.status).toBe(401)
expect(data).toHaveProperty('error')
})
it('should handle token refresh failure', async () => {
mockGetUserId.mockResolvedValueOnce('test-user-id')
mockAuthorizeCredentialUse.mockResolvedValueOnce({
ok: true,
authType: 'session',
requesterUserId: 'test-user-id',
credentialOwnerUserId: 'owner-user-id',
})
mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
@@ -211,7 +239,11 @@ describe('OAuth Token API Routes', () => {
*/
describe('GET handler', () => {
it('should return access token successfully', async () => {
mockGetUserId.mockResolvedValueOnce('test-user-id')
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
userId: 'test-user-id',
})
mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
@@ -236,7 +268,7 @@ describe('OAuth Token API Routes', () => {
expect(response.status).toBe(200)
expect(data).toHaveProperty('accessToken', 'fresh-token')
expect(mockGetUserId).toHaveBeenCalledWith(mockRequestId)
expect(mockCheckHybridAuth).toHaveBeenCalled()
expect(mockGetCredential).toHaveBeenCalledWith(mockRequestId, 'credential-id', 'test-user-id')
expect(mockRefreshTokenIfNeeded).toHaveBeenCalled()
})
@@ -255,7 +287,10 @@ describe('OAuth Token API Routes', () => {
})
it('should handle authentication failure', async () => {
mockGetUserId.mockResolvedValueOnce(undefined)
mockCheckHybridAuth.mockResolvedValueOnce({
success: false,
error: 'Authentication required',
})
const req = new Request(
'http://localhost:3000/api/auth/oauth/token?credentialId=credential-id'
@@ -267,11 +302,15 @@ describe('OAuth Token API Routes', () => {
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'User not authenticated')
expect(data).toHaveProperty('error')
})
it('should handle credential not found', async () => {
mockGetUserId.mockResolvedValueOnce('test-user-id')
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
userId: 'test-user-id',
})
mockGetCredential.mockResolvedValueOnce(undefined)
const req = new Request(
@@ -284,11 +323,15 @@ describe('OAuth Token API Routes', () => {
const data = await response.json()
expect(response.status).toBe(404)
expect(data).toHaveProperty('error', 'Credential not found')
expect(data).toHaveProperty('error')
})
it('should handle missing access token', async () => {
mockGetUserId.mockResolvedValueOnce('test-user-id')
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
userId: 'test-user-id',
})
mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: null,
@@ -306,12 +349,15 @@ describe('OAuth Token API Routes', () => {
const data = await response.json()
expect(response.status).toBe(400)
expect(data).toHaveProperty('error', 'No access token available')
expect(mockLogger.warn).toHaveBeenCalled()
expect(data).toHaveProperty('error')
})
it('should handle token refresh failure', async () => {
mockGetUserId.mockResolvedValueOnce('test-user-id')
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
userId: 'test-user-id',
})
mockGetCredential.mockResolvedValueOnce({
id: 'credential-id',
accessToken: 'test-token',
@@ -331,7 +377,7 @@ describe('OAuth Token API Routes', () => {
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'Failed to refresh access token')
expect(data).toHaveProperty('error')
})
})
})

View File

@@ -1,6 +1,8 @@
import { type NextRequest, NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createLogger } from '@/lib/logs/console/logger'
import { getCredential, getUserId, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getCredential, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
@@ -26,23 +28,18 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
// Determine the user ID based on the context
const userId = await getUserId(requestId, workflowId)
if (!userId) {
return NextResponse.json(
{ error: workflowId ? 'Workflow not found' : 'User not authenticated' },
{ status: workflowId ? 404 : 401 }
)
// We already have workflowId from the parsed body; avoid forcing hybrid auth to re-read it
const authz = await authorizeCredentialUse(request, {
credentialId,
workflowId,
requireWorkflowIdForInternal: false,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
// Get the credential from the database
const credential = await getCredential(requestId, credentialId, userId)
if (!credential) {
logger.error(`[${requestId}] Credential not found: ${credentialId}`)
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
// Fetch the credential as the owner to enforce ownership scoping
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
try {
// Refresh the token if needed
@@ -75,14 +72,13 @@ export async function GET(request: NextRequest) {
}
// For GET requests, we only support session-based authentication
const userId = await getUserId(requestId)
if (!userId) {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || auth.authType !== 'session' || !auth.userId) {
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
// Get the credential from the database
const credential = await getCredential(requestId, credentialId, userId)
const credential = await getCredential(requestId, credentialId, auth.userId)
if (!credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })

View File

@@ -2,8 +2,8 @@ import crypto from 'crypto'
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { env } from '@/lib/env'
import { isProd } from '@/lib/environment'
import { checkInternalApiKey } from '@/lib/copilot/utils'
import { isBillingEnabled } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { userStats } from '@/db/schema'
@@ -11,34 +11,14 @@ import { calculateCost } from '@/providers/utils'
const logger = createLogger('billing-update-cost')
// Schema for the request body
const UpdateCostSchema = z.object({
userId: z.string().min(1, 'User ID is required'),
input: z.number().min(0, 'Input tokens must be a non-negative number'),
output: z.number().min(0, 'Output tokens must be a non-negative number'),
model: z.string().min(1, 'Model is required'),
multiplier: z.number().min(0),
})
// Authentication function (reused from copilot/methods route)
function checkInternalApiKey(req: NextRequest) {
const apiKey = req.headers.get('x-api-key')
const expectedApiKey = env.INTERNAL_API_SECRET
if (!expectedApiKey) {
return { success: false, error: 'Internal API key not configured' }
}
if (!apiKey) {
return { success: false, error: 'API key required' }
}
if (apiKey !== expectedApiKey) {
return { success: false, error: 'Invalid API key' }
}
return { success: true }
}
/**
* POST /api/billing/update-cost
* Update user cost based on token usage with internal API key auth
@@ -50,6 +30,19 @@ export async function POST(req: NextRequest) {
try {
logger.info(`[${requestId}] Update cost request started`)
if (!isBillingEnabled) {
logger.debug(`[${requestId}] Billing is disabled, skipping cost update`)
return NextResponse.json({
success: true,
message: 'Billing disabled, cost update skipped',
data: {
billingEnabled: false,
processedAt: new Date().toISOString(),
requestId,
},
})
}
// Check authentication (internal API key)
const authResult = checkInternalApiKey(req)
if (!authResult.success) {
@@ -82,27 +75,27 @@ export async function POST(req: NextRequest) {
)
}
const { userId, input, output, model } = validation.data
const { userId, input, output, model, multiplier } = validation.data
logger.info(`[${requestId}] Processing cost update`, {
userId,
input,
output,
model,
multiplier,
})
const finalPromptTokens = input
const finalCompletionTokens = output
const totalTokens = input + output
// Calculate cost using COPILOT_COST_MULTIPLIER (only in production, like normal executions)
const copilotMultiplier = isProd ? env.COPILOT_COST_MULTIPLIER || 1 : 1
// Calculate cost using provided multiplier (required)
const costResult = calculateCost(
model,
finalPromptTokens,
finalCompletionTokens,
false,
copilotMultiplier
multiplier
)
logger.info(`[${requestId}] Cost calculation result`, {
@@ -111,7 +104,7 @@ export async function POST(req: NextRequest) {
promptTokens: finalPromptTokens,
completionTokens: finalCompletionTokens,
totalTokens: totalTokens,
copilotMultiplier,
multiplier,
costResult,
})
@@ -134,6 +127,10 @@ export async function POST(req: NextRequest) {
totalTokensUsed: totalTokens,
totalCost: costToStore.toString(),
currentPeriodCost: costToStore.toString(),
// Copilot usage tracking
totalCopilotCost: costToStore.toString(),
totalCopilotTokens: totalTokens,
totalCopilotCalls: 1,
lastActive: new Date(),
})
@@ -148,6 +145,10 @@ export async function POST(req: NextRequest) {
totalTokensUsed: sql`total_tokens_used + ${totalTokens}`,
totalCost: sql`total_cost + ${costToStore}`,
currentPeriodCost: sql`current_period_cost + ${costToStore}`,
// Copilot usage tracking increments
totalCopilotCost: sql`total_copilot_cost + ${costToStore}`,
totalCopilotTokens: sql`total_copilot_tokens + ${totalTokens}`,
totalCopilotCalls: sql`total_copilot_calls + 1`,
totalApiCalls: sql`total_api_calls`,
lastActive: new Date(),
}

View File

@@ -245,6 +245,11 @@ describe('Chat API Route', () => {
NODE_ENV: 'development',
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
},
isTruthy: (value: string | boolean | number | undefined) =>
typeof value === 'string'
? value.toLowerCase() === 'true' || value === '1'
: Boolean(value),
getEnv: (variable: string) => process.env[variable],
}))
const validData = {
@@ -287,6 +292,9 @@ describe('Chat API Route', () => {
NODE_ENV: 'development',
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
},
isTruthy: (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
getEnv: (variable: string) => process.env[variable],
}))
const validData = {

View File

@@ -14,8 +14,6 @@ import { chat } from '@/db/schema'
const logger = createLogger('ChatAPI')
export const dynamic = 'force-dynamic'
const chatSchema = z.object({
workflowId: z.string().min(1, 'Workflow ID is required'),
subdomain: z
@@ -150,7 +148,7 @@ export async function POST(request: NextRequest) {
// Merge customizations with the additional fields
const mergedCustomizations = {
...(customizations || {}),
primaryColor: customizations?.primaryColor || '#802FFF',
primaryColor: customizations?.primaryColor || 'var(--brand-primary-hover-hex)',
welcomeMessage: customizations?.welcomeMessage || 'Hi there! How can I help you today?',
}

View File

@@ -2,9 +2,6 @@ import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
export const dynamic = 'force-dynamic'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import { db } from '@/db'
import { chat } from '@/db/schema'

View File

@@ -0,0 +1,70 @@
import { createCipheriv, createHash, createHmac, randomBytes } from 'crypto'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { generateApiKey } from '@/lib/utils'
import { db } from '@/db'
import { copilotApiKeys } from '@/db/schema'
const logger = createLogger('CopilotApiKeysGenerate')
function deriveKey(keyString: string): Buffer {
return createHash('sha256').update(keyString, 'utf8').digest()
}
function encryptRandomIv(plaintext: string, keyString: string): string {
const key = deriveKey(keyString)
const iv = randomBytes(16)
const cipher = createCipheriv('aes-256-gcm', key, iv)
let encrypted = cipher.update(plaintext, 'utf8', 'hex')
encrypted += cipher.final('hex')
const authTag = cipher.getAuthTag().toString('hex')
return `${iv.toString('hex')}:${encrypted}:${authTag}`
}
function computeLookup(plaintext: string, keyString: string): string {
// Deterministic, constant-time comparable MAC: HMAC-SHA256(DB_KEY, plaintext)
return createHmac('sha256', Buffer.from(keyString, 'utf8'))
.update(plaintext, 'utf8')
.digest('hex')
}
export async function POST(req: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!env.AGENT_API_DB_ENCRYPTION_KEY) {
logger.error('AGENT_API_DB_ENCRYPTION_KEY is not set')
return NextResponse.json({ error: 'Server not configured' }, { status: 500 })
}
const userId = session.user.id
// Generate and prefix the key (strip the generic sim_ prefix from the random part)
const rawKey = generateApiKey().replace(/^sim_/, '')
const plaintextKey = `sk-sim-copilot-${rawKey}`
// Encrypt with random IV for confidentiality
const dbEncrypted = encryptRandomIv(plaintextKey, env.AGENT_API_DB_ENCRYPTION_KEY)
// Compute deterministic lookup value for O(1) search
const lookup = computeLookup(plaintextKey, env.AGENT_API_DB_ENCRYPTION_KEY)
const [inserted] = await db
.insert(copilotApiKeys)
.values({ userId, apiKeyEncrypted: dbEncrypted, apiKeyLookup: lookup })
.returning({ id: copilotApiKeys.id })
return NextResponse.json(
{ success: true, key: { id: inserted.id, apiKey: plaintextKey } },
{ status: 201 }
)
} catch (error) {
logger.error('Failed to generate copilot API key', { error })
return NextResponse.json({ error: 'Failed to generate copilot API key' }, { status: 500 })
}
}

View File

@@ -0,0 +1,85 @@
import { createDecipheriv, createHash } from 'crypto'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { copilotApiKeys } from '@/db/schema'
const logger = createLogger('CopilotApiKeys')
function deriveKey(keyString: string): Buffer {
return createHash('sha256').update(keyString, 'utf8').digest()
}
function decryptWithKey(encryptedValue: string, keyString: string): string {
const parts = encryptedValue.split(':')
if (parts.length !== 3) {
throw new Error('Invalid encrypted value format')
}
const [ivHex, encryptedHex, authTagHex] = parts
const key = deriveKey(keyString)
const iv = Buffer.from(ivHex, 'hex')
const decipher = createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'))
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return decrypted
}
export async function GET(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!env.AGENT_API_DB_ENCRYPTION_KEY) {
logger.error('AGENT_API_DB_ENCRYPTION_KEY is not set')
return NextResponse.json({ error: 'Server not configured' }, { status: 500 })
}
const userId = session.user.id
const rows = await db
.select({ id: copilotApiKeys.id, apiKeyEncrypted: copilotApiKeys.apiKeyEncrypted })
.from(copilotApiKeys)
.where(eq(copilotApiKeys.userId, userId))
const keys = rows.map((row) => ({
id: row.id,
apiKey: decryptWithKey(row.apiKeyEncrypted, env.AGENT_API_DB_ENCRYPTION_KEY as string),
}))
return NextResponse.json({ keys }, { status: 200 })
} catch (error) {
logger.error('Failed to get copilot API keys', { error })
return NextResponse.json({ error: 'Failed to get keys' }, { status: 500 })
}
}
export async function DELETE(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const url = new URL(request.url)
const id = url.searchParams.get('id')
if (!id) {
return NextResponse.json({ error: 'id is required' }, { status: 400 })
}
await db
.delete(copilotApiKeys)
.where(and(eq(copilotApiKeys.userId, userId), eq(copilotApiKeys.id, id)))
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error('Failed to delete copilot API key', { error })
return NextResponse.json({ error: 'Failed to delete key' }, { status: 500 })
}
}

View File

@@ -0,0 +1,79 @@
import { createHmac } from 'crypto'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { copilotApiKeys, userStats } from '@/db/schema'
const logger = createLogger('CopilotApiKeysValidate')
function computeLookup(plaintext: string, keyString: string): string {
// Deterministic MAC: HMAC-SHA256(DB_KEY, plaintext)
return createHmac('sha256', Buffer.from(keyString, 'utf8'))
.update(plaintext, 'utf8')
.digest('hex')
}
export async function POST(req: NextRequest) {
try {
if (!env.AGENT_API_DB_ENCRYPTION_KEY) {
logger.error('AGENT_API_DB_ENCRYPTION_KEY is not set')
return NextResponse.json({ error: 'Server not configured' }, { status: 500 })
}
const body = await req.json().catch(() => null)
const apiKey = typeof body?.apiKey === 'string' ? body.apiKey : undefined
if (!apiKey) {
return new NextResponse(null, { status: 401 })
}
const lookup = computeLookup(apiKey, env.AGENT_API_DB_ENCRYPTION_KEY)
// Find matching API key and its user
const rows = await db
.select({ id: copilotApiKeys.id, userId: copilotApiKeys.userId })
.from(copilotApiKeys)
.where(eq(copilotApiKeys.apiKeyLookup, lookup))
.limit(1)
if (rows.length === 0) {
return new NextResponse(null, { status: 401 })
}
const { userId } = rows[0]
// Check usage for the associated user
const usage = await db
.select({
currentPeriodCost: userStats.currentPeriodCost,
totalCost: userStats.totalCost,
currentUsageLimit: userStats.currentUsageLimit,
})
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
if (usage.length > 0) {
const currentUsage = Number.parseFloat(
(usage[0].currentPeriodCost?.toString() as string) ||
(usage[0].totalCost as unknown as string) ||
'0'
)
const limit = Number.parseFloat((usage[0].currentUsageLimit as unknown as string) || '0')
if (!Number.isNaN(limit) && limit > 0 && currentUsage >= limit) {
// Usage exceeded
logger.info('[API VALIDATION] Usage exceeded', { userId, currentUsage, limit })
return new NextResponse(null, { status: 402 })
}
}
// Valid and within usage limits
return new NextResponse(null, { status: 200 })
} catch (error) {
logger.error('Error validating copilot API key', { error })
return NextResponse.json({ error: 'Failed to validate key' }, { status: 500 })
}
}

View File

@@ -104,7 +104,8 @@ describe('Copilot Chat API Route', () => {
vi.doMock('@/lib/env', () => ({
env: {
SIM_AGENT_API_URL: 'http://localhost:8000',
SIM_AGENT_API_KEY: 'test-sim-agent-key',
COPILOT_API_KEY: 'test-sim-agent-key',
BETTER_AUTH_URL: 'http://localhost:3000',
},
}))
@@ -223,6 +224,9 @@ describe('Copilot Chat API Route', () => {
stream: true,
streamToolCalls: true,
mode: 'agent',
provider: 'openai',
depth: 0,
origin: 'http://localhost:3000',
}),
})
)
@@ -284,6 +288,9 @@ describe('Copilot Chat API Route', () => {
stream: true,
streamToolCalls: true,
mode: 'agent',
provider: 'openai',
depth: 0,
origin: 'http://localhost:3000',
}),
})
)
@@ -337,6 +344,9 @@ describe('Copilot Chat API Route', () => {
stream: true,
streamToolCalls: true,
mode: 'agent',
provider: 'openai',
depth: 0,
origin: 'http://localhost:3000',
}),
})
)
@@ -430,6 +440,9 @@ describe('Copilot Chat API Route', () => {
stream: true,
streamToolCalls: true,
mode: 'ask',
provider: 'openai',
depth: 0,
origin: 'http://localhost:3000',
}),
})
)

View File

@@ -1,3 +1,4 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto'
import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
@@ -13,6 +14,7 @@ import { getCopilotModel } from '@/lib/copilot/config'
import { TITLE_GENERATION_SYSTEM_PROMPT, TITLE_GENERATION_USER_PROMPT } from '@/lib/copilot/prompts'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/sim-agent'
import { downloadFile } from '@/lib/uploads'
import { downloadFromS3WithConfig } from '@/lib/uploads/s3/s3-client'
import { S3_COPILOT_CONFIG, USE_S3_STORAGE } from '@/lib/uploads/setup'
@@ -23,6 +25,46 @@ import { createAnthropicFileContent, isSupportedFileType } from './file-utils'
const logger = createLogger('CopilotChatAPI')
// Sim Agent API configuration
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT
function getRequestOrigin(_req: NextRequest): string {
try {
// Strictly use configured Better Auth URL
return env.BETTER_AUTH_URL || ''
} catch (_) {
return ''
}
}
function deriveKey(keyString: string): Buffer {
return createHash('sha256').update(keyString, 'utf8').digest()
}
function decryptWithKey(encryptedValue: string, keyString: string): string {
const [ivHex, encryptedHex, authTagHex] = encryptedValue.split(':')
if (!ivHex || !encryptedHex || !authTagHex) {
throw new Error('Invalid encrypted format')
}
const key = deriveKey(keyString)
const iv = Buffer.from(ivHex, 'hex')
const decipher = createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'))
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return decrypted
}
function encryptWithKey(plaintext: string, keyString: string): string {
const key = deriveKey(keyString)
const iv = randomBytes(16)
const cipher = createCipheriv('aes-256-gcm', key, iv)
let encrypted = cipher.update(plaintext, 'utf8', 'hex')
encrypted += cipher.final('hex')
const authTag = cipher.getAuthTag().toString('hex')
return `${iv.toString('hex')}:${encrypted}:${authTag}`
}
// Schema for file attachments
const FileAttachmentSchema = z.object({
id: z.string(),
@@ -39,16 +81,16 @@ const ChatMessageSchema = z.object({
chatId: z.string().optional(),
workflowId: z.string().min(1, 'Workflow ID is required'),
mode: z.enum(['ask', 'agent']).optional().default('agent'),
depth: z.number().int().min(-2).max(3).optional().default(0),
prefetch: z.boolean().optional(),
createNewChat: z.boolean().optional().default(false),
stream: z.boolean().optional().default(true),
implicitFeedback: z.string().optional(),
fileAttachments: z.array(FileAttachmentSchema).optional(),
provider: z.string().optional().default('openai'),
conversationId: z.string().optional(),
})
// Sim Agent API configuration
const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || 'http://localhost:8000'
const SIM_AGENT_API_KEY = env.SIM_AGENT_API_KEY
/**
* Generate a chat title using LLM
*/
@@ -156,12 +198,37 @@ export async function POST(req: NextRequest) {
chatId,
workflowId,
mode,
depth,
prefetch,
createNewChat,
stream,
implicitFeedback,
fileAttachments,
provider,
conversationId,
} = ChatMessageSchema.parse(body)
// Derive request origin for downstream service
const requestOrigin = getRequestOrigin(req)
if (!requestOrigin) {
logger.error(`[${tracker.requestId}] Missing required configuration: BETTER_AUTH_URL`)
return createInternalServerErrorResponse('Missing required configuration: BETTER_AUTH_URL')
}
// Consolidation mapping: map negative depths to base depth with prefetch=true
let effectiveDepth: number | undefined = typeof depth === 'number' ? depth : undefined
let effectivePrefetch: boolean | undefined = prefetch
if (typeof effectiveDepth === 'number') {
if (effectiveDepth === -2) {
effectiveDepth = 1
effectivePrefetch = true
} else if (effectiveDepth === -1) {
effectiveDepth = 0
effectivePrefetch = true
}
}
logger.info(`[${tracker.requestId}] Processing copilot chat request`, {
userId: authenticatedUserId,
workflowId,
@@ -171,6 +238,11 @@ export async function POST(req: NextRequest) {
createNewChat,
messageLength: message.length,
hasImplicitFeedback: !!implicitFeedback,
provider: provider || 'openai',
hasConversationId: !!conversationId,
depth,
prefetch,
origin: requestOrigin,
})
// Handle chat context
@@ -252,7 +324,7 @@ export async function POST(req: NextRequest) {
}
// Build messages array for sim agent with conversation history
const messages = []
const messages: any[] = []
// Add conversation history (need to rebuild these with file support if they had attachments)
for (const msg of conversationHistory) {
@@ -327,40 +399,74 @@ export async function POST(req: NextRequest) {
})
}
// Start title generation in parallel if this is a new chat with first message
if (actualChatId && !currentChat?.title && conversationHistory.length === 0) {
logger.info(`[${tracker.requestId}] Will start parallel title generation inside stream`)
// Determine provider and conversationId to use for this request
const providerToUse = provider || 'openai'
const effectiveConversationId =
(currentChat?.conversationId as string | undefined) || conversationId
// If we have a conversationId, only send the most recent user message; else send full history
const latestUserMessage =
[...messages].reverse().find((m) => m?.role === 'user') || messages[messages.length - 1]
const messagesForAgent = effectiveConversationId ? [latestUserMessage] : messages
const requestPayload = {
messages: messagesForAgent,
workflowId,
userId: authenticatedUserId,
stream: stream,
streamToolCalls: true,
mode: mode,
provider: providerToUse,
...(effectiveConversationId ? { conversationId: effectiveConversationId } : {}),
...(typeof effectiveDepth === 'number' ? { depth: effectiveDepth } : {}),
...(typeof effectivePrefetch === 'boolean' ? { prefetch: effectivePrefetch } : {}),
...(session?.user?.name && { userName: session.user.name }),
...(requestOrigin ? { origin: requestOrigin } : {}),
}
// Forward to sim agent API
logger.info(`[${tracker.requestId}] Sending request to sim agent API`, {
messageCount: messages.length,
endpoint: `${SIM_AGENT_API_URL}/api/chat-completion-streaming`,
})
// Log the payload being sent to the streaming endpoint
try {
logger.info(`[${tracker.requestId}] Sending payload to sim agent streaming endpoint`, {
url: `${SIM_AGENT_API_URL}/api/chat-completion-streaming`,
provider: providerToUse,
mode,
stream,
workflowId,
hasConversationId: !!effectiveConversationId,
depth: typeof effectiveDepth === 'number' ? effectiveDepth : undefined,
prefetch: typeof effectivePrefetch === 'boolean' ? effectivePrefetch : undefined,
messagesCount: requestPayload.messages.length,
...(requestOrigin ? { origin: requestOrigin } : {}),
})
// Full payload as JSON string
logger.info(
`[${tracker.requestId}] Full streaming payload: ${JSON.stringify(requestPayload)}`
)
} catch (e) {
logger.warn(`[${tracker.requestId}] Failed to log payload preview for streaming endpoint`, e)
}
const simAgentResponse = await fetch(`${SIM_AGENT_API_URL}/api/chat-completion-streaming`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SIM_AGENT_API_KEY && { 'x-api-key': SIM_AGENT_API_KEY }),
...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}),
},
body: JSON.stringify({
messages,
workflowId,
userId: authenticatedUserId,
stream: stream,
streamToolCalls: true,
mode: mode,
...(session?.user?.name && { userName: session.user.name }),
}),
body: JSON.stringify(requestPayload),
})
if (!simAgentResponse.ok) {
const errorText = await simAgentResponse.text()
if (simAgentResponse.status === 401 || simAgentResponse.status === 402) {
// Rethrow status only; client will render appropriate assistant message
return new NextResponse(null, { status: simAgentResponse.status })
}
const errorText = await simAgentResponse.text().catch(() => '')
logger.error(`[${tracker.requestId}] Sim agent API error:`, {
status: simAgentResponse.status,
error: errorText,
})
return NextResponse.json(
{ error: `Sim agent API error: ${simAgentResponse.statusText}` },
{ status: simAgentResponse.status }
@@ -388,6 +494,14 @@ export async function POST(req: NextRequest) {
const toolCalls: any[] = []
let buffer = ''
let isFirstDone = true
let responseIdFromStart: string | undefined
let responseIdFromDone: string | undefined
// Track tool call progress to identify a safe done event
const announcedToolCallIds = new Set<string>()
const startedToolExecutionIds = new Set<string>()
const completedToolExecutionIds = new Set<string>()
let lastDoneResponseId: string | undefined
let lastSafeDoneResponseId: string | undefined
// Send chatId as first event
if (actualChatId) {
@@ -486,6 +600,13 @@ export async function POST(req: NextRequest) {
}
break
case 'reasoning':
// Treat like thinking: do not add to assistantContent to avoid leaking
logger.debug(
`[${tracker.requestId}] Reasoning chunk received (${(event.data || event.content || '').length} chars)`
)
break
case 'tool_call':
logger.info(
`[${tracker.requestId}] Tool call ${event.data?.partial ? '(partial)' : '(complete)'}:`,
@@ -498,6 +619,9 @@ export async function POST(req: NextRequest) {
)
if (!event.data?.partial) {
toolCalls.push(event.data)
if (event.data?.id) {
announcedToolCallIds.add(event.data.id)
}
}
break
@@ -507,6 +631,14 @@ export async function POST(req: NextRequest) {
toolName: event.toolName,
status: event.status,
})
if (event.toolCallId) {
if (event.status === 'completed') {
startedToolExecutionIds.add(event.toolCallId)
completedToolExecutionIds.add(event.toolCallId)
} else {
startedToolExecutionIds.add(event.toolCallId)
}
}
break
case 'tool_result':
@@ -517,6 +649,9 @@ export async function POST(req: NextRequest) {
result: `${JSON.stringify(event.result).substring(0, 200)}...`,
resultSize: JSON.stringify(event.result).length,
})
if (event.toolCallId) {
completedToolExecutionIds.add(event.toolCallId)
}
break
case 'tool_error':
@@ -526,9 +661,43 @@ export async function POST(req: NextRequest) {
error: event.error,
success: event.success,
})
if (event.toolCallId) {
completedToolExecutionIds.add(event.toolCallId)
}
break
case 'start':
if (event.data?.responseId) {
responseIdFromStart = event.data.responseId
logger.info(
`[${tracker.requestId}] Received start event with responseId: ${responseIdFromStart}`
)
}
break
case 'done':
if (event.data?.responseId) {
responseIdFromDone = event.data.responseId
lastDoneResponseId = responseIdFromDone
logger.info(
`[${tracker.requestId}] Received done event with responseId: ${responseIdFromDone}`
)
// Mark this done as safe only if no tool call is currently in progress or pending
const announced = announcedToolCallIds.size
const completed = completedToolExecutionIds.size
const started = startedToolExecutionIds.size
const hasToolInProgress = announced > completed || started > completed
if (!hasToolInProgress) {
lastSafeDoneResponseId = responseIdFromDone
logger.info(
`[${tracker.requestId}] Marked done as SAFE (no tools in progress)`
)
} else {
logger.info(
`[${tracker.requestId}] Done received but tools are in progress (announced=${announced}, started=${started}, completed=${completed})`
)
}
}
if (isFirstDone) {
logger.info(
`[${tracker.requestId}] Initial AI response complete, tool count: ${toolCalls.length}`
@@ -622,12 +791,17 @@ export async function POST(req: NextRequest) {
)
}
// Persist only a safe conversationId to avoid continuing from a state that expects tool outputs
const previousConversationId = currentChat?.conversationId as string | undefined
const responseId = lastSafeDoneResponseId || previousConversationId || undefined
// Update chat in database immediately (without title)
await db
.update(copilotChats)
.set({
messages: updatedMessages,
updatedAt: new Date(),
...(responseId ? { conversationId: responseId } : {}),
})
.where(eq(copilotChats.id, actualChatId!))
@@ -635,6 +809,7 @@ export async function POST(req: NextRequest) {
messageCount: updatedMessages.length,
savedUserMessage: true,
savedAssistantMessage: assistantContent.trim().length > 0,
updatedConversationId: responseId || null,
})
}
} catch (error) {

View File

@@ -51,12 +51,6 @@ export async function POST(req: NextRequest) {
const body = await req.json()
const { chatId, messages } = UpdateMessagesSchema.parse(body)
logger.info(`[${tracker.requestId}] Updating chat messages`, {
userId,
chatId,
messageCount: messages.length,
})
// Verify that the chat belongs to the user
const [chat] = await db
.select()

View File

@@ -38,7 +38,7 @@ async function updateToolCallStatus(
try {
const key = `tool_call:${toolCallId}`
const timeout = 60000 // 1 minute timeout
const timeout = 600000 // 10 minutes timeout for user confirmation
const pollInterval = 100 // Poll every 100ms
const startTime = Date.now()
@@ -48,11 +48,6 @@ async function updateToolCallStatus(
while (Date.now() - startTime < timeout) {
const exists = await redis.exists(key)
if (exists) {
logger.info('Tool call found in Redis, updating status', {
toolCallId,
key,
pollDuration: Date.now() - startTime,
})
break
}
@@ -79,27 +74,8 @@ async function updateToolCallStatus(
timestamp: new Date().toISOString(),
}
// Log what we're about to update in Redis
logger.info('About to update Redis with tool call data', {
toolCallId,
key,
toolCallData,
serializedData: JSON.stringify(toolCallData),
providedStatus: status,
providedMessage: message,
messageIsUndefined: message === undefined,
messageIsNull: message === null,
})
await redis.set(key, JSON.stringify(toolCallData), 'EX', 86400) // Keep 24 hour expiry
logger.info('Tool call status updated in Redis', {
toolCallId,
key,
status,
message,
pollDuration: Date.now() - startTime,
})
return true
} catch (error) {
logger.error('Failed to update tool call status in Redis', {
@@ -131,13 +107,6 @@ export async function POST(req: NextRequest) {
const body = await req.json()
const { toolCallId, status, message } = ConfirmationSchema.parse(body)
logger.info(`[${tracker.requestId}] Tool call confirmation request`, {
userId: authenticatedUserId,
toolCallId,
status,
message,
})
// Update the tool call status in Redis
const updated = await updateToolCallStatus(toolCallId, status, message)
@@ -153,13 +122,6 @@ export async function POST(req: NextRequest) {
}
const duration = tracker.getDuration()
logger.info(`[${tracker.requestId}] Tool call confirmation completed`, {
userId: authenticatedUserId,
toolCallId,
status,
internalStatus: status,
duration,
})
return NextResponse.json({
success: true,

View File

@@ -60,6 +60,7 @@ describe('Copilot Methods API Route', () => {
vi.doMock('@/lib/env', () => ({
env: {
INTERNAL_API_SECRET: 'test-secret-key',
COPILOT_API_KEY: 'test-copilot-key',
},
}))
@@ -123,10 +124,8 @@ describe('Copilot Methods API Route', () => {
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData).toEqual({
success: false,
error: 'Invalid API key',
})
expect(responseData.success).toBe(false)
expect(typeof responseData.error).toBe('string')
})
it('should return 401 when internal API key is not configured', async () => {
@@ -134,6 +133,7 @@ describe('Copilot Methods API Route', () => {
vi.doMock('@/lib/env', () => ({
env: {
INTERNAL_API_SECRET: undefined,
COPILOT_API_KEY: 'test-copilot-key',
},
}))
@@ -154,10 +154,9 @@ describe('Copilot Methods API Route', () => {
expect(response.status).toBe(401)
const responseData = await response.json()
expect(responseData).toEqual({
success: false,
error: 'Internal API key not configured',
})
expect(responseData.status).toBeUndefined()
expect(responseData.success).toBe(false)
expect(typeof responseData.error).toBe('string')
})
it('should return 400 for invalid request body - missing methodId', async () => {

View File

@@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { copilotToolRegistry } from '@/lib/copilot/tools/server-tools/registry'
import type { NotificationStatus } from '@/lib/copilot/types'
import { env } from '@/lib/env'
import { checkCopilotApiKey, checkInternalApiKey } from '@/lib/copilot/utils'
import { createLogger } from '@/lib/logs/console/logger'
import { getRedisClient } from '@/lib/redis'
import { createErrorResponse } from '@/app/api/copilot/methods/utils'
@@ -65,16 +65,10 @@ async function pollRedisForTool(
}
const key = `tool_call:${toolCallId}`
const timeout = 300000 // 5 minutes
const timeout = 600000 // 10 minutes for long-running operations
const pollInterval = 1000 // 1 second
const startTime = Date.now()
logger.info('Starting to poll Redis for tool call status', {
toolCallId,
timeout,
pollInterval,
})
while (Date.now() - startTime < timeout) {
try {
const redisValue = await redis.get(key)
@@ -112,23 +106,6 @@ async function pollRedisForTool(
rawRedisValue: redisValue,
})
logger.info('Tool call status resolved', {
toolCallId,
status,
message,
duration: Date.now() - startTime,
rawRedisValue: redisValue,
parsedAsJSON: redisValue
? (() => {
try {
return JSON.parse(redisValue)
} catch {
return 'failed-to-parse'
}
})()
: null,
})
// Special logging for set environment variables tool when Redis status is found
if (toolCallId && (status === 'accepted' || status === 'rejected')) {
logger.info('SET_ENV_VARS: Redis polling found status update', {
@@ -240,33 +217,12 @@ async function interruptHandler(toolCallId: string): Promise<{
}
}
// Schema for method execution
const MethodExecutionSchema = z.object({
methodId: z.string().min(1, 'Method ID is required'),
params: z.record(z.any()).optional().default({}),
toolCallId: z.string().nullable().optional().default(null),
})
// Simple internal API key authentication
function checkInternalApiKey(req: NextRequest) {
const apiKey = req.headers.get('x-api-key')
const expectedApiKey = env.INTERNAL_API_SECRET
if (!expectedApiKey) {
return { success: false, error: 'Internal API key not configured' }
}
if (!apiKey) {
return { success: false, error: 'API key required' }
}
if (apiKey !== expectedApiKey) {
return { success: false, error: 'Invalid API key' }
}
return { success: true }
}
/**
* POST /api/copilot/methods
* Execute a method based on methodId with internal API key auth
@@ -276,10 +232,13 @@ export async function POST(req: NextRequest) {
const startTime = Date.now()
try {
// Check authentication (internal API key)
const authResult = checkInternalApiKey(req)
if (!authResult.success) {
return NextResponse.json(createErrorResponse(authResult.error || 'Authentication failed'), {
// Evaluate both auth schemes; pass if either is valid
const internalAuth = checkInternalApiKey(req)
const copilotAuth = checkCopilotApiKey(req)
const isAuthenticated = !!(internalAuth?.success || copilotAuth?.success)
if (!isAuthenticated) {
const errorMessage = copilotAuth.error || internalAuth.error || 'Authentication failed'
return NextResponse.json(createErrorResponse(errorMessage), {
status: 401,
})
}
@@ -287,7 +246,7 @@ export async function POST(req: NextRequest) {
const body = await req.json()
const { methodId, params, toolCallId } = MethodExecutionSchema.parse(body)
logger.info(`[${requestId}] Method execution request: ${methodId}`, {
logger.info(`[${requestId}] Method execution request`, {
methodId,
toolCallId,
hasParams: !!params && Object.keys(params).length > 0,

View File

@@ -3,9 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
export const dynamic = 'force-dynamic'
import { decryptSecret, encryptSecret } from '@/lib/utils'
import { db } from '@/db'
import { environment } from '@/db/schema'

View File

@@ -0,0 +1,164 @@
import {
AbortMultipartUploadCommand,
CompleteMultipartUploadCommand,
CreateMultipartUploadCommand,
UploadPartCommand,
} from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { getStorageProvider, isUsingCloudStorage } from '@/lib/uploads'
import { S3_KB_CONFIG } from '@/lib/uploads/setup'
const logger = createLogger('MultipartUploadAPI')
interface InitiateMultipartRequest {
fileName: string
contentType: string
fileSize: number
}
interface GetPartUrlsRequest {
uploadId: string
key: string
partNumbers: number[]
}
interface CompleteMultipartRequest {
uploadId: string
key: string
parts: Array<{
ETag: string
PartNumber: number
}>
}
export async function POST(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const action = request.nextUrl.searchParams.get('action')
if (!isUsingCloudStorage() || getStorageProvider() !== 's3') {
return NextResponse.json(
{ error: 'Multipart upload is only available with S3 storage' },
{ status: 400 }
)
}
const { getS3Client } = await import('@/lib/uploads/s3/s3-client')
const s3Client = getS3Client()
switch (action) {
case 'initiate': {
const data: InitiateMultipartRequest = await request.json()
const { fileName, contentType } = data
const safeFileName = fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_')
const uniqueKey = `kb/${uuidv4()}-${safeFileName}`
const command = new CreateMultipartUploadCommand({
Bucket: S3_KB_CONFIG.bucket,
Key: uniqueKey,
ContentType: contentType,
Metadata: {
originalName: fileName,
uploadedAt: new Date().toISOString(),
purpose: 'knowledge-base',
},
})
const response = await s3Client.send(command)
logger.info(`Initiated multipart upload for ${fileName}: ${response.UploadId}`)
return NextResponse.json({
uploadId: response.UploadId,
key: uniqueKey,
})
}
case 'get-part-urls': {
const data: GetPartUrlsRequest = await request.json()
const { uploadId, key, partNumbers } = data
const presignedUrls = await Promise.all(
partNumbers.map(async (partNumber) => {
const command = new UploadPartCommand({
Bucket: S3_KB_CONFIG.bucket,
Key: key,
PartNumber: partNumber,
UploadId: uploadId,
})
const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 })
return { partNumber, url }
})
)
return NextResponse.json({ presignedUrls })
}
case 'complete': {
const data: CompleteMultipartRequest = await request.json()
const { uploadId, key, parts } = data
const command = new CompleteMultipartUploadCommand({
Bucket: S3_KB_CONFIG.bucket,
Key: key,
UploadId: uploadId,
MultipartUpload: {
Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber),
},
})
const response = await s3Client.send(command)
logger.info(`Completed multipart upload for key ${key}`)
const finalPath = `/api/files/serve/s3/${encodeURIComponent(key)}`
return NextResponse.json({
success: true,
location: response.Location,
path: finalPath,
key,
})
}
case 'abort': {
const data = await request.json()
const { uploadId, key } = data
const command = new AbortMultipartUploadCommand({
Bucket: S3_KB_CONFIG.bucket,
Key: key,
UploadId: uploadId,
})
await s3Client.send(command)
logger.info(`Aborted multipart upload for key ${key}`)
return NextResponse.json({ success: true })
}
default:
return NextResponse.json(
{ error: 'Invalid action. Use: initiate, get-part-urls, complete, or abort' },
{ status: 400 }
)
}
} catch (error) {
logger.error('Multipart upload error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Multipart upload failed' },
{ status: 500 }
)
}
}

View File

@@ -2,6 +2,7 @@ import { PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { getStorageProvider, isUsingCloudStorage } from '@/lib/uploads'
// Dynamic imports for storage clients to avoid client-side bundling
@@ -54,6 +55,11 @@ class ValidationError extends PresignedUrlError {
export async function POST(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
let data: PresignedUrlRequest
try {
data = await request.json()
@@ -61,7 +67,7 @@ export async function POST(request: NextRequest) {
throw new ValidationError('Invalid JSON in request body')
}
const { fileName, contentType, fileSize, userId, chatId } = data
const { fileName, contentType, fileSize } = data
if (!fileName?.trim()) {
throw new ValidationError('fileName is required and cannot be empty')
@@ -90,10 +96,13 @@ export async function POST(request: NextRequest) {
? 'copilot'
: 'general'
// Validate copilot-specific requirements
// Evaluate user id from session for copilot uploads
const sessionUserId = session.user.id
// Validate copilot-specific requirements (use session user)
if (uploadType === 'copilot') {
if (!userId?.trim()) {
throw new ValidationError('userId is required for copilot uploads')
if (!sessionUserId?.trim()) {
throw new ValidationError('Authenticated user session is required for copilot uploads')
}
}
@@ -108,9 +117,21 @@ export async function POST(request: NextRequest) {
switch (storageProvider) {
case 's3':
return await handleS3PresignedUrl(fileName, contentType, fileSize, uploadType, userId)
return await handleS3PresignedUrl(
fileName,
contentType,
fileSize,
uploadType,
sessionUserId
)
case 'blob':
return await handleBlobPresignedUrl(fileName, contentType, fileSize, uploadType, userId)
return await handleBlobPresignedUrl(
fileName,
contentType,
fileSize,
uploadType,
sessionUserId
)
default:
throw new StorageConfigError(`Unknown storage provider: ${storageProvider}`)
}

View File

@@ -2,6 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import { getPresignedUrl, isUsingCloudStorage, uploadFile } from '@/lib/uploads'
import '@/lib/uploads/setup.server'
import { getSession } from '@/lib/auth'
import {
createErrorResponse,
createOptionsResponse,
@@ -14,6 +15,11 @@ const logger = createLogger('FilesUploadAPI')
export async function POST(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const formData = await request.formData()
// Check if multiple files are being uploaded or a single file

View File

@@ -178,7 +178,7 @@ export function findLocalFile(filename: string): string | null {
* Create a file response with appropriate headers
*/
export function createFileResponse(file: FileResponse): NextResponse {
return new NextResponse(file.buffer, {
return new NextResponse(file.buffer as BodyInit, {
status: 200,
headers: {
'Content-Type': file.contentType,

View File

@@ -7,7 +7,6 @@ import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest } from '@/app/api/__test-utils__/utils'
const mockFreestyleExecuteScript = vi.fn()
const mockCreateContext = vi.fn()
const mockRunInContext = vi.fn()
const mockLogger = {
@@ -29,27 +28,10 @@ describe('Function Execute API Route', () => {
})),
}))
vi.doMock('freestyle-sandboxes', () => ({
FreestyleSandboxes: vi.fn().mockImplementation(() => ({
executeScript: mockFreestyleExecuteScript,
})),
}))
vi.doMock('@/lib/env', () => ({
env: {
FREESTYLE_API_KEY: 'test-freestyle-key',
},
}))
vi.doMock('@/lib/logs/console/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
mockFreestyleExecuteScript.mockResolvedValue({
result: 'freestyle success',
logs: [],
})
mockRunInContext.mockResolvedValue('vm success')
mockCreateContext.mockReturnValue({})
})
@@ -228,107 +210,6 @@ describe('Function Execute API Route', () => {
})
})
describe.skip('Freestyle Execution', () => {
it('should use Freestyle when API key is available', async () => {
const req = createMockRequest('POST', {
code: 'return "freestyle test"',
})
const { POST } = await import('@/app/api/function/execute/route')
await POST(req)
expect(mockFreestyleExecuteScript).toHaveBeenCalled()
expect(mockLogger.info).toHaveBeenCalledWith(
expect.stringMatching(/\[.*\] Using Freestyle for code execution/)
)
})
it('should handle Freestyle errors and fallback to VM', async () => {
mockFreestyleExecuteScript.mockRejectedValueOnce(new Error('Freestyle API error'))
const req = createMockRequest('POST', {
code: 'return "fallback test"',
})
const { POST } = await import('@/app/api/function/execute/route')
const response = await POST(req)
expect(mockFreestyleExecuteScript).toHaveBeenCalled()
expect(mockRunInContext).toHaveBeenCalled()
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringMatching(/\[.*\] Freestyle API call failed, falling back to VM:/),
expect.any(Object)
)
})
it('should handle Freestyle script errors', async () => {
mockFreestyleExecuteScript.mockResolvedValueOnce({
result: null,
logs: [{ type: 'error', message: 'ReferenceError: undefined variable' }],
})
const req = createMockRequest('POST', {
code: 'return undefinedVariable',
})
const { POST } = await import('@/app/api/function/execute/route')
const response = await POST(req)
expect(response.status).toBe(500)
const data = await response.json()
expect(data.success).toBe(false)
})
})
describe('VM Execution', () => {
it.skip('should use VM when Freestyle API key is not available', async () => {
// Mock no Freestyle API key
vi.doMock('@/lib/env', () => ({
env: {
FREESTYLE_API_KEY: undefined,
},
}))
const req = createMockRequest('POST', {
code: 'return "vm test"',
})
const { POST } = await import('@/app/api/function/execute/route')
await POST(req)
expect(mockFreestyleExecuteScript).not.toHaveBeenCalled()
expect(mockRunInContext).toHaveBeenCalled()
expect(mockLogger.info).toHaveBeenCalledWith(
expect.stringMatching(
/\[.*\] Using VM for code execution \(no Freestyle API key available\)/
)
)
})
it('should handle VM execution errors', async () => {
// Mock no Freestyle API key so it uses VM
vi.doMock('@/lib/env', () => ({
env: {
FREESTYLE_API_KEY: undefined,
},
}))
mockRunInContext.mockRejectedValueOnce(new Error('VM execution error'))
const req = createMockRequest('POST', {
code: 'return invalidCode(',
})
const { POST } = await import('@/app/api/function/execute/route')
const response = await POST(req)
expect(response.status).toBe(500)
const data = await response.json()
expect(data.success).toBe(false)
expect(data.error).toContain('VM execution error')
})
})
describe('Custom Tools', () => {
it('should handle custom tool execution with direct parameter access', async () => {
const req = createMockRequest('POST', {
@@ -651,113 +532,3 @@ SyntaxError: Invalid or unexpected token
})
})
})
describe('Function Execute API - Template Variable Edge Cases', () => {
beforeEach(() => {
vi.resetModules()
vi.resetAllMocks()
vi.doMock('@/lib/logs/console/logger', () => ({
createLogger: vi.fn().mockReturnValue(mockLogger),
}))
vi.doMock('@/lib/env', () => ({
env: {
FREESTYLE_API_KEY: 'test-freestyle-key',
},
}))
vi.doMock('vm', () => ({
createContext: mockCreateContext,
Script: vi.fn().mockImplementation(() => ({
runInContext: mockRunInContext,
})),
}))
vi.doMock('freestyle-sandboxes', () => ({
FreestyleSandboxes: vi.fn().mockImplementation(() => ({
executeScript: mockFreestyleExecuteScript,
})),
}))
mockFreestyleExecuteScript.mockResolvedValue({
result: 'freestyle success',
logs: [],
})
mockRunInContext.mockResolvedValue('vm success')
mockCreateContext.mockReturnValue({})
})
it.skip('should handle nested template variables', async () => {
mockFreestyleExecuteScript.mockResolvedValueOnce({
result: 'environment-valueparam-value',
logs: [],
})
const req = createMockRequest('POST', {
code: 'return {{outer}} + <inner>',
envVars: {
outer: 'environment-value',
},
params: {
inner: 'param-value',
},
})
const { POST } = await import('@/app/api/function/execute/route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.output.result).toBe('environment-valueparam-value')
})
it.skip('should prioritize environment variables over params for {{}} syntax', async () => {
mockFreestyleExecuteScript.mockResolvedValueOnce({
result: 'env-wins',
logs: [],
})
const req = createMockRequest('POST', {
code: 'return {{conflictVar}}',
envVars: {
conflictVar: 'env-wins',
},
params: {
conflictVar: 'param-loses',
},
})
const { POST } = await import('@/app/api/function/execute/route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
// Environment variable should take precedence
expect(data.output.result).toBe('env-wins')
})
it.skip('should handle missing template variables gracefully', async () => {
mockFreestyleExecuteScript.mockResolvedValueOnce({
result: '',
logs: [],
})
const req = createMockRequest('POST', {
code: 'return {{nonexistent}} + <alsoMissing>',
envVars: {},
params: {},
})
const { POST } = await import('@/app/api/function/execute/route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.output.result).toBe('')
})
})

View File

@@ -367,150 +367,6 @@ export async function POST(req: NextRequest) {
const executionMethod = 'vm' // Default execution method
// // Try to use Freestyle if the API key is available
// if (env.FREESTYLE_API_KEY) {
// try {
// logger.info(`[${requestId}] Using Freestyle for code execution`)
// executionMethod = 'freestyle'
// // Extract npm packages from code if needed
// const importRegex =
// /import\s+?(?:(?:(?:[\w*\s{},]*)\s+from\s+?)|)(?:(?:"([^"]*)")|(?:'([^']*)'))[^;]*/g
// const requireRegex = /const\s+[\w\s{}]*\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/g
// const packages: Record<string, string> = {}
// const matches = [
// ...resolvedCode.matchAll(importRegex),
// ...resolvedCode.matchAll(requireRegex),
// ]
// // Extract package names from import statements
// for (const match of matches) {
// const packageName = match[1] || match[2]
// if (packageName && !packageName.startsWith('.') && !packageName.startsWith('/')) {
// // Extract just the package name without version or subpath
// const basePackageName = packageName.split('/')[0]
// packages[basePackageName] = 'latest' // Use latest version
// }
// }
// const freestyle = new FreestyleSandboxes({
// apiKey: env.FREESTYLE_API_KEY,
// })
// // Wrap code in export default to match Freestyle's expectations
// const wrappedCode = isCustomTool
// ? `export default async () => {
// // For custom tools, directly declare parameters as variables
// ${Object.entries(executionParams)
// .map(([key, value]) => `const ${key} = ${safeJSONStringify(value)};`)
// .join('\n ')}
// ${resolvedCode}
// }`
// : `export default async () => { ${resolvedCode} }`
// // Execute the code with Freestyle
// const res = await freestyle.executeScript(wrappedCode, {
// nodeModules: packages,
// timeout: null,
// envVars: envVars,
// })
// // Check for direct API error response
// // Type assertion since the library types don't include error response
// const response = res as { _type?: string; error?: string }
// if (response._type === 'error' && response.error) {
// logger.error(`[${requestId}] Freestyle returned error response`, {
// error: response.error,
// })
// throw response.error
// }
// // Capture stdout/stderr from Freestyle logs
// stdout =
// res.logs
// ?.map((log) => (log.type === 'error' ? 'ERROR: ' : '') + log.message)
// .join('\n') || ''
// // Check for errors reported within Freestyle logs
// const freestyleErrors = res.logs?.filter((log) => log.type === 'error') || []
// if (freestyleErrors.length > 0) {
// const errorMessage = freestyleErrors.map((log) => log.message).join('\n')
// logger.error(`[${requestId}] Freestyle execution completed with script errors`, {
// errorMessage,
// stdout,
// })
// // Create a proper Error object to be caught by the outer handler
// const scriptError = new Error(errorMessage)
// scriptError.name = 'FreestyleScriptError'
// throw scriptError
// }
// // If no errors, execution was successful
// result = res.result
// logger.info(`[${requestId}] Freestyle execution successful`, {
// result,
// stdout,
// })
// } catch (error: any) {
// // Check if the error came from our explicit throw above due to script errors
// if (error.name === 'FreestyleScriptError') {
// throw error // Re-throw to be caught by the outer handler
// }
// // Otherwise, it's likely a Freestyle API call error (network, auth, config, etc.) -> Fallback to VM
// logger.error(`[${requestId}] Freestyle API call failed, falling back to VM:`, {
// error: error.message,
// stack: error.stack,
// })
// executionMethod = 'vm_fallback'
// // Continue to VM execution
// const context = createContext({
// params: executionParams,
// environmentVariables: envVars,
// console: {
// log: (...args: any[]) => {
// const logMessage = `${args
// .map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : String(arg)))
// .join(' ')}\n`
// stdout += logMessage
// },
// error: (...args: any[]) => {
// const errorMessage = `${args
// .map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : String(arg)))
// .join(' ')}\n`
// logger.error(`[${requestId}] Code Console Error: ${errorMessage}`)
// stdout += `ERROR: ${errorMessage}`
// },
// },
// })
// const script = new Script(`
// (async () => {
// try {
// ${
// isCustomTool
// ? `// For custom tools, make parameters directly accessible
// ${Object.keys(executionParams)
// .map((key) => `const ${key} = params.${key};`)
// .join('\n ')}`
// : ''
// }
// ${resolvedCode}
// } catch (error) {
// console.error(error);
// throw error;
// }
// })()
// `)
// result = await script.runInContext(context, {
// timeout,
// displayErrors: true,
// })
// }
// } else {
logger.info(`[${requestId}] Using VM for code execution`, {
resolvedCode,
hasEnvVars: Object.keys(envVars).length > 0,

View File

@@ -1,6 +1,7 @@
import { type NextRequest, NextResponse } from 'next/server'
import { Resend } from 'resend'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { getEmailDomain } from '@/lib/urls/utils'
@@ -9,7 +10,6 @@ const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null
const logger = createLogger('HelpAPI')
const helpFormSchema = z.object({
email: z.string().email('Invalid email address'),
subject: z.string().min(1, 'Subject is required'),
message: z.string().min(1, 'Message is required'),
type: z.enum(['bug', 'feedback', 'feature_request', 'other']),
@@ -19,6 +19,15 @@ export async function POST(req: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
// Get user session
const session = await getSession()
if (!session?.user?.email) {
logger.warn(`[${requestId}] Unauthorized help request attempt`)
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const email = session.user.email
// Check if Resend API key is configured
if (!resend) {
logger.error(`[${requestId}] RESEND_API_KEY not configured`)
@@ -35,7 +44,6 @@ export async function POST(req: NextRequest) {
const formData = await req.formData()
// Extract form fields
const email = formData.get('email') as string
const subject = formData.get('subject') as string
const message = formData.get('message') as string
const type = formData.get('type') as string
@@ -47,7 +55,6 @@ export async function POST(req: NextRequest) {
// Validate the form data
const result = helpFormSchema.safeParse({
email,
subject,
message,
type,
@@ -97,7 +104,7 @@ ${message}
}
// Send email using Resend
const { data, error } = await resend.emails.send({
const { error } = await resend.emails.send({
from: `Sim <noreply@${env.EMAIL_DOMAIN || getEmailDomain()}>`,
to: [`help@${env.EMAIL_DOMAIN || getEmailDomain()}`],
subject: `[${type.toUpperCase()}] ${subject}`,

View File

@@ -3,11 +3,8 @@ import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
export const dynamic = 'force-dynamic'
import { createErrorResponse } from '@/app/api/workflows/utils'
import { db } from '@/db'
import { apiKey as apiKeyTable } from '@/db/schema'
const logger = createLogger('TaskStatusAPI')

View File

@@ -4,9 +4,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
export const dynamic = 'force-dynamic'
import { checkChunkAccess } from '@/app/api/knowledge/utils'
import { db } from '@/db'
import { document, embedding } from '@/db/schema'

View File

@@ -4,9 +4,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
export const dynamic = 'force-dynamic'
import { estimateTokenCount } from '@/lib/tokenization/estimators'
import { getUserId } from '@/app/api/auth/oauth/utils'
import {

View File

@@ -4,9 +4,6 @@ import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { TAG_SLOTS } from '@/lib/constants/knowledge'
import { createLogger } from '@/lib/logs/console/logger'
export const dynamic = 'force-dynamic'
import {
checkDocumentAccess,
checkDocumentWriteAccess,

View File

@@ -0,0 +1,118 @@
import { randomUUID } from 'crypto'
import { and, eq, isNotNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils'
import { db } from '@/db'
import { document, embedding, knowledgeBaseTagDefinitions } from '@/db/schema'
export const dynamic = 'force-dynamic'
const logger = createLogger('TagDefinitionAPI')
// DELETE /api/knowledge/[id]/tag-definitions/[tagId] - Delete a tag definition
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string; tagId: string }> }
) {
const requestId = randomUUID().slice(0, 8)
const { id: knowledgeBaseId, tagId } = await params
try {
logger.info(
`[${requestId}] Deleting tag definition ${tagId} from knowledge base ${knowledgeBaseId}`
)
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if user has access to the knowledge base
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
if (!accessCheck.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// Get the tag definition to find which slot it uses
const tagDefinition = await db
.select({
id: knowledgeBaseTagDefinitions.id,
tagSlot: knowledgeBaseTagDefinitions.tagSlot,
displayName: knowledgeBaseTagDefinitions.displayName,
})
.from(knowledgeBaseTagDefinitions)
.where(
and(
eq(knowledgeBaseTagDefinitions.id, tagId),
eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId)
)
)
.limit(1)
if (tagDefinition.length === 0) {
return NextResponse.json({ error: 'Tag definition not found' }, { status: 404 })
}
const tagDef = tagDefinition[0]
// Delete the tag definition and clear all document tags in a transaction
await db.transaction(async (tx) => {
logger.info(`[${requestId}] Starting transaction to delete ${tagDef.tagSlot}`)
try {
// Clear the tag from documents that actually have this tag set
logger.info(`[${requestId}] Clearing tag from documents...`)
await tx
.update(document)
.set({ [tagDef.tagSlot]: null })
.where(
and(
eq(document.knowledgeBaseId, knowledgeBaseId),
isNotNull(document[tagDef.tagSlot as keyof typeof document.$inferSelect])
)
)
logger.info(`[${requestId}] Documents updated successfully`)
// Clear the tag from embeddings that actually have this tag set
logger.info(`[${requestId}] Clearing tag from embeddings...`)
await tx
.update(embedding)
.set({ [tagDef.tagSlot]: null })
.where(
and(
eq(embedding.knowledgeBaseId, knowledgeBaseId),
isNotNull(embedding[tagDef.tagSlot as keyof typeof embedding.$inferSelect])
)
)
logger.info(`[${requestId}] Embeddings updated successfully`)
// Delete the tag definition
logger.info(`[${requestId}] Deleting tag definition...`)
await tx
.delete(knowledgeBaseTagDefinitions)
.where(eq(knowledgeBaseTagDefinitions.id, tagId))
logger.info(`[${requestId}] Tag definition deleted successfully`)
} catch (error) {
logger.error(`[${requestId}] Error in transaction:`, error)
throw error
}
})
logger.info(
`[${requestId}] Successfully deleted tag definition ${tagDef.displayName} (${tagDef.tagSlot})`
)
return NextResponse.json({
success: true,
message: `Tag definition "${tagDef.displayName}" deleted successfully`,
})
} catch (error) {
logger.error(`[${requestId}] Error deleting tag definition`, error)
return NextResponse.json({ error: 'Failed to delete tag definition' }, { status: 500 })
}
}

View File

@@ -1,5 +1,5 @@
import { randomUUID } from 'crypto'
import { eq } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
@@ -55,3 +55,89 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'Failed to get tag definitions' }, { status: 500 })
}
}
// POST /api/knowledge/[id]/tag-definitions - Create a new tag definition
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = randomUUID().slice(0, 8)
const { id: knowledgeBaseId } = await params
try {
logger.info(`[${requestId}] Creating tag definition for knowledge base ${knowledgeBaseId}`)
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if user has access to the knowledge base
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
if (!accessCheck.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await req.json()
const { tagSlot, displayName, fieldType } = body
if (!tagSlot || !displayName || !fieldType) {
return NextResponse.json(
{ error: 'tagSlot, displayName, and fieldType are required' },
{ status: 400 }
)
}
// Check if tag slot is already used
const existingTag = await db
.select()
.from(knowledgeBaseTagDefinitions)
.where(
and(
eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId),
eq(knowledgeBaseTagDefinitions.tagSlot, tagSlot)
)
)
.limit(1)
if (existingTag.length > 0) {
return NextResponse.json({ error: 'Tag slot is already in use' }, { status: 409 })
}
// Check if display name is already used
const existingName = await db
.select()
.from(knowledgeBaseTagDefinitions)
.where(
and(
eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId),
eq(knowledgeBaseTagDefinitions.displayName, displayName)
)
)
.limit(1)
if (existingName.length > 0) {
return NextResponse.json({ error: 'Tag name is already in use' }, { status: 409 })
}
// Create the new tag definition
const newTagDefinition = {
id: randomUUID(),
knowledgeBaseId,
tagSlot,
displayName,
fieldType,
createdAt: new Date(),
updatedAt: new Date(),
}
await db.insert(knowledgeBaseTagDefinitions).values(newTagDefinition)
logger.info(`[${requestId}] Successfully created tag definition ${displayName} (${tagSlot})`)
return NextResponse.json({
success: true,
data: newTagDefinition,
})
} catch (error) {
logger.error(`[${requestId}] Error creating tag definition`, error)
return NextResponse.json({ error: 'Failed to create tag definition' }, { status: 500 })
}
}

View File

@@ -0,0 +1,88 @@
import { randomUUID } from 'crypto'
import { and, eq, isNotNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils'
import { db } from '@/db'
import { document, knowledgeBaseTagDefinitions } from '@/db/schema'
export const dynamic = 'force-dynamic'
const logger = createLogger('TagUsageAPI')
// GET /api/knowledge/[id]/tag-usage - Get usage statistics for all tag definitions
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = randomUUID().slice(0, 8)
const { id: knowledgeBaseId } = await params
try {
logger.info(`[${requestId}] Getting tag usage statistics for knowledge base ${knowledgeBaseId}`)
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if user has access to the knowledge base
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
if (!accessCheck.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// Get all tag definitions for the knowledge base
const tagDefinitions = await db
.select({
id: knowledgeBaseTagDefinitions.id,
tagSlot: knowledgeBaseTagDefinitions.tagSlot,
displayName: knowledgeBaseTagDefinitions.displayName,
})
.from(knowledgeBaseTagDefinitions)
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
// Get usage statistics for each tag definition
const usageStats = await Promise.all(
tagDefinitions.map(async (tagDef) => {
// Count documents using this tag slot
const tagSlotColumn = tagDef.tagSlot as keyof typeof document.$inferSelect
const documentsWithTag = await db
.select({
id: document.id,
filename: document.filename,
[tagDef.tagSlot]: document[tagSlotColumn as keyof typeof document.$inferSelect] as any,
})
.from(document)
.where(
and(
eq(document.knowledgeBaseId, knowledgeBaseId),
isNotNull(document[tagSlotColumn as keyof typeof document.$inferSelect])
)
)
return {
tagName: tagDef.displayName,
tagSlot: tagDef.tagSlot,
documentCount: documentsWithTag.length,
documents: documentsWithTag.map((doc) => ({
id: doc.id,
name: doc.filename,
tagValue: doc[tagDef.tagSlot],
})),
}
})
)
logger.info(
`[${requestId}] Retrieved usage statistics for ${tagDefinitions.length} tag definitions`
)
return NextResponse.json({
success: true,
data: usageStats,
})
} catch (error) {
logger.error(`[${requestId}] Error getting tag usage statistics`, error)
return NextResponse.json({ error: 'Failed to get tag usage statistics' }, { status: 500 })
}
}

View File

@@ -30,6 +30,8 @@ vi.mock('@/lib/env', () => ({
env: {
OPENAI_API_KEY: 'test-api-key',
},
isTruthy: (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
}))
vi.mock('@/lib/documents/utils', () => ({

View File

@@ -15,7 +15,11 @@ vi.mock('drizzle-orm', () => ({
sql: (strings: TemplateStringsArray, ...expr: any[]) => ({ strings, expr }),
}))
vi.mock('@/lib/env', () => ({ env: { OPENAI_API_KEY: 'test-key' } }))
vi.mock('@/lib/env', () => ({
env: { OPENAI_API_KEY: 'test-key' },
isTruthy: (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value),
}))
vi.mock('@/lib/documents/utils', () => ({
retryWithExponentialBackoff: (fn: any) => fn(),

View File

@@ -46,20 +46,7 @@ export async function GET(
startedAt: workflowLog.startedAt.toISOString(),
endedAt: workflowLog.endedAt?.toISOString(),
totalDurationMs: workflowLog.totalDurationMs,
blockStats: {
total: workflowLog.blockCount,
success: workflowLog.successCount,
error: workflowLog.errorCount,
skipped: workflowLog.skippedCount,
},
cost: {
total: workflowLog.totalCost ? Number.parseFloat(workflowLog.totalCost) : null,
input: workflowLog.totalInputCost ? Number.parseFloat(workflowLog.totalInputCost) : null,
output: workflowLog.totalOutputCost
? Number.parseFloat(workflowLog.totalOutputCost)
: null,
},
totalTokens: workflowLog.totalTokens,
cost: workflowLog.cost || null,
},
}

View File

@@ -0,0 +1,102 @@
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { permissions, workflow, workflowExecutionLogs } from '@/db/schema'
const logger = createLogger('LogDetailsByIdAPI')
export const revalidate = 0
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized log details access attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const { id } = await params
const rows = await db
.select({
id: workflowExecutionLogs.id,
workflowId: workflowExecutionLogs.workflowId,
executionId: workflowExecutionLogs.executionId,
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
level: workflowExecutionLogs.level,
trigger: workflowExecutionLogs.trigger,
startedAt: workflowExecutionLogs.startedAt,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
executionData: workflowExecutionLogs.executionData,
cost: workflowExecutionLogs.cost,
files: workflowExecutionLogs.files,
createdAt: workflowExecutionLogs.createdAt,
workflowName: workflow.name,
workflowDescription: workflow.description,
workflowColor: workflow.color,
workflowFolderId: workflow.folderId,
workflowUserId: workflow.userId,
workflowWorkspaceId: workflow.workspaceId,
workflowCreatedAt: workflow.createdAt,
workflowUpdatedAt: workflow.updatedAt,
})
.from(workflowExecutionLogs)
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workflow.workspaceId),
eq(permissions.userId, userId)
)
)
.where(eq(workflowExecutionLogs.id, id))
.limit(1)
const log = rows[0]
if (!log) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const workflowSummary = {
id: log.workflowId,
name: log.workflowName,
description: log.workflowDescription,
color: log.workflowColor,
folderId: log.workflowFolderId,
userId: log.workflowUserId,
workspaceId: log.workflowWorkspaceId,
createdAt: log.workflowCreatedAt,
updatedAt: log.workflowUpdatedAt,
}
const response = {
id: log.id,
workflowId: log.workflowId,
executionId: log.executionId,
level: log.level,
duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null,
trigger: log.trigger,
createdAt: log.startedAt.toISOString(),
files: log.files || undefined,
workflow: workflowSummary,
executionData: {
totalDuration: log.totalDurationMs,
...(log.executionData as any),
enhanced: true,
},
cost: log.cost as any,
}
return NextResponse.json({ data: response })
} catch (error: any) {
logger.error(`[${requestId}] log details fetch error`, error)
return NextResponse.json({ error: error.message }, { status: 500 })
}
}

View File

@@ -99,21 +99,13 @@ export async function GET(request: NextRequest) {
executionId: workflowExecutionLogs.executionId,
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
level: workflowExecutionLogs.level,
message: workflowExecutionLogs.message,
trigger: workflowExecutionLogs.trigger,
startedAt: workflowExecutionLogs.startedAt,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
blockCount: workflowExecutionLogs.blockCount,
successCount: workflowExecutionLogs.successCount,
errorCount: workflowExecutionLogs.errorCount,
skippedCount: workflowExecutionLogs.skippedCount,
totalCost: workflowExecutionLogs.totalCost,
totalInputCost: workflowExecutionLogs.totalInputCost,
totalOutputCost: workflowExecutionLogs.totalOutputCost,
totalTokens: workflowExecutionLogs.totalTokens,
executionData: workflowExecutionLogs.executionData,
cost: workflowExecutionLogs.cost,
files: workflowExecutionLogs.files,
metadata: workflowExecutionLogs.metadata,
createdAt: workflowExecutionLogs.createdAt,
})
.from(workflowExecutionLogs)

View File

@@ -1,4 +1,4 @@
import { and, desc, eq, gte, inArray, lte, or, type SQL, sql } from 'drizzle-orm'
import { and, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
@@ -44,8 +44,7 @@ function extractBlockExecutionsFromTraceSpans(traceSpans: any[]): any[] {
export const revalidate = 0
const QueryParamsSchema = z.object({
includeWorkflow: z.coerce.boolean().optional().default(false),
includeBlocks: z.coerce.boolean().optional().default(false),
details: z.enum(['basic', 'full']).optional().default('basic'),
limit: z.coerce.number().optional().default(100),
offset: z.coerce.number().optional().default(0),
level: z.string().optional(),
@@ -81,20 +80,12 @@ export async function GET(request: NextRequest) {
executionId: workflowExecutionLogs.executionId,
stateSnapshotId: workflowExecutionLogs.stateSnapshotId,
level: workflowExecutionLogs.level,
message: workflowExecutionLogs.message,
trigger: workflowExecutionLogs.trigger,
startedAt: workflowExecutionLogs.startedAt,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
blockCount: workflowExecutionLogs.blockCount,
successCount: workflowExecutionLogs.successCount,
errorCount: workflowExecutionLogs.errorCount,
skippedCount: workflowExecutionLogs.skippedCount,
totalCost: workflowExecutionLogs.totalCost,
totalInputCost: workflowExecutionLogs.totalInputCost,
totalOutputCost: workflowExecutionLogs.totalOutputCost,
totalTokens: workflowExecutionLogs.totalTokens,
metadata: workflowExecutionLogs.metadata,
executionData: workflowExecutionLogs.executionData,
cost: workflowExecutionLogs.cost,
files: workflowExecutionLogs.files,
createdAt: workflowExecutionLogs.createdAt,
workflowName: workflow.name,
@@ -163,13 +154,8 @@ export async function GET(request: NextRequest) {
// Filter by search query
if (params.search) {
const searchTerm = `%${params.search}%`
conditions = and(
conditions,
or(
sql`${workflowExecutionLogs.message} ILIKE ${searchTerm}`,
sql`${workflowExecutionLogs.executionId} ILIKE ${searchTerm}`
)
)
// With message removed, restrict search to executionId only
conditions = and(conditions, sql`${workflowExecutionLogs.executionId} ILIKE ${searchTerm}`)
}
// Execute the query using the optimized join
@@ -290,31 +276,20 @@ export async function GET(request: NextRequest) {
const enhancedLogs = logs.map((log) => {
const blockExecutions = blockExecutionsByExecution[log.executionId] || []
// Use stored trace spans from metadata if available, otherwise create from block executions
const storedTraceSpans = (log.metadata as any)?.traceSpans
// Use stored trace spans if available, otherwise create from block executions
const storedTraceSpans = (log.executionData as any)?.traceSpans
const traceSpans =
storedTraceSpans && Array.isArray(storedTraceSpans) && storedTraceSpans.length > 0
? storedTraceSpans
: createTraceSpans(blockExecutions)
// Use extracted cost summary if available, otherwise use stored values
// Prefer stored cost JSON; otherwise synthesize from blocks
const costSummary =
blockExecutions.length > 0
? extractCostSummary(blockExecutions)
: {
input: Number(log.totalInputCost) || 0,
output: Number(log.totalOutputCost) || 0,
total: Number(log.totalCost) || 0,
tokens: {
total: log.totalTokens || 0,
prompt: (log.metadata as any)?.tokenBreakdown?.prompt || 0,
completion: (log.metadata as any)?.tokenBreakdown?.completion || 0,
},
models: (log.metadata as any)?.models || {},
}
log.cost && Object.keys(log.cost as any).length > 0
? (log.cost as any)
: extractCostSummary(blockExecutions)
// Build workflow object from joined data
const workflow = {
const workflowSummary = {
id: log.workflowId,
name: log.workflowName,
description: log.workflowDescription,
@@ -329,67 +304,28 @@ export async function GET(request: NextRequest) {
return {
id: log.id,
workflowId: log.workflowId,
executionId: log.executionId,
executionId: params.details === 'full' ? log.executionId : undefined,
level: log.level,
message: log.message,
duration: log.totalDurationMs ? `${log.totalDurationMs}ms` : null,
trigger: log.trigger,
createdAt: log.startedAt.toISOString(),
files: log.files || undefined,
workflow: params.includeWorkflow ? workflow : undefined,
metadata: {
totalDuration: log.totalDurationMs,
cost: costSummary,
blockStats: {
total: log.blockCount,
success: log.successCount,
error: log.errorCount,
skipped: log.skippedCount,
},
traceSpans,
blockExecutions,
enhanced: true,
},
files: params.details === 'full' ? log.files || undefined : undefined,
workflow: workflowSummary,
executionData:
params.details === 'full'
? {
totalDuration: log.totalDurationMs,
traceSpans,
blockExecutions,
enhanced: true,
}
: undefined,
cost:
params.details === 'full'
? (costSummary as any)
: { total: (costSummary as any)?.total || 0 },
}
})
// Include block execution data if requested
if (params.includeBlocks) {
// Block executions are now extracted from stored trace spans in metadata
const blockLogsByExecution: Record<string, any[]> = {}
logs.forEach((log) => {
const storedTraceSpans = (log.metadata as any)?.traceSpans
if (storedTraceSpans && Array.isArray(storedTraceSpans)) {
blockLogsByExecution[log.executionId] =
extractBlockExecutionsFromTraceSpans(storedTraceSpans)
} else {
blockLogsByExecution[log.executionId] = []
}
})
// Add block logs to metadata
const logsWithBlocks = enhancedLogs.map((log) => ({
...log,
metadata: {
...log.metadata,
blockExecutions: blockLogsByExecution[log.executionId] || [],
},
}))
return NextResponse.json(
{
data: logsWithBlocks,
total: Number(count),
page: Math.floor(params.offset / params.limit) + 1,
pageSize: params.limit,
totalPages: Math.ceil(Number(count) / params.limit),
},
{ status: 200 }
)
}
// Return basic logs
return NextResponse.json(
{
data: enhancedLogs,

View File

@@ -21,8 +21,6 @@ import { invitation, member, organization, user, workspace, workspaceInvitation
const logger = createLogger('OrganizationInvitationsAPI')
export const dynamic = 'force-dynamic'
interface WorkspaceInvitation {
workspaceId: string
permission: 'admin' | 'write' | 'read'

View File

@@ -7,8 +7,6 @@ import { member, user, userStats } from '@/db/schema'
const logger = createLogger('OrganizationMemberAPI')
export const dynamic = 'force-dynamic'
/**
* GET /api/organizations/[id]/members/[memberId]
* Get individual organization member details

View File

@@ -13,8 +13,6 @@ import { invitation, member, organization, user, userStats } from '@/db/schema'
const logger = createLogger('OrganizationMembersAPI')
export const dynamic = 'force-dynamic'
/**
* GET /api/organizations/[id]/members
* Get organization members with optional usage data

View File

@@ -9,8 +9,6 @@ import { invitation, member, permissions, workspaceInvitation } from '@/db/schem
const logger = createLogger('OrganizationInvitationAcceptanceAPI')
export const dynamic = 'force-dynamic'
// Accept an organization invitation and any associated workspace invitations
export async function GET(req: NextRequest) {
const invitationId = req.nextUrl.searchParams.get('id')

View File

@@ -235,42 +235,8 @@ export async function POST(request: Request) {
error: result.error || 'Unknown error',
})
if (tool.transformError) {
try {
const errorResult = tool.transformError(result)
// Handle both string and Promise return types
if (typeof errorResult === 'string') {
throw new Error(errorResult)
}
// It's a Promise, await it
const transformedError = await errorResult
// If it's a string or has an error property, use it
if (typeof transformedError === 'string') {
throw new Error(transformedError)
}
if (
transformedError &&
typeof transformedError === 'object' &&
'error' in transformedError
) {
throw new Error(transformedError.error || 'Tool returned an error')
}
// Fallback
throw new Error('Tool returned an error')
} catch (transformError) {
logger.error(`[${requestId}] Error transformation failed for ${toolId}`, {
error:
transformError instanceof Error ? transformError.message : String(transformError),
})
if (transformError instanceof Error) {
throw transformError
}
throw new Error('Tool returned an error')
}
} else {
throw new Error('Tool returned an error')
}
// Let the main executeTool handle error transformation to avoid double transformation
throw new Error(result.error || 'Tool execution failed')
}
const endTime = new Date()

View File

@@ -3,9 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
export const dynamic = 'force-dynamic'
import { db } from '@/db'
import { workflow, workflowSchedule } from '@/db/schema'

View File

@@ -18,8 +18,6 @@ import { workflow, workflowSchedule } from '@/db/schema'
const logger = createLogger('ScheduledAPI')
export const dynamic = 'force-dynamic'
const ScheduleRequestSchema = z.object({
workflowId: z.string(),
blockId: z.string().optional(),

View File

@@ -80,7 +80,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
workspaceId: workspaceId,
name: `${templateData.name} (copy)`,
description: templateData.description,
state: templateData.state,
color: templateData.color,
userId: session.user.id,
createdAt: now,
@@ -158,9 +157,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}))
}
// Update the workflow with the corrected state
await tx.update(workflow).set({ state: updatedState }).where(eq(workflow.id, newWorkflowId))
// Insert blocks and edges
if (blockEntries.length > 0) {
await tx.insert(workflowBlocks).values(blockEntries)

View File

@@ -9,9 +9,6 @@ import { customTools } from '@/db/schema'
const logger = createLogger('CustomToolsAPI')
export const dynamic = 'force-dynamic'
// Define validation schema for custom tools
const CustomToolSchema = z.object({
tools: z.array(
z.object({

View File

@@ -1,10 +1,7 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { account } from '@/db/schema'
export const dynamic = 'force-dynamic'
@@ -18,46 +15,28 @@ export async function GET(request: NextRequest) {
logger.info(`[${requestId}] Google Drive file request received`)
try {
// Get the session
const session = await getSession()
// Check if the user is authenticated
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthenticated request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
// Get the credential ID and file ID from the query params
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const fileId = searchParams.get('fileId')
const workflowId = searchParams.get('workflowId') || undefined
if (!credentialId || !fileId) {
logger.warn(`[${requestId}] Missing required parameters`)
return NextResponse.json({ error: 'Credential ID and File ID are required' }, { status: 400 })
}
// Get the credential from the database
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`, { credentialId })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
// Check if the credential belongs to the user
if (credential.userId !== session.user.id) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
credentialUserId: credential.userId,
requestUserId: session.user.id,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
const authz = await authorizeCredentialUse(request, { credentialId: credentialId, workflowId })
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
@@ -66,7 +45,7 @@ export async function GET(request: NextRequest) {
// Fetch the file from Google Drive API
logger.info(`[${requestId}] Fetching file ${fileId} from Google Drive API`)
const response = await fetch(
`https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks`,
`https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks,shortcutDetails&supportsAllDrives=true`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
@@ -98,6 +77,34 @@ export async function GET(request: NextRequest) {
'application/vnd.google-apps.presentation': 'application/pdf', // Google Slides to PDF
}
// Resolve shortcuts transparently for UI stability
if (
file.mimeType === 'application/vnd.google-apps.shortcut' &&
file.shortcutDetails?.targetId
) {
const targetId = file.shortcutDetails.targetId
const shortcutResp = await fetch(
`https://www.googleapis.com/drive/v3/files/${targetId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks&supportsAllDrives=true`,
{
headers: { Authorization: `Bearer ${accessToken}` },
}
)
if (shortcutResp.ok) {
const targetFile = await shortcutResp.json()
file.id = targetFile.id
file.name = targetFile.name
file.mimeType = targetFile.mimeType
file.iconLink = targetFile.iconLink
file.webViewLink = targetFile.webViewLink
file.thumbnailLink = targetFile.thumbnailLink
file.createdTime = targetFile.createdTime
file.modifiedTime = targetFile.modifiedTime
file.size = targetFile.size
file.owners = targetFile.owners
file.exportLinks = targetFile.exportLinks
}
}
// If the file is a Google Docs, Sheets, or Slides file, we need to provide the export link
if (file.mimeType.startsWith('application/vnd.google-apps.')) {
const format = exportFormats[file.mimeType] || 'application/pdf'

View File

@@ -1,10 +1,8 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { account } from '@/db/schema'
export const dynamic = 'force-dynamic'
@@ -32,64 +30,48 @@ export async function GET(request: NextRequest) {
const credentialId = searchParams.get('credentialId')
const mimeType = searchParams.get('mimeType')
const query = searchParams.get('query') || ''
const folderId = searchParams.get('folderId') || searchParams.get('parentId') || ''
const workflowId = searchParams.get('workflowId') || undefined
if (!credentialId) {
logger.warn(`[${requestId}] Missing credential ID`)
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
// Get the credential from the database
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`, { credentialId })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
// Check if the credential belongs to the user
if (credential.userId !== session.user.id) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
credentialUserId: credential.userId,
requestUserId: session.user.id,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
// Authorize use of the credential (supports collaborator credentials via workflow)
const authz = await authorizeCredentialUse(request, { credentialId: credentialId!, workflowId })
if (!authz.ok || !authz.credentialOwnerUserId) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, authz)
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
credentialId!,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
// Build the query parameters for Google Drive API
let queryParams = 'trashed=false'
// Add mimeType filter if provided
// Build Drive 'q' expression safely
const qParts: string[] = ['trashed = false']
if (folderId) {
qParts.push(`'${folderId.replace(/'/g, "\\'")}' in parents`)
}
if (mimeType) {
// For Google Drive API, we need to use 'q' parameter for mimeType filtering
// Instead of using the mimeType parameter directly, we'll add it to the query
if (queryParams.includes('q=')) {
queryParams += ` and mimeType='${mimeType}'`
} else {
queryParams += `&q=mimeType='${mimeType}'`
}
qParts.push(`mimeType = '${mimeType.replace(/'/g, "\\'")}'`)
}
// Add search query if provided
if (query) {
if (queryParams.includes('q=')) {
queryParams += ` and name contains '${query}'`
} else {
queryParams += `&q=name contains '${query}'`
}
qParts.push(`name contains '${query.replace(/'/g, "\\'")}'`)
}
const q = encodeURIComponent(qParts.join(' and '))
// Fetch files from Google Drive API
// Fetch files from Google Drive API with shared drives support
const response = await fetch(
`https://www.googleapis.com/drive/v3/files?${queryParams}&fields=files(id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners)`,
`https://www.googleapis.com/drive/v3/files?q=${q}&supportsAllDrives=true&includeItemsFromAllDrives=true&spaces=drive&fields=files(id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,parents)`,
{
headers: {
Authorization: `Bearer ${accessToken}`,

View File

@@ -40,16 +40,20 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
// Get the credential from the database
const credentials = await db
// Get the credential from the database. Prefer session-owned credential, but
// if not found, resolve by credential ID to support collaborator-owned credentials.
let credentials = await db
.select()
.from(account)
.where(and(eq(account.id, credentialId), eq(account.userId, session.user.id)))
.limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`)
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`)
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
}
const credential = credentials[0]
@@ -60,7 +64,7 @@ export async function GET(request: NextRequest) {
)
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(credentialId, credential.userId, requestId)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })

View File

@@ -1,10 +1,7 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { account } from '@/db/schema'
export const dynamic = 'force-dynamic'
@@ -28,45 +25,26 @@ export async function GET(request: NextRequest) {
logger.info(`[${requestId}] Google Calendar calendars request received`)
try {
// Get the session
const session = await getSession()
// Check if the user is authenticated
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthenticated request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
// Get the credential ID from the query params
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const workflowId = searchParams.get('workflowId') || undefined
if (!credentialId) {
logger.warn(`[${requestId}] Missing credentialId parameter`)
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
}
// Get the credential from the database
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`, { credentialId })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credential = credentials[0]
// Check if the credential belongs to the user
if (credential.userId !== session.user.id) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
credentialUserId: credential.userId,
requestUserId: session.user.id,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
const authz = await authorizeCredentialUse(request, { credentialId, workflowId })
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })

View File

@@ -110,6 +110,7 @@ export async function GET(request: Request) {
const accessToken = url.searchParams.get('accessToken')
const providedCloudId = url.searchParams.get('cloudId')
const query = url.searchParams.get('query') || ''
const projectId = url.searchParams.get('projectId') || ''
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
@@ -131,36 +132,70 @@ export async function GET(request: Request) {
params.append('query', query)
}
// Use the correct Jira Cloud OAuth endpoint structure
const apiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/picker?${params.toString()}`
let data: any
logger.info(`Fetching Jira issue suggestions from: ${apiUrl}`)
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
logger.info('Response status:', response.status, response.statusText)
if (!response.ok) {
logger.error(`Jira API error: ${response.status} ${response.statusText}`)
let errorMessage
try {
const errorData = await response.json()
logger.error('Error details:', errorData)
errorMessage = errorData.message || `Failed to fetch issue suggestions (${response.status})`
} catch (_e) {
errorMessage = `Failed to fetch issue suggestions: ${response.status} ${response.statusText}`
if (query) {
const apiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/picker?${params.toString()}`
logger.info(`Fetching Jira issue suggestions from: ${apiUrl}`)
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
logger.info('Response status:', response.status, response.statusText)
if (!response.ok) {
logger.error(`Jira API error: ${response.status} ${response.statusText}`)
let errorMessage
try {
const errorData = await response.json()
logger.error('Error details:', errorData)
errorMessage =
errorData.message || `Failed to fetch issue suggestions (${response.status})`
} catch (_e) {
errorMessage = `Failed to fetch issue suggestions: ${response.status} ${response.statusText}`
}
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
return NextResponse.json({ error: errorMessage }, { status: response.status })
data = await response.json()
} else if (projectId) {
// When no query, list latest issues for the selected project using Search API
const searchParams = new URLSearchParams()
searchParams.append('jql', `project=${projectId} ORDER BY updated DESC`)
searchParams.append('maxResults', '25')
searchParams.append('fields', 'summary,key')
const searchUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search?${searchParams.toString()}`
logger.info(`Fetching Jira issues via search from: ${searchUrl}`)
const response = await fetch(searchUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
if (!response.ok) {
let errorMessage
try {
const errorData = await response.json()
logger.error('Jira Search API error details:', errorData)
errorMessage =
errorData.errorMessages?.[0] || `Failed to fetch issues (${response.status})`
} catch (_e) {
errorMessage = `Failed to fetch issues: ${response.status} ${response.statusText}`
}
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const searchData = await response.json()
const issues = (searchData.issues || []).map((it: any) => ({
key: it.key,
summary: it.fields?.summary || it.key,
}))
data = { sections: [{ issues }], cloudId }
} else {
data = { sections: [], cloudId }
}
const data = await response.json()
return NextResponse.json({
...data,
cloudId, // Return the cloudId so it can be cached

View File

@@ -0,0 +1,147 @@
import { NextResponse } from 'next/server'
import { Logger } from '@/lib/logs/console/logger'
import { getJiraCloudId } from '@/tools/jira/utils'
export const dynamic = 'force-dynamic'
const logger = new Logger('JiraUpdateAPI')
export async function PUT(request: Request) {
try {
const {
domain,
accessToken,
issueKey,
summary,
title, // Support both summary and title for backwards compatibility
description,
status,
priority,
assignee,
cloudId: providedCloudId,
} = await request.json()
// Validate required parameters
if (!domain) {
logger.error('Missing domain in request')
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
logger.error('Missing access token in request')
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!issueKey) {
logger.error('Missing issue key in request')
return NextResponse.json({ error: 'Issue key is required' }, { status: 400 })
}
// Use provided cloudId or fetch it if not provided
const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken))
logger.info('Using cloud ID:', cloudId)
// Build the URL using cloudId for Jira API
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${issueKey}`
logger.info('Updating Jira issue at:', url)
// Map the summary from either summary or title field
const summaryValue = summary || title
const fields: Record<string, any> = {}
if (summaryValue) {
fields.summary = summaryValue
}
if (description) {
fields.description = {
type: 'doc',
version: 1,
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: description,
},
],
},
],
}
}
if (status) {
fields.status = {
name: status,
}
}
if (priority) {
fields.priority = {
name: priority,
}
}
if (assignee) {
fields.assignee = {
id: assignee,
}
}
const body = { fields }
// Make the request to Jira API
const response = await fetch(url, {
method: 'PUT',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Jira API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: `Jira API error: ${response.status} ${response.statusText}`, details: errorText },
{ status: response.status }
)
}
// Note: Jira update API typically returns 204 No Content on success
const responseData = response.status === 204 ? {} : await response.json()
logger.info('Successfully updated Jira issue:', issueKey)
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
issueKey: responseData.key || issueKey,
summary: responseData.fields?.summary || 'Issue updated',
success: true,
},
})
} catch (error: any) {
logger.error('Error updating Jira issue:', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
return NextResponse.json(
{
error: error instanceof Error ? error.message : 'Internal server error',
success: false,
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,162 @@
import { NextResponse } from 'next/server'
import { Logger } from '@/lib/logs/console/logger'
import { getJiraCloudId } from '@/tools/jira/utils'
export const dynamic = 'force-dynamic'
const logger = new Logger('JiraWriteAPI')
export async function POST(request: Request) {
try {
const {
domain,
accessToken,
projectId,
summary,
description,
priority,
assignee,
cloudId: providedCloudId,
issueType,
parent,
} = await request.json()
// Validate required parameters
if (!domain) {
logger.error('Missing domain in request')
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!accessToken) {
logger.error('Missing access token in request')
return NextResponse.json({ error: 'Access token is required' }, { status: 400 })
}
if (!projectId) {
logger.error('Missing project ID in request')
return NextResponse.json({ error: 'Project ID is required' }, { status: 400 })
}
if (!summary) {
logger.error('Missing summary in request')
return NextResponse.json({ error: 'Summary is required' }, { status: 400 })
}
if (!issueType) {
logger.error('Missing issue type in request')
return NextResponse.json({ error: 'Issue type is required' }, { status: 400 })
}
// Use provided cloudId or fetch it if not provided
const cloudId = providedCloudId || (await getJiraCloudId(domain, accessToken))
logger.info('Using cloud ID:', cloudId)
// Build the URL using cloudId for Jira API
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue`
logger.info('Creating Jira issue at:', url)
// Construct fields object with only the necessary fields
const fields: Record<string, any> = {
project: {
id: projectId,
},
issuetype: {
name: issueType,
},
summary: summary,
}
// Only add description if it exists
if (description) {
fields.description = {
type: 'doc',
version: 1,
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: description,
},
],
},
],
}
}
// Only add parent if it exists
if (parent) {
fields.parent = parent
}
// Only add priority if it exists
if (priority) {
fields.priority = {
name: priority,
}
}
// Only add assignee if it exists
if (assignee) {
fields.assignee = {
id: assignee,
}
}
const body = { fields }
// Make the request to Jira API
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Jira API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: `Jira API error: ${response.status} ${response.statusText}`, details: errorText },
{ status: response.status }
)
}
const responseData = await response.json()
logger.info('Successfully created Jira issue:', responseData.key)
return NextResponse.json({
success: true,
output: {
ts: new Date().toISOString(),
issueKey: responseData.key || 'unknown',
summary: responseData.fields?.summary || 'Issue created',
success: true,
url: `https://${domain}/browse/${responseData.key}`,
},
})
} catch (error: any) {
logger.error('Error creating Jira issue:', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
return NextResponse.json(
{
error: error instanceof Error ? error.message : 'Internal server error',
success: false,
},
{ status: 500 }
)
}
}

View File

@@ -1,7 +1,7 @@
import type { Project } from '@linear/sdk'
import { LinearClient } from '@linear/sdk'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -11,7 +11,6 @@ const logger = createLogger('LinearProjectsAPI')
export async function POST(request: Request) {
try {
const session = await getSession()
const body = await request.json()
const { credential, teamId, workflowId } = body
@@ -20,15 +19,25 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Credential and teamId are required' }, { status: 400 })
}
const userId = session?.user?.id || ''
if (!userId) {
logger.error('No user ID found in session')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
const requestId = crypto.randomUUID().slice(0, 8)
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(credential, userId, workflowId)
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', { credentialId: credential, userId })
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{
error: 'Could not retrieve access token',

View File

@@ -1,7 +1,7 @@
import type { Team } from '@linear/sdk'
import { LinearClient } from '@linear/sdk'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -11,7 +11,7 @@ const logger = createLogger('LinearTeamsAPI')
export async function POST(request: Request) {
try {
const session = await getSession()
const requestId = crypto.randomUUID().slice(0, 8)
const body = await request.json()
const { credential, workflowId } = body
@@ -20,15 +20,24 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
const userId = session?.user?.id || ''
if (!userId) {
logger.error('No user ID found in session')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(credential, userId, workflowId)
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', { credentialId: credential, userId })
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{
error: 'Could not retrieve access token',

View File

@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -9,7 +9,6 @@ const logger = createLogger('TeamsChannelsAPI')
export async function POST(request: Request) {
try {
const session = await getSession()
const body = await request.json()
const { credential, teamId, workflowId } = body
@@ -25,18 +24,24 @@ export async function POST(request: Request) {
}
try {
// Get the userId either from the session or from the workflowId
const userId = session?.user?.id || ''
if (!userId) {
logger.error('No user ID found in session')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(credential, userId, workflowId)
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
'TeamsChannelsAPI'
)
if (!accessToken) {
logger.error('Failed to get access token', { credentialId: credential, userId })
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{
error: 'Could not retrieve access token',

View File

@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -115,10 +115,10 @@ const getChatDisplayName = async (
export async function POST(request: Request) {
try {
const session = await getSession()
const requestId = crypto.randomUUID().slice(0, 8)
const body = await request.json()
const { credential } = body
const { credential, workflowId } = body
if (!credential) {
logger.error('Missing credential in request')
@@ -126,18 +126,24 @@ export async function POST(request: Request) {
}
try {
// Get the userId either from the session or from the workflowId
const userId = session?.user?.id || ''
if (!userId) {
logger.error('No user ID found in session')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(credential, userId, body.workflowId)
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
'TeamsChatsAPI'
)
if (!accessToken) {
logger.error('Failed to get access token', { credentialId: credential, userId })
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json({ error: 'Could not retrieve access token' }, { status: 401 })
}

View File

@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -9,7 +9,6 @@ const logger = createLogger('TeamsTeamsAPI')
export async function POST(request: Request) {
try {
const session = await getSession()
const body = await request.json()
const { credential, workflowId } = body
@@ -20,18 +19,26 @@ export async function POST(request: Request) {
}
try {
// Get the userId either from the session or from the workflowId
const userId = session?.user?.id || ''
if (!userId) {
logger.error('No user ID found in session')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
const requestId = crypto.randomUUID().slice(0, 8)
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(credential, userId, workflowId)
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
'TeamsTeamsAPI'
)
if (!accessToken) {
logger.error('Failed to get access token', { credentialId: credential, userId })
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{
error: 'Could not retrieve access token',

View File

@@ -1,7 +1,10 @@
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { db } from '@/db'
import { account } from '@/db/schema'
export const dynamic = 'force-dynamic'
@@ -26,22 +29,30 @@ export async function GET(request: Request) {
}
try {
// Get the userId from the session
const userId = session?.user?.id || ''
// Ensure we have a session for permission checks
const sessionUserId = session?.user?.id || ''
if (!userId) {
if (!sessionUserId) {
logger.error('No user ID found in session')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
// Resolve the credential owner to support collaborator-owned credentials
const creds = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
if (!creds.length) {
logger.warn('Credential not found', { credentialId })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const credentialOwnerUserId = creds[0].userId
const accessToken = await refreshAccessTokenIfNeeded(
credentialId,
userId,
credentialOwnerUserId,
crypto.randomUUID().slice(0, 8)
)
if (!accessToken) {
logger.error('Failed to get access token', { credentialId, userId })
logger.error('Failed to get access token', { credentialId, userId: credentialOwnerUserId })
return NextResponse.json(
{
error: 'Could not retrieve access token',

View File

@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { createLogger } from '@/lib/logs/console/logger'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -17,7 +17,7 @@ interface SlackChannel {
export async function POST(request: Request) {
try {
const session = await getSession()
const requestId = crypto.randomUUID().slice(0, 8)
const body = await request.json()
const { credential, workflowId } = body
@@ -34,15 +34,23 @@ export async function POST(request: Request) {
isBotToken = true
logger.info('Using direct bot token for Slack API')
} else {
const userId = session?.user?.id || ''
if (!userId) {
logger.error('No user ID found in session')
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const resolvedToken = await refreshAccessTokenIfNeeded(credential, userId, workflowId)
const resolvedToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!resolvedToken) {
logger.error('Failed to get access token', { credentialId: credential, userId })
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{
error: 'Could not retrieve access token',

View File

@@ -0,0 +1,53 @@
import { type NextRequest, NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import type { ThinkingToolParams, ThinkingToolResponse } from '@/tools/thinking/types'
const logger = createLogger('ThinkingToolAPI')
export const dynamic = 'force-dynamic'
/**
* POST - Process a thinking tool request
* Simply acknowledges the thought by returning it in the output
*/
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const body: ThinkingToolParams = await request.json()
logger.info(`[${requestId}] Processing thinking tool request`)
// Validate the required parameter
if (!body.thought || typeof body.thought !== 'string') {
logger.warn(`[${requestId}] Missing or invalid 'thought' parameter`)
return NextResponse.json(
{
success: false,
error: 'The thought parameter is required and must be a string',
},
{ status: 400 }
)
}
// Simply acknowledge the thought by returning it in the output
const response: ThinkingToolResponse = {
success: true,
output: {
acknowledgedThought: body.thought,
},
}
logger.info(`[${requestId}] Thinking tool processed successfully`)
return NextResponse.json(response)
} catch (error) {
logger.error(`[${requestId}] Error processing thinking tool:`, error)
return NextResponse.json(
{
success: false,
error: 'Failed to process thinking tool request',
},
{ status: 500 }
)
}
}

View File

@@ -7,8 +7,6 @@ import { isOrganizationOwnerOrAdmin } from '@/lib/permissions/utils'
const logger = createLogger('UnifiedUsageLimitsAPI')
export const dynamic = 'force-dynamic'
/**
* Unified Usage Limits Endpoint
* GET/PUT /api/usage-limits?context=user|member&userId=<id>&organizationId=<id>

View File

@@ -2,9 +2,6 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
export const dynamic = 'force-dynamic'
import { db } from '@/db'
import { apiKey } from '@/db/schema'

View File

@@ -9,8 +9,6 @@ import { apiKey } from '@/db/schema'
const logger = createLogger('ApiKeysAPI')
export const dynamic = 'force-dynamic'
// GET /api/users/me/api-keys - Get all API keys for the current user
export async function GET(request: NextRequest) {
try {

View File

@@ -0,0 +1,120 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { user } from '@/db/schema'
const logger = createLogger('UpdateUserProfileAPI')
// Schema for updating user profile
const UpdateProfileSchema = z
.object({
name: z.string().min(1, 'Name is required').optional(),
})
.refine((data) => data.name !== undefined, {
message: 'Name field must be provided',
})
export const dynamic = 'force-dynamic'
export async function PATCH(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized profile update attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const body = await request.json()
const validatedData = UpdateProfileSchema.parse(body)
// Build update object
const updateData: any = { updatedAt: new Date() }
if (validatedData.name !== undefined) updateData.name = validatedData.name
// Update user profile
const [updatedUser] = await db
.update(user)
.set(updateData)
.where(eq(user.id, userId))
.returning()
if (!updatedUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
logger.info(`[${requestId}] User profile updated`, {
userId,
updatedFields: Object.keys(validatedData),
})
return NextResponse.json({
success: true,
user: {
id: updatedUser.id,
name: updatedUser.name,
email: updatedUser.email,
image: updatedUser.image,
},
})
} catch (error: any) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid profile data`, {
errors: error.errors,
})
return NextResponse.json(
{ error: 'Invalid profile data', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Profile update error`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// GET endpoint to fetch current user profile
export async function GET() {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized profile fetch attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const [userRecord] = await db
.select({
id: user.id,
name: user.name,
email: user.email,
image: user.image,
emailVerified: user.emailVerified,
})
.from(user)
.where(eq(user.id, userId))
.limit(1)
if (!userRecord) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
return NextResponse.json({
user: userRecord,
})
} catch (error: any) {
logger.error(`[${requestId}] Profile fetch error`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -16,7 +16,6 @@ const SettingsSchema = z.object({
autoPan: z.boolean().optional(),
consoleExpandedByDefault: z.boolean().optional(),
telemetryEnabled: z.boolean().optional(),
telemetryNotifiedUser: z.boolean().optional(),
emailPreferences: z
.object({
unsubscribeAll: z.boolean().optional(),
@@ -35,7 +34,6 @@ const defaultSettings = {
autoPan: true,
consoleExpandedByDefault: true,
telemetryEnabled: true,
telemetryNotifiedUser: false,
emailPreferences: {},
}
@@ -69,7 +67,6 @@ export async function GET() {
autoPan: userSettings.autoPan,
consoleExpandedByDefault: userSettings.consoleExpandedByDefault,
telemetryEnabled: userSettings.telemetryEnabled,
telemetryNotifiedUser: userSettings.telemetryNotifiedUser,
emailPreferences: userSettings.emailPreferences ?? {},
},
},

View File

@@ -3,9 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
export const dynamic = 'force-dynamic'
import { db } from '@/db'
import { member, organization, subscription } from '@/db/schema'

View File

@@ -2,9 +2,6 @@ import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
export const dynamic = 'force-dynamic'
import { createErrorResponse } from '@/app/api/workflows/utils'
import { db } from '@/db'
import { apiKey as apiKeyTable, subscription } from '@/db/schema'

View File

@@ -29,29 +29,63 @@ export async function GET(request: NextRequest) {
const workflowId = searchParams.get('workflowId')
const blockId = searchParams.get('blockId')
if (workflowId && blockId) {
// Collaborative-aware path: allow collaborators with read access to view webhooks
// Fetch workflow to verify access
const wf = await db
.select({ id: workflow.id, userId: workflow.userId, workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!wf.length) {
logger.warn(`[${requestId}] Workflow not found: ${workflowId}`)
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
const wfRecord = wf[0]
let canRead = wfRecord.userId === session.user.id
if (!canRead && wfRecord.workspaceId) {
const permission = await getUserEntityPermissions(
session.user.id,
'workspace',
wfRecord.workspaceId
)
canRead = permission === 'read' || permission === 'write' || permission === 'admin'
}
if (!canRead) {
logger.warn(
`[${requestId}] User ${session.user.id} denied permission to read webhooks for workflow ${workflowId}`
)
return NextResponse.json({ webhooks: [] }, { status: 200 })
}
const webhooks = await db
.select({
webhook: webhook,
workflow: {
id: workflow.id,
name: workflow.name,
},
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId)))
logger.info(
`[${requestId}] Retrieved ${webhooks.length} webhooks for workflow ${workflowId} block ${blockId}`
)
return NextResponse.json({ webhooks }, { status: 200 })
}
if (workflowId && !blockId) {
// For now, allow the call but return empty results to avoid breaking the UI
return NextResponse.json({ webhooks: [] }, { status: 200 })
}
logger.debug(`[${requestId}] Fetching webhooks for user ${session.user.id}`, {
filteredByWorkflow: !!workflowId,
filteredByBlock: !!blockId,
})
// Create where condition
const conditions = [eq(workflow.userId, session.user.id)]
if (workflowId) {
conditions.push(eq(webhook.workflowId, workflowId))
}
if (blockId) {
conditions.push(eq(webhook.blockId, blockId))
}
const whereCondition = conditions.length > 1 ? and(...conditions) : conditions[0]
// Default: list webhooks owned by the session user
logger.debug(`[${requestId}] Fetching user-owned webhooks for ${session.user.id}`)
const webhooks = await db
.select({
webhook: webhook,
@@ -62,9 +96,9 @@ export async function GET(request: NextRequest) {
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(whereCondition)
.where(eq(workflow.userId, session.user.id))
logger.info(`[${requestId}] Retrieved ${webhooks.length} webhooks for user ${session.user.id}`)
logger.info(`[${requestId}] Retrieved ${webhooks.length} user-owned webhooks`)
return NextResponse.json({ webhooks }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching webhooks`, error)
@@ -95,17 +129,36 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
}
// For credential-based providers (those that use polling instead of webhooks),
// generate a dummy path if none provided since they don't use actual webhook URLs
// but still need database entries for the polling services to find them
// Determine final path with special handling for credential-based providers
// to avoid generating a new path on every save.
let finalPath = path
if (!path || path.trim() === '') {
// List of providers that use credential-based polling instead of webhooks
const credentialBasedProviders = ['gmail', 'outlook']
const credentialBasedProviders = ['gmail', 'outlook']
const isCredentialBased = credentialBasedProviders.includes(provider)
if (credentialBasedProviders.includes(provider)) {
finalPath = `${provider}-${crypto.randomUUID()}`
logger.info(`[${requestId}] Generated dummy path for ${provider} trigger: ${finalPath}`)
// If path is missing
if (!finalPath || finalPath.trim() === '') {
if (isCredentialBased) {
// Try to reuse existing path for this workflow+block if one exists
if (blockId) {
const existingForBlock = await db
.select({ id: webhook.id, path: webhook.path })
.from(webhook)
.where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId)))
.limit(1)
if (existingForBlock.length > 0) {
finalPath = existingForBlock[0].path
logger.info(
`[${requestId}] Reusing existing dummy path for ${provider} trigger: ${finalPath}`
)
}
}
// If still no path, generate a new dummy path (first-time save)
if (!finalPath || finalPath.trim() === '') {
finalPath = `${provider}-${crypto.randomUUID()}`
logger.info(`[${requestId}] Generated dummy path for ${provider} trigger: ${finalPath}`)
}
} else {
logger.warn(`[${requestId}] Missing path for webhook creation`, {
hasWorkflowId: !!workflowId,
@@ -160,29 +213,43 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Check if a webhook with the same path already exists
const existingWebhooks = await db
.select({ id: webhook.id, workflowId: webhook.workflowId })
.from(webhook)
.where(eq(webhook.path, finalPath))
.limit(1)
// Determine existing webhook to update (prefer by workflow+block for credential-based providers)
let targetWebhookId: string | null = null
if (isCredentialBased && blockId) {
const existingForBlock = await db
.select({ id: webhook.id })
.from(webhook)
.where(and(eq(webhook.workflowId, workflowId), eq(webhook.blockId, blockId)))
.limit(1)
if (existingForBlock.length > 0) {
targetWebhookId = existingForBlock[0].id
}
}
if (!targetWebhookId) {
const existingByPath = await db
.select({ id: webhook.id, workflowId: webhook.workflowId })
.from(webhook)
.where(eq(webhook.path, finalPath))
.limit(1)
if (existingByPath.length > 0) {
// If a webhook with the same path exists but belongs to a different workflow, return an error
if (existingByPath[0].workflowId !== workflowId) {
logger.warn(`[${requestId}] Webhook path conflict: ${finalPath}`)
return NextResponse.json(
{ error: 'Webhook path already exists.', code: 'PATH_EXISTS' },
{ status: 409 }
)
}
targetWebhookId = existingByPath[0].id
}
}
let savedWebhook: any = null // Variable to hold the result of save/update
// If a webhook with the same path exists but belongs to a different workflow, return an error
if (existingWebhooks.length > 0 && existingWebhooks[0].workflowId !== workflowId) {
logger.warn(`[${requestId}] Webhook path conflict: ${finalPath}`)
return NextResponse.json(
{ error: 'Webhook path already exists.', code: 'PATH_EXISTS' },
{ status: 409 }
)
}
// Use the original provider config - Gmail/Outlook configuration functions will inject userId automatically
const finalProviderConfig = providerConfig
// If a webhook with the same path and workflowId exists, update it
if (existingWebhooks.length > 0 && existingWebhooks[0].workflowId === workflowId) {
if (targetWebhookId) {
logger.info(`[${requestId}] Updating existing webhook for path: ${finalPath}`)
const updatedResult = await db
.update(webhook)
@@ -193,7 +260,7 @@ export async function POST(request: NextRequest) {
isActive: true,
updatedAt: new Date(),
})
.where(eq(webhook.id, existingWebhooks[0].id))
.where(eq(webhook.id, targetWebhookId))
.returning()
savedWebhook = updatedResult[0]
} else {
@@ -262,7 +329,8 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`)
try {
const { configureGmailPolling } = await import('@/lib/webhooks/utils')
const success = await configureGmailPolling(userId, savedWebhook, requestId)
// Pass workflow owner for backward-compat fallback (utils prefers credentialId if present)
const success = await configureGmailPolling(workflowRecord.userId, savedWebhook, requestId)
if (!success) {
logger.error(`[${requestId}] Failed to configure Gmail polling`)
@@ -296,7 +364,12 @@ export async function POST(request: NextRequest) {
)
try {
const { configureOutlookPolling } = await import('@/lib/webhooks/utils')
const success = await configureOutlookPolling(userId, savedWebhook, requestId)
// Pass workflow owner for backward-compat fallback (utils prefers credentialId if present)
const success = await configureOutlookPolling(
workflowRecord.userId,
savedWebhook,
requestId
)
if (!success) {
logger.error(`[${requestId}] Failed to configure Outlook polling`)
@@ -323,7 +396,7 @@ export async function POST(request: NextRequest) {
}
// --- End Outlook specific logic ---
const status = existingWebhooks.length > 0 ? 200 : 201
const status = targetWebhookId ? 200 : 201
return NextResponse.json({ webhook: savedWebhook }, { status })
} catch (error: any) {
logger.error(`[${requestId}] Error creating/updating webhook`, {
@@ -352,12 +425,15 @@ async function createAirtableWebhookSubscription(
return // Cannot proceed without base/table IDs
}
const accessToken = await getOAuthToken(userId, 'airtable') // Use 'airtable' as the providerId key
const accessToken = await getOAuthToken(userId, 'airtable')
if (!accessToken) {
logger.warn(
`[${requestId}] Could not retrieve Airtable access token for user ${userId}. Cannot create webhook in Airtable.`
)
return
// Instead of silently returning, throw an error with clear user guidance
throw new Error(
'Airtable account connection required. Please connect your Airtable account in the trigger configuration and try again.'
)
}
const requestOrigin = new URL(request.url).origin

View File

@@ -7,7 +7,6 @@ import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest, mockExecutionDependencies } from '@/app/api/__test-utils__/utils'
// Define mock functions at the top level to be used in mocks
const hasProcessedMessageMock = vi.fn().mockResolvedValue(false)
const markMessageAsProcessedMock = vi.fn().mockResolvedValue(true)
const closeRedisConnectionMock = vi.fn().mockResolvedValue(undefined)
@@ -33,7 +32,6 @@ const executeMock = vi.fn().mockResolvedValue({
},
})
// Mock the DB schema objects
const webhookMock = {
id: 'webhook-id-column',
path: 'path-column',
@@ -43,10 +41,6 @@ const webhookMock = {
}
const workflowMock = { id: 'workflow-id-column' }
// Mock global timers
vi.useFakeTimers()
// Mock modules at file scope before any tests
vi.mock('@/lib/redis', () => ({
hasProcessedMessage: hasProcessedMessageMock,
markMessageAsProcessed: markMessageAsProcessedMock,
@@ -77,19 +71,6 @@ vi.mock('@/executor', () => ({
})),
}))
// Mock setTimeout and other timer functions
vi.mock('timers', () => {
return {
setTimeout: (callback: any) => {
// Immediately invoke the callback
callback()
// Return a fake timer id
return 123
},
}
})
// Mock the database and schema
vi.mock('@/db', () => {
const dbMock = {
select: vi.fn().mockImplementation((columns) => ({
@@ -128,11 +109,9 @@ describe('Webhook Trigger API Route', () => {
beforeEach(() => {
vi.resetModules()
vi.resetAllMocks()
vi.clearAllTimers()
mockExecutionDependencies()
// Mock services/queue for rate limiting
vi.doMock('@/services/queue', () => ({
RateLimiter: vi.fn().mockImplementation(() => ({
checkRateLimit: vi.fn().mockResolvedValue({
@@ -284,10 +263,340 @@ describe('Webhook Trigger API Route', () => {
expect(text).toMatch(/not found/i) // Response should contain "not found" message
})
/**
* Test Slack-specific webhook handling
* Verifies that Slack signature verification is performed
*/
// TODO: Fix failing test - returns 500 instead of 200
// it('should handle Slack webhooks with signature verification', async () => { ... })
describe('Generic Webhook Authentication', () => {
const setupGenericWebhook = async (config: Record<string, any>) => {
const { db } = await import('@/db')
const limitMock = vi.fn().mockReturnValue([
{
webhook: {
id: 'generic-webhook-id',
provider: 'generic',
path: 'test-path',
isActive: true,
providerConfig: config,
workflowId: 'test-workflow-id',
},
workflow: {
id: 'test-workflow-id',
userId: 'test-user-id',
name: 'Test Workflow',
},
},
])
const whereMock = vi.fn().mockReturnValue({ limit: limitMock })
const innerJoinMock = vi.fn().mockReturnValue({ where: whereMock })
const fromMock = vi.fn().mockReturnValue({ innerJoin: innerJoinMock })
const subscriptionLimitMock = vi.fn().mockReturnValue([{ plan: 'pro' }])
const subscriptionWhereMock = vi.fn().mockReturnValue({ limit: subscriptionLimitMock })
const subscriptionFromMock = vi.fn().mockReturnValue({ where: subscriptionWhereMock })
// @ts-ignore - mocking the query chain
db.select.mockImplementation((columns: any) => {
if (columns.plan) {
return { from: subscriptionFromMock }
}
return { from: fromMock }
})
}
/**
* Test generic webhook without authentication (default behavior)
*/
it('should process generic webhook without authentication', async () => {
await setupGenericWebhook({ requireAuth: false })
const req = createMockRequest('POST', { event: 'test', id: 'test-123' })
const params = Promise.resolve({ path: 'test-path' })
vi.doMock('@trigger.dev/sdk/v3', () => ({
tasks: {
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
},
}))
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
// Authentication passed if we don't get 401
expect(response.status).not.toBe(401)
})
/**
* Test generic webhook with Bearer token authentication (no custom header)
*/
it('should authenticate with Bearer token when no custom header is configured', async () => {
await setupGenericWebhook({
requireAuth: true,
token: 'test-token-123',
// No secretHeaderName - should default to Bearer
})
const headers = {
'Content-Type': 'application/json',
Authorization: 'Bearer test-token-123',
}
const req = createMockRequest('POST', { event: 'bearer.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
vi.doMock('@trigger.dev/sdk/v3', () => ({
tasks: {
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
},
}))
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
// Authentication passed if we don't get 401
expect(response.status).not.toBe(401)
})
/**
* Test generic webhook with custom header authentication
*/
it('should authenticate with custom header when configured', async () => {
await setupGenericWebhook({
requireAuth: true,
token: 'secret-token-456',
secretHeaderName: 'X-Custom-Auth',
})
const headers = {
'Content-Type': 'application/json',
'X-Custom-Auth': 'secret-token-456',
}
const req = createMockRequest('POST', { event: 'custom.header.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
vi.doMock('@trigger.dev/sdk/v3', () => ({
tasks: {
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
},
}))
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
// Authentication passed if we don't get 401
expect(response.status).not.toBe(401)
})
/**
* Test case insensitive Bearer token authentication
*/
it('should handle case insensitive Bearer token authentication', async () => {
await setupGenericWebhook({
requireAuth: true,
token: 'case-test-token',
})
vi.doMock('@trigger.dev/sdk/v3', () => ({
tasks: {
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
},
}))
const testCases = [
'Bearer case-test-token',
'bearer case-test-token',
'BEARER case-test-token',
'BeArEr case-test-token',
]
for (const authHeader of testCases) {
const headers = {
'Content-Type': 'application/json',
Authorization: authHeader,
}
const req = createMockRequest('POST', { event: 'case.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
// Authentication passed if we don't get 401
expect(response.status).not.toBe(401)
}
})
/**
* Test case insensitive custom header authentication
*/
it('should handle case insensitive custom header authentication', async () => {
await setupGenericWebhook({
requireAuth: true,
token: 'custom-token-789',
secretHeaderName: 'X-Secret-Key',
})
vi.doMock('@trigger.dev/sdk/v3', () => ({
tasks: {
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
},
}))
const testCases = ['X-Secret-Key', 'x-secret-key', 'X-SECRET-KEY', 'x-Secret-Key']
for (const headerName of testCases) {
const headers = {
'Content-Type': 'application/json',
[headerName]: 'custom-token-789',
}
const req = createMockRequest('POST', { event: 'custom.case.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
// Authentication passed if we don't get 401
expect(response.status).not.toBe(401)
}
})
/**
* Test rejection of wrong Bearer token
*/
it('should reject wrong Bearer token', async () => {
await setupGenericWebhook({
requireAuth: true,
token: 'correct-token',
})
const headers = {
'Content-Type': 'application/json',
Authorization: 'Bearer wrong-token',
}
const req = createMockRequest('POST', { event: 'wrong.token.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
expect(response.status).toBe(401)
expect(await response.text()).toContain('Unauthorized - Invalid authentication token')
expect(processWebhookMock).not.toHaveBeenCalled()
})
/**
* Test rejection of wrong custom header token
*/
it('should reject wrong custom header token', async () => {
await setupGenericWebhook({
requireAuth: true,
token: 'correct-custom-token',
secretHeaderName: 'X-Auth-Key',
})
const headers = {
'Content-Type': 'application/json',
'X-Auth-Key': 'wrong-custom-token',
}
const req = createMockRequest('POST', { event: 'wrong.custom.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
expect(response.status).toBe(401)
expect(await response.text()).toContain('Unauthorized - Invalid authentication token')
expect(processWebhookMock).not.toHaveBeenCalled()
})
/**
* Test rejection of missing authentication
*/
it('should reject missing authentication when required', async () => {
await setupGenericWebhook({
requireAuth: true,
token: 'required-token',
})
const req = createMockRequest('POST', { event: 'no.auth.test' })
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
expect(response.status).toBe(401)
expect(await response.text()).toContain('Unauthorized - Invalid authentication token')
expect(processWebhookMock).not.toHaveBeenCalled()
})
/**
* Test exclusivity - Bearer token should be rejected when custom header is configured
*/
it('should reject Bearer token when custom header is configured', async () => {
await setupGenericWebhook({
requireAuth: true,
token: 'exclusive-token',
secretHeaderName: 'X-Only-Header',
})
const headers = {
'Content-Type': 'application/json',
Authorization: 'Bearer exclusive-token', // Correct token but wrong header type
}
const req = createMockRequest('POST', { event: 'exclusivity.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
expect(response.status).toBe(401)
expect(await response.text()).toContain('Unauthorized - Invalid authentication token')
expect(processWebhookMock).not.toHaveBeenCalled()
})
/**
* Test wrong custom header name is rejected
*/
it('should reject wrong custom header name', async () => {
await setupGenericWebhook({
requireAuth: true,
token: 'correct-token',
secretHeaderName: 'X-Expected-Header',
})
const headers = {
'Content-Type': 'application/json',
'X-Wrong-Header': 'correct-token', // Correct token but wrong header name
}
const req = createMockRequest('POST', { event: 'wrong.header.name.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
expect(response.status).toBe(401)
expect(await response.text()).toContain('Unauthorized - Invalid authentication token')
expect(processWebhookMock).not.toHaveBeenCalled()
})
/**
* Test authentication required but no token configured
*/
it('should reject when auth is required but no token is configured', async () => {
await setupGenericWebhook({
requireAuth: true,
// No token configured
})
const headers = {
'Content-Type': 'application/json',
Authorization: 'Bearer any-token',
}
const req = createMockRequest('POST', { event: 'no.token.config.test' }, headers)
const params = Promise.resolve({ path: 'test-path' })
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
const response = await POST(req, { params })
expect(response.status).toBe(401)
expect(await response.text()).toContain(
'Unauthorized - Authentication required but not configured'
)
expect(processWebhookMock).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,6 +1,7 @@
import { tasks } from '@trigger.dev/sdk/v3'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { checkServerSideUsageLimits } from '@/lib/billing'
import { createLogger } from '@/lib/logs/console/logger'
import {
handleSlackChallenge,
@@ -100,20 +101,41 @@ export async function POST(
return new NextResponse('Failed to read request body', { status: 400 })
}
// Parse the body as JSON
// Parse the body - handle both JSON and form-encoded payloads
let body: any
try {
body = JSON.parse(rawBody)
// Check content type to handle both JSON and form-encoded payloads
const contentType = request.headers.get('content-type') || ''
if (contentType.includes('application/x-www-form-urlencoded')) {
// GitHub sends form-encoded data with JSON in the 'payload' field
const formData = new URLSearchParams(rawBody)
const payloadString = formData.get('payload')
if (!payloadString) {
logger.warn(`[${requestId}] No payload field found in form-encoded data`)
return new NextResponse('Missing payload field', { status: 400 })
}
body = JSON.parse(payloadString)
logger.debug(`[${requestId}] Parsed form-encoded GitHub webhook payload`)
} else {
// Default to JSON parsing
body = JSON.parse(rawBody)
logger.debug(`[${requestId}] Parsed JSON webhook payload`)
}
if (Object.keys(body).length === 0) {
logger.warn(`[${requestId}] Rejecting empty JSON object`)
return new NextResponse('Empty JSON payload', { status: 400 })
}
} catch (parseError) {
logger.error(`[${requestId}] Failed to parse JSON body`, {
logger.error(`[${requestId}] Failed to parse webhook body`, {
error: parseError instanceof Error ? parseError.message : String(parseError),
contentType: request.headers.get('content-type'),
bodyPreview: `${rawBody?.slice(0, 100)}...`,
})
return new NextResponse('Invalid JSON payload', { status: 400 })
return new NextResponse('Invalid payload format', { status: 400 })
}
// Handle Slack challenge
@@ -174,6 +196,53 @@ export async function POST(
}
}
// Handle generic webhook authentication if enabled
if (foundWebhook.provider === 'generic') {
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
if (providerConfig.requireAuth) {
const configToken = providerConfig.token
const secretHeaderName = providerConfig.secretHeaderName
// --- Token Validation ---
if (configToken) {
let isTokenValid = false
if (secretHeaderName) {
// Check custom header (headers are case-insensitive)
const headerValue = request.headers.get(secretHeaderName.toLowerCase())
if (headerValue === configToken) {
isTokenValid = true
}
} else {
// Check standard Authorization header (case-insensitive Bearer keyword)
const authHeader = request.headers.get('authorization')
// Case-insensitive comparison for "Bearer" keyword
if (authHeader?.toLowerCase().startsWith('bearer ')) {
const token = authHeader.substring(7) // Remove "Bearer " (7 characters)
if (token === configToken) {
isTokenValid = true
}
}
}
if (!isTokenValid) {
const expectedHeader = secretHeaderName || 'Authorization: Bearer TOKEN'
logger.warn(
`[${requestId}] Generic webhook authentication failed. Expected header: ${expectedHeader}`
)
return new NextResponse('Unauthorized - Invalid authentication token', { status: 401 })
}
} else {
logger.warn(`[${requestId}] Generic webhook requires auth but no token configured`)
return new NextResponse('Unauthorized - Authentication required but not configured', {
status: 401,
})
}
}
}
// --- PHASE 3: Rate limiting for webhook execution ---
try {
// Get user subscription for rate limiting
@@ -224,7 +293,44 @@ export async function POST(
// Continue processing - better to risk rate limit bypass than fail webhook
}
// --- PHASE 4: Queue webhook execution via trigger.dev ---
// --- PHASE 4: Usage limit check ---
try {
const usageCheck = await checkServerSideUsageLimits(foundWorkflow.userId)
if (usageCheck.isExceeded) {
logger.warn(
`[${requestId}] User ${foundWorkflow.userId} has exceeded usage limits. Skipping webhook execution.`,
{
currentUsage: usageCheck.currentUsage,
limit: usageCheck.limit,
workflowId: foundWorkflow.id,
provider: foundWebhook.provider,
}
)
// Return 200 to prevent webhook provider retries, but indicate usage limit exceeded
if (foundWebhook.provider === 'microsoftteams') {
// Microsoft Teams requires specific response format
return NextResponse.json({
type: 'message',
text: 'Usage limit exceeded. Please upgrade your plan to continue.',
})
}
// Simple error response for other providers (return 200 to prevent retries)
return NextResponse.json({ message: 'Usage limit exceeded' }, { status: 200 })
}
logger.debug(`[${requestId}] Usage limit check passed for webhook`, {
provider: foundWebhook.provider,
currentUsage: usageCheck.currentUsage,
limit: usageCheck.limit,
})
} catch (usageError) {
logger.error(`[${requestId}] Error checking webhook usage limits:`, usageError)
// Continue processing - better to risk usage limit bypass than fail webhook
}
// --- PHASE 5: Queue webhook execution via trigger.dev ---
try {
// Queue the webhook execution task
const handle = await tasks.trigger('webhook-execution', {

View File

@@ -17,12 +17,6 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('AutoLayoutAPI')
// Check API key configuration at module level
const SIM_AGENT_API_KEY = process.env.SIM_AGENT_API_KEY
if (!SIM_AGENT_API_KEY) {
logger.warn('SIM_AGENT_API_KEY not configured - autolayout requests will fail')
}
const AutoLayoutRequestSchema = z.object({
strategy: z
.enum(['smart', 'hierarchical', 'layered', 'force-directed'])
@@ -125,15 +119,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Could not load workflow data' }, { status: 500 })
}
// Apply autolayout
logger.info(
`[${requestId}] Applying autolayout to ${Object.keys(currentWorkflowData.blocks).length} blocks`,
{
hasApiKey: !!SIM_AGENT_API_KEY,
simAgentUrl: process.env.SIM_AGENT_API_URL || 'http://localhost:8000',
}
)
// Create workflow state for autolayout
const workflowState = {
blocks: currentWorkflowData.blocks,
@@ -184,7 +169,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
resolveOutputType: resolveOutputType.toString(),
},
},
apiKey: SIM_AGENT_API_KEY,
})
// Log the full response for debugging

View File

@@ -118,44 +118,49 @@ describe('Workflow Deployment API Route', () => {
db: {
select: vi.fn().mockImplementation(() => {
selectCallCount++
const buildLimitResponse = () => ({
limit: vi.fn().mockImplementation(() => {
// First call: workflow lookup (should return workflow)
if (selectCallCount === 1) {
return Promise.resolve([{ userId: 'user-id', id: 'workflow-id' }])
}
// Second call: blocks lookup
if (selectCallCount === 2) {
return Promise.resolve([
{
id: 'block-1',
type: 'starter',
name: 'Start',
positionX: '100',
positionY: '100',
enabled: true,
subBlocks: {},
data: {},
},
])
}
// Third call: edges lookup
if (selectCallCount === 3) {
return Promise.resolve([])
}
// Fourth call: subflows lookup
if (selectCallCount === 4) {
return Promise.resolve([])
}
// Fifth call: API key lookup (should return empty for new key test)
if (selectCallCount === 5) {
return Promise.resolve([])
}
// Default: empty array
return Promise.resolve([])
}),
})
return {
from: vi.fn().mockImplementation(() => ({
where: vi.fn().mockImplementation(() => ({
limit: vi.fn().mockImplementation(() => {
// First call: workflow lookup (should return workflow)
if (selectCallCount === 1) {
return Promise.resolve([{ userId: 'user-id', id: 'workflow-id' }])
}
// Second call: blocks lookup
if (selectCallCount === 2) {
return Promise.resolve([
{
id: 'block-1',
type: 'starter',
name: 'Start',
positionX: '100',
positionY: '100',
enabled: true,
subBlocks: {},
data: {},
},
])
}
// Third call: edges lookup
if (selectCallCount === 3) {
return Promise.resolve([])
}
// Fourth call: subflows lookup
if (selectCallCount === 4) {
return Promise.resolve([])
}
// Fifth call: API key lookup (should return empty for new key test)
if (selectCallCount === 5) {
return Promise.resolve([])
}
// Default: empty array
return Promise.resolve([])
}),
...buildLimitResponse(),
orderBy: vi.fn().mockReturnValue(buildLimitResponse()),
})),
})),
}
@@ -216,160 +221,7 @@ describe('Workflow Deployment API Route', () => {
expect(data).toHaveProperty('deployedAt', null)
})
/**
* Test POST deployment with no existing API key
* This should generate a new API key
*/
it('should create new API key when deploying workflow for user with no API key', async () => {
// Override the global mock for this specific test
vi.doMock('@/db', () => ({
db: {
select: vi
.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ userId: 'user-id', id: 'workflow-id' }]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([
{
id: 'block-1',
type: 'starter',
name: 'Start',
positionX: '100',
positionY: '100',
enabled: true,
subBlocks: {},
data: {},
},
]),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]), // No edges
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]), // No subflows
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]), // No existing API key
}),
}),
}),
insert: vi.fn().mockImplementation(() => ({
values: vi.fn().mockResolvedValue([{ id: 'mock-api-key-id' }]),
})),
update: vi.fn().mockImplementation(() => ({
set: vi.fn().mockImplementation(() => ({
where: vi.fn().mockResolvedValue([]),
})),
})),
},
}))
const req = createMockRequest('POST')
const params = Promise.resolve({ id: 'workflow-id' })
const { POST } = await import('@/app/api/workflows/[id]/deploy/route')
const response = await POST(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('apiKey', 'sim_testkeygenerated12345')
expect(data).toHaveProperty('isDeployed', true)
expect(data).toHaveProperty('deployedAt')
})
/**
* Test POST deployment with existing API key
* This should use the existing API key
*/
it('should use existing API key when deploying workflow', async () => {
// Override the global mock for this specific test
vi.doMock('@/db', () => ({
db: {
select: vi
.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ userId: 'user-id', id: 'workflow-id' }]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([
{
id: 'block-1',
type: 'starter',
name: 'Start',
positionX: '100',
positionY: '100',
enabled: true,
subBlocks: {},
data: {},
},
]),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]), // No edges
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]), // No subflows
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ key: 'sim_existingtestapikey12345' }]), // Existing API key
}),
}),
}),
insert: vi.fn().mockImplementation(() => ({
values: vi.fn().mockResolvedValue([{ id: 'mock-api-key-id' }]),
})),
update: vi.fn().mockImplementation(() => ({
set: vi.fn().mockImplementation(() => ({
where: vi.fn().mockResolvedValue([]),
})),
})),
},
}))
const req = createMockRequest('POST')
const params = Promise.resolve({ id: 'workflow-id' })
const { POST } = await import('@/app/api/workflows/[id]/deploy/route')
const response = await POST(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('apiKey', 'sim_existingtestapikey12345')
expect(data).toHaveProperty('isDeployed', true)
})
// Removed two POST deployment tests by request
/**
* Test DELETE undeployment

View File

@@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm'
import { and, desc, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console/logger'
@@ -33,6 +33,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
deployedAt: workflow.deployedAt,
userId: workflow.userId,
deployedState: workflow.deployedState,
pinnedApiKey: workflow.pinnedApiKey,
})
.from(workflow)
.where(eq(workflow.id, id))
@@ -56,37 +57,42 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
})
}
// Fetch the user's API key
const userApiKey = await db
.select({
key: apiKey.key,
})
.from(apiKey)
.where(eq(apiKey.userId, workflowData.userId))
.limit(1)
let userKey: string | null = null
let userKey = null
// If no API key exists, create one automatically
if (userApiKey.length === 0) {
try {
const newApiKey = generateApiKey()
await db.insert(apiKey).values({
id: uuidv4(),
userId: workflowData.userId,
name: 'Default API Key',
key: newApiKey,
createdAt: new Date(),
updatedAt: new Date(),
})
userKey = newApiKey
logger.info(`[${requestId}] Generated new API key for user: ${workflowData.userId}`)
} catch (keyError) {
// If key generation fails, log the error but continue with the request
logger.error(`[${requestId}] Failed to generate API key:`, keyError)
}
if (workflowData.pinnedApiKey) {
userKey = workflowData.pinnedApiKey
} else {
userKey = userApiKey[0].key
// Fetch the user's API key, preferring the most recently used
const userApiKey = await db
.select({
key: apiKey.key,
})
.from(apiKey)
.where(eq(apiKey.userId, workflowData.userId))
.orderBy(desc(apiKey.lastUsed), desc(apiKey.createdAt))
.limit(1)
// If no API key exists, create one automatically
if (userApiKey.length === 0) {
try {
const newApiKeyVal = generateApiKey()
await db.insert(apiKey).values({
id: uuidv4(),
userId: workflowData.userId,
name: 'Default API Key',
key: newApiKeyVal,
createdAt: new Date(),
updatedAt: new Date(),
})
userKey = newApiKeyVal
logger.info(`[${requestId}] Generated new API key for user: ${workflowData.userId}`)
} catch (keyError) {
// If key generation fails, log the error but continue with the request
logger.error(`[${requestId}] Failed to generate API key:`, keyError)
}
} else {
userKey = userApiKey[0].key
}
}
// Check if the workflow has meaningful changes that would require redeployment
@@ -139,10 +145,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return createErrorResponse(validation.error.message, validation.error.status)
}
// Get the workflow to find the user (removed deprecated state column)
// Get the workflow to find the user and existing pin (removed deprecated state column)
const workflowData = await db
.select({
userId: workflow.userId,
pinnedApiKey: workflow.pinnedApiKey,
})
.from(workflow)
.where(eq(workflow.id, id))
@@ -155,6 +162,17 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const userId = workflowData[0].userId
// Parse request body to capture selected API key (if provided)
let providedApiKey: string | null = null
try {
const parsed = await request.json()
if (parsed && typeof parsed.apiKey === 'string' && parsed.apiKey.trim().length > 0) {
providedApiKey = parsed.apiKey.trim()
}
} catch (_err) {
// Body may be empty; ignore
}
// Get the current live state from normalized tables instead of stale JSON
logger.debug(`[${requestId}] Getting current workflow state for deployment`)
@@ -193,16 +211,19 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const config = (subflow.config as any) || {}
if (subflow.type === 'loop') {
loops[subflow.id] = {
id: subflow.id,
nodes: config.nodes || [],
iterationCount: config.iterationCount || 1,
iterationType: config.iterationType || 'fixed',
collection: config.collection || '',
iterations: config.iterations || 1,
loopType: config.loopType || 'for',
forEachItems: config.forEachItems || '',
}
} else if (subflow.type === 'parallel') {
parallels[subflow.id] = {
id: subflow.id,
nodes: config.nodes || [],
parallelCount: config.parallelCount || 2,
collection: config.collection || '',
count: config.count || 2,
distribution: config.distribution || '',
parallelType: config.parallelType || 'count',
}
}
})
@@ -241,13 +262,14 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const deployedAt = new Date()
logger.debug(`[${requestId}] Proceeding with deployment at ${deployedAt.toISOString()}`)
// Check if the user already has an API key
// Check if the user already has API keys
const userApiKey = await db
.select({
key: apiKey.key,
})
.from(apiKey)
.where(eq(apiKey.userId, userId))
.orderBy(desc(apiKey.lastUsed), desc(apiKey.createdAt))
.limit(1)
let userKey = null
@@ -274,15 +296,42 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
userKey = userApiKey[0].key
}
// If client provided a specific API key and it belongs to the user, prefer it
if (providedApiKey) {
const [owned] = await db
.select({ key: apiKey.key })
.from(apiKey)
.where(and(eq(apiKey.userId, userId), eq(apiKey.key, providedApiKey)))
.limit(1)
if (owned) {
userKey = providedApiKey
}
}
// Update the workflow deployment status and save current state as deployed state
await db
.update(workflow)
.set({
isDeployed: true,
deployedAt,
deployedState: currentState,
})
.where(eq(workflow.id, id))
const updateData: any = {
isDeployed: true,
deployedAt,
deployedState: currentState,
}
// Only pin when the client explicitly provided a key in this request
if (providedApiKey) {
updateData.pinnedApiKey = userKey
}
await db.update(workflow).set(updateData).where(eq(workflow.id, id))
// Update lastUsed for the key we returned
if (userKey) {
try {
await db
.update(apiKey)
.set({ lastUsed: new Date(), updatedAt: new Date() })
.where(eq(apiKey.key, userKey))
} catch (e) {
logger.warn(`[${requestId}] Failed to update lastUsed for api key`)
}
}
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
return createSuccessResponse({ apiKey: userKey, isDeployed: true, deployedAt })

View File

@@ -4,13 +4,10 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
export const dynamic = 'force-dynamic'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { db } from '@/db'
import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@/db/schema'
import type { LoopConfig, ParallelConfig, WorkflowState } from '@/stores/workflows/workflow/types'
import type { LoopConfig, ParallelConfig } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowDuplicateAPI')
@@ -93,7 +90,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
folderId: folderId || source.folderId,
name,
description: description || source.description,
state: source.state, // We'll update this later with new block IDs
color: color || source.color,
lastSynced: now,
createdAt: now,
@@ -115,9 +111,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
// Create a mapping from old block IDs to new block IDs
const blockIdMapping = new Map<string, string>()
// Initialize state for updating with new block IDs
let updatedState: WorkflowState = source.state as WorkflowState
if (sourceBlocks.length > 0) {
// First pass: Create all block ID mappings
sourceBlocks.forEach((block) => {
@@ -268,86 +261,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
)
}
// Update the JSON state to use new block IDs
if (updatedState && typeof updatedState === 'object') {
updatedState = JSON.parse(JSON.stringify(updatedState)) as WorkflowState
// Update blocks object keys
if (updatedState.blocks && typeof updatedState.blocks === 'object') {
const newBlocks = {} as Record<string, (typeof updatedState.blocks)[string]>
for (const [oldId, blockData] of Object.entries(updatedState.blocks)) {
const newId = blockIdMapping.get(oldId) || oldId
newBlocks[newId] = {
...blockData,
id: newId,
// Update data.parentId and extent in the JSON state as well
data: (() => {
const block = blockData as any
if (block.data && typeof block.data === 'object' && block.data.parentId) {
return {
...block.data,
parentId: blockIdMapping.get(block.data.parentId) || block.data.parentId,
extent: 'parent', // Ensure extent is set for child blocks
}
}
return block.data
})(),
}
}
updatedState.blocks = newBlocks
}
// Update edges array
if (updatedState.edges && Array.isArray(updatedState.edges)) {
updatedState.edges = updatedState.edges.map((edge) => ({
...edge,
id: crypto.randomUUID(),
source: blockIdMapping.get(edge.source) || edge.source,
target: blockIdMapping.get(edge.target) || edge.target,
}))
}
// Update loops and parallels if they exist
if (updatedState.loops && typeof updatedState.loops === 'object') {
const newLoops = {} as Record<string, (typeof updatedState.loops)[string]>
for (const [oldId, loopData] of Object.entries(updatedState.loops)) {
const newId = blockIdMapping.get(oldId) || oldId
const loopConfig = loopData as any
newLoops[newId] = {
...loopConfig,
id: newId,
// Update node references in loop config
nodes: loopConfig.nodes
? loopConfig.nodes.map((nodeId: string) => blockIdMapping.get(nodeId) || nodeId)
: [],
}
}
updatedState.loops = newLoops
}
if (updatedState.parallels && typeof updatedState.parallels === 'object') {
const newParallels = {} as Record<string, (typeof updatedState.parallels)[string]>
for (const [oldId, parallelData] of Object.entries(updatedState.parallels)) {
const newId = blockIdMapping.get(oldId) || oldId
const parallelConfig = parallelData as any
newParallels[newId] = {
...parallelConfig,
id: newId,
// Update node references in parallel config
nodes: parallelConfig.nodes
? parallelConfig.nodes.map((nodeId: string) => blockIdMapping.get(nodeId) || nodeId)
: [],
}
}
updatedState.parallels = newParallels
}
}
// Update the workflow state with the new block IDs
// Update the workflow timestamp
await tx
.update(workflow)
.set({
state: updatedState,
updatedAt: now,
})
.where(eq(workflow.id, newWorkflowId))

View File

@@ -89,7 +89,14 @@ describe('Workflow By ID API Route', () => {
userId: 'user-123',
name: 'Test Workflow',
workspaceId: null,
state: { blocks: {}, edges: [] },
}
const mockNormalizedData = {
blocks: {},
edges: [],
loops: {},
parallels: {},
isFromNormalizedTables: true,
}
vi.doMock('@/lib/auth', () => ({
@@ -110,6 +117,10 @@ describe('Workflow By ID API Route', () => {
},
}))
vi.doMock('@/lib/workflows/db-helpers', () => ({
loadWorkflowFromNormalizedTables: vi.fn().mockResolvedValue(mockNormalizedData),
}))
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123')
const params = Promise.resolve({ id: 'workflow-123' })
@@ -127,7 +138,14 @@ describe('Workflow By ID API Route', () => {
userId: 'other-user',
name: 'Test Workflow',
workspaceId: 'workspace-456',
state: { blocks: {}, edges: [] },
}
const mockNormalizedData = {
blocks: {},
edges: [],
loops: {},
parallels: {},
isFromNormalizedTables: true,
}
vi.doMock('@/lib/auth', () => ({
@@ -148,6 +166,10 @@ describe('Workflow By ID API Route', () => {
},
}))
vi.doMock('@/lib/workflows/db-helpers', () => ({
loadWorkflowFromNormalizedTables: vi.fn().mockResolvedValue(mockNormalizedData),
}))
vi.doMock('@/lib/permissions/utils', () => ({
getUserEntityPermissions: vi.fn().mockResolvedValue('read'),
hasAdminPermission: vi.fn().mockResolvedValue(false),
@@ -170,7 +192,6 @@ describe('Workflow By ID API Route', () => {
userId: 'other-user',
name: 'Test Workflow',
workspaceId: 'workspace-456',
state: { blocks: {}, edges: [] },
}
vi.doMock('@/lib/auth', () => ({
@@ -213,7 +234,6 @@ describe('Workflow By ID API Route', () => {
userId: 'user-123',
name: 'Test Workflow',
workspaceId: null,
state: { blocks: {}, edges: [] },
}
const mockNormalizedData = {

View File

@@ -12,8 +12,6 @@ import { apiKey as apiKeyTable, workflow } from '@/db/schema'
const logger = createLogger('WorkflowByIdAPI')
export const dynamic = 'force-dynamic'
const UpdateWorkflowSchema = z.object({
name: z.string().min(1, 'Name is required').optional(),
description: z.string().optional(),
@@ -122,8 +120,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
logger.debug(`[${requestId}] Attempting to load workflow ${workflowId} from normalized tables`)
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
const finalWorkflowData = { ...workflowData }
if (normalizedData) {
logger.debug(`[${requestId}] Found normalized data for workflow ${workflowId}:`, {
blocksCount: Object.keys(normalizedData.blocks).length,
@@ -133,38 +129,31 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
loops: normalizedData.loops,
})
// Use normalized table data - reconstruct complete state object
// First get any existing state properties, then override with normalized data
const existingState =
workflowData.state && typeof workflowData.state === 'object' ? workflowData.state : {}
finalWorkflowData.state = {
// Default values for expected properties
deploymentStatuses: {},
hasActiveWebhook: false,
// Preserve any existing state properties
...existingState,
// Override with normalized data (this takes precedence)
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
lastSaved: Date.now(),
isDeployed: workflowData.isDeployed || false,
deployedAt: workflowData.deployedAt,
// Construct response object with workflow data and state from normalized tables
const finalWorkflowData = {
...workflowData,
state: {
// Default values for expected properties
deploymentStatuses: {},
hasActiveWebhook: false,
// Data from normalized tables
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
lastSaved: Date.now(),
isDeployed: workflowData.isDeployed || false,
deployedAt: workflowData.deployedAt,
},
}
logger.info(`[${requestId}] Loaded workflow ${workflowId} from normalized tables`)
} else {
// Fallback to JSON blob
logger.info(
`[${requestId}] Using JSON blob for workflow ${workflowId} - no normalized data found`
)
const elapsed = Date.now() - startTime
logger.info(`[${requestId}] Successfully fetched workflow ${workflowId} in ${elapsed}ms`)
return NextResponse.json({ data: finalWorkflowData }, { status: 200 })
}
const elapsed = Date.now() - startTime
logger.info(`[${requestId}] Successfully fetched workflow ${workflowId} in ${elapsed}ms`)
return NextResponse.json({ data: finalWorkflowData }, { status: 200 })
return NextResponse.json({ error: 'Workflow has no normalized data' }, { status: 400 })
} catch (error: any) {
const elapsed = Date.now() - startTime
logger.error(`[${requestId}] Error fetching workflow ${workflowId} after ${elapsed}ms`, error)

View File

@@ -3,9 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
export const dynamic = 'force-dynamic'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers'
import { db } from '@/db'
@@ -13,7 +10,6 @@ import { workflow } from '@/db/schema'
const logger = createLogger('WorkflowStateAPI')
// Zod schemas for workflow state validation
const PositionSchema = z.object({
x: z.number(),
y: z.number(),
@@ -224,7 +220,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
.set({
lastSynced: new Date(),
updatedAt: new Date(),
state: saveResult.jsonBlob, // Also update JSON blob for backward compatibility
})
.where(eq(workflow.id, workflowId))

Some files were not shown because too many files have changed in this diff Show More