agent-relay 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitattributes +3 -0
- package/.nvmrc +1 -0
- package/.trajectories/agent-relay-322-324.md +17 -0
- package/.trajectories/completed/2026-01/traj_03zupyv1s7b9.json +49 -0
- package/.trajectories/completed/2026-01/traj_03zupyv1s7b9.md +31 -0
- package/.trajectories/completed/2026-01/traj_0zacdjl1g4ht.json +125 -0
- package/.trajectories/completed/2026-01/traj_0zacdjl1g4ht.md +62 -0
- package/.trajectories/completed/2026-01/traj_1dviorhnkcb5.json +65 -0
- package/.trajectories/completed/2026-01/traj_1dviorhnkcb5.md +37 -0
- package/.trajectories/completed/2026-01/traj_1k5if5snst2e.json +65 -0
- package/.trajectories/completed/2026-01/traj_1k5if5snst2e.md +37 -0
- package/.trajectories/completed/2026-01/traj_1rp3rges5811.json +49 -0
- package/.trajectories/completed/2026-01/traj_1rp3rges5811.md +31 -0
- package/.trajectories/completed/2026-01/traj_22bhyulruouw.json +113 -0
- package/.trajectories/completed/2026-01/traj_22bhyulruouw.md +57 -0
- package/.trajectories/completed/2026-01/traj_2dao7ddgnta0.json +53 -0
- package/.trajectories/completed/2026-01/traj_2dao7ddgnta0.md +32 -0
- package/.trajectories/completed/2026-01/traj_33iuy72sezbk.json +49 -0
- package/.trajectories/completed/2026-01/traj_33iuy72sezbk.md +31 -0
- package/.trajectories/completed/2026-01/traj_3t0440mjeunc.json +26 -0
- package/.trajectories/completed/2026-01/traj_3t0440mjeunc.md +6 -0
- package/.trajectories/completed/2026-01/traj_45x9494d9xnr.json +47 -0
- package/.trajectories/completed/2026-01/traj_45x9494d9xnr.md +32 -0
- package/.trajectories/completed/2026-01/traj_4aa0bb77s4nh.json +53 -0
- package/.trajectories/completed/2026-01/traj_4aa0bb77s4nh.md +32 -0
- package/.trajectories/completed/2026-01/traj_5ammh5qtvklq.json +77 -0
- package/.trajectories/completed/2026-01/traj_5ammh5qtvklq.md +42 -0
- package/.trajectories/completed/2026-01/traj_5lhmzq8rxpqv.json +59 -0
- package/.trajectories/completed/2026-01/traj_5lhmzq8rxpqv.md +33 -0
- package/.trajectories/completed/2026-01/traj_5vr4e9erb1fs.json +53 -0
- package/.trajectories/completed/2026-01/traj_5vr4e9erb1fs.md +32 -0
- package/.trajectories/completed/2026-01/traj_6fgiwdoklvym.json +48 -0
- package/.trajectories/completed/2026-01/traj_6fgiwdoklvym.md +24 -0
- package/.trajectories/completed/2026-01/traj_6mieijqyvaag.json +77 -0
- package/.trajectories/completed/2026-01/traj_6mieijqyvaag.md +42 -0
- package/.trajectories/completed/2026-01/traj_78ffm31jn3uk.json +77 -0
- package/.trajectories/completed/2026-01/traj_78ffm31jn3uk.md +42 -0
- package/.trajectories/completed/2026-01/traj_7ludwvz45veh.json +209 -0
- package/.trajectories/completed/2026-01/traj_7ludwvz45veh.md +97 -0
- package/.trajectories/completed/2026-01/traj_94gnp3k30goq.json +66 -0
- package/.trajectories/completed/2026-01/traj_94gnp3k30goq.md +36 -0
- package/.trajectories/completed/2026-01/traj_9921cuhel0pj.json +48 -0
- package/.trajectories/completed/2026-01/traj_9921cuhel0pj.md +24 -0
- package/.trajectories/completed/2026-01/traj_ajs7zqfux4wc.json +49 -0
- package/.trajectories/completed/2026-01/traj_ajs7zqfux4wc.md +23 -0
- package/.trajectories/completed/2026-01/traj_avqeghu6pz5a.json +40 -0
- package/.trajectories/completed/2026-01/traj_avqeghu6pz5a.md +22 -0
- package/.trajectories/completed/2026-01/traj_cvtqhlwcq9s0.json +53 -0
- package/.trajectories/completed/2026-01/traj_cvtqhlwcq9s0.md +32 -0
- package/.trajectories/completed/2026-01/traj_cxofprm2m2en.json +49 -0
- package/.trajectories/completed/2026-01/traj_cxofprm2m2en.md +31 -0
- package/.trajectories/completed/2026-01/traj_d2hhz3k0vrhn.json +26 -0
- package/.trajectories/completed/2026-01/traj_d2hhz3k0vrhn.md +6 -0
- package/.trajectories/completed/2026-01/traj_dcsp9s8y01ra.json +121 -0
- package/.trajectories/completed/2026-01/traj_dcsp9s8y01ra.md +29 -0
- package/.trajectories/completed/2026-01/traj_dfuvww9pege5.json +59 -0
- package/.trajectories/completed/2026-01/traj_dfuvww9pege5.md +37 -0
- package/.trajectories/completed/2026-01/traj_fhx9irlckht6.json +53 -0
- package/.trajectories/completed/2026-01/traj_fhx9irlckht6.md +32 -0
- package/.trajectories/completed/2026-01/traj_fqduidx3xbtp.json +101 -0
- package/.trajectories/completed/2026-01/traj_fqduidx3xbtp.md +52 -0
- package/.trajectories/completed/2026-01/traj_g0fisy9h51mf.json +77 -0
- package/.trajectories/completed/2026-01/traj_g0fisy9h51mf.md +42 -0
- package/.trajectories/completed/2026-01/traj_gjdre5voouod.json +53 -0
- package/.trajectories/completed/2026-01/traj_gjdre5voouod.md +32 -0
- package/.trajectories/completed/2026-01/traj_gtlyqtta3x8l.json +25 -0
- package/.trajectories/completed/2026-01/traj_gtlyqtta3x8l.md +15 -0
- package/.trajectories/completed/2026-01/traj_h4xijiuip3w4.json +101 -0
- package/.trajectories/completed/2026-01/traj_h4xijiuip3w4.md +44 -0
- package/.trajectories/completed/2026-01/traj_hf81ey93uz6t.json +49 -0
- package/.trajectories/completed/2026-01/traj_hf81ey93uz6t.md +31 -0
- package/.trajectories/completed/2026-01/traj_hfmki2jr9d4r.json +65 -0
- package/.trajectories/completed/2026-01/traj_hfmki2jr9d4r.md +37 -0
- package/.trajectories/completed/2026-01/traj_hhxte7w4gjjx.json +22 -0
- package/.trajectories/completed/2026-01/traj_hhxte7w4gjjx.md +5 -0
- package/.trajectories/completed/2026-01/traj_hpungyhoj6v5.json +53 -0
- package/.trajectories/completed/2026-01/traj_hpungyhoj6v5.md +32 -0
- package/.trajectories/completed/2026-01/traj_lq450ly148uw.json +49 -0
- package/.trajectories/completed/2026-01/traj_lq450ly148uw.md +31 -0
- package/.trajectories/completed/2026-01/traj_m2xkjv0w2sq7.json +25 -0
- package/.trajectories/completed/2026-01/traj_m2xkjv0w2sq7.md +15 -0
- package/.trajectories/completed/2026-01/traj_multi_server_arch.md +101 -0
- package/.trajectories/completed/2026-01/traj_noq5zbvnrdvz.json +53 -0
- package/.trajectories/completed/2026-01/traj_noq5zbvnrdvz.md +32 -0
- package/.trajectories/completed/2026-01/traj_ntbs6ppopf46.json +53 -0
- package/.trajectories/completed/2026-01/traj_ntbs6ppopf46.md +32 -0
- package/.trajectories/completed/2026-01/traj_ozd98si6a7ns.json +48 -0
- package/.trajectories/completed/2026-01/traj_ozd98si6a7ns.md +24 -0
- package/.trajectories/completed/2026-01/traj_prdza7a5cxp5.json +53 -0
- package/.trajectories/completed/2026-01/traj_prdza7a5cxp5.md +32 -0
- package/.trajectories/completed/2026-01/traj_psd9ob0j2ru3.json +27 -0
- package/.trajectories/completed/2026-01/traj_psd9ob0j2ru3.md +14 -0
- package/.trajectories/completed/2026-01/traj_qb3twvvywfwi.json +77 -0
- package/.trajectories/completed/2026-01/traj_qb3twvvywfwi.md +42 -0
- package/.trajectories/completed/2026-01/traj_qft54mi7nfor.json +53 -0
- package/.trajectories/completed/2026-01/traj_qft54mi7nfor.md +32 -0
- package/.trajectories/completed/2026-01/traj_qx9uhf8whhxo.json +83 -0
- package/.trajectories/completed/2026-01/traj_qx9uhf8whhxo.md +47 -0
- package/.trajectories/completed/2026-01/traj_rd9toccj18a0.json +59 -0
- package/.trajectories/completed/2026-01/traj_rd9toccj18a0.md +37 -0
- package/.trajectories/completed/2026-01/traj_rt4fiw3ecp50.json +48 -0
- package/.trajectories/completed/2026-01/traj_rt4fiw3ecp50.md +16 -0
- package/.trajectories/completed/2026-01/traj_st8j35b0hrlc.json +59 -0
- package/.trajectories/completed/2026-01/traj_st8j35b0hrlc.md +37 -0
- package/.trajectories/completed/2026-01/traj_t1yy8m7hbuxp.json +53 -0
- package/.trajectories/completed/2026-01/traj_t1yy8m7hbuxp.md +32 -0
- package/.trajectories/completed/2026-01/traj_tmux_orchestrator_analysis.json +84 -0
- package/.trajectories/completed/2026-01/traj_tmux_orchestrator_analysis.md +109 -0
- package/.trajectories/completed/2026-01/traj_u9n9eqasw16k.json +53 -0
- package/.trajectories/completed/2026-01/traj_u9n9eqasw16k.md +32 -0
- package/.trajectories/completed/2026-01/traj_ub8csuv3lcv4.json +53 -0
- package/.trajectories/completed/2026-01/traj_ub8csuv3lcv4.md +32 -0
- package/.trajectories/completed/2026-01/traj_uc29tlso8i9s.json +186 -0
- package/.trajectories/completed/2026-01/traj_uc29tlso8i9s.md +86 -0
- package/.trajectories/completed/2026-01/traj_ui9b4tqxoa7j.json +77 -0
- package/.trajectories/completed/2026-01/traj_ui9b4tqxoa7j.md +42 -0
- package/.trajectories/completed/2026-01/traj_v87hypnongqx.json +71 -0
- package/.trajectories/completed/2026-01/traj_v87hypnongqx.md +42 -0
- package/.trajectories/completed/2026-01/traj_v9dkdoxylyid.json +89 -0
- package/.trajectories/completed/2026-01/traj_v9dkdoxylyid.md +47 -0
- package/.trajectories/completed/2026-01/traj_wkp2fgzdyinb.json +53 -0
- package/.trajectories/completed/2026-01/traj_wkp2fgzdyinb.md +32 -0
- package/.trajectories/completed/2026-01/traj_x14t8w8rn7xg.json +20 -0
- package/.trajectories/completed/2026-01/traj_x14t8w8rn7xg.md +6 -0
- package/.trajectories/completed/2026-01/traj_xnwbznkvv8ua.json +175 -0
- package/.trajectories/completed/2026-01/traj_xnwbznkvv8ua.md +82 -0
- package/.trajectories/completed/2026-01/traj_xy9vifpqet80.json +65 -0
- package/.trajectories/completed/2026-01/traj_xy9vifpqet80.md +37 -0
- package/.trajectories/completed/2026-01/traj_y7aiwijyfmmv.json +49 -0
- package/.trajectories/completed/2026-01/traj_y7aiwijyfmmv.md +31 -0
- package/.trajectories/completed/2026-01/traj_ysjc8zaeqtd3.json +47 -0
- package/.trajectories/completed/2026-01/traj_ysjc8zaeqtd3.md +32 -0
- package/.trajectories/completed/2026-01/traj_yvdadtvdgnz3.json +59 -0
- package/.trajectories/completed/2026-01/traj_yvdadtvdgnz3.md +37 -0
- package/.trajectories/completed/2026-01/traj_z0vcw1wrzide.json +53 -0
- package/.trajectories/completed/2026-01/traj_z0vcw1wrzide.md +32 -0
- package/.trajectories/consolidate-settings-panel.md +24 -0
- package/.trajectories/gh-cli-user-token.md +26 -0
- package/.trajectories/index.json +468 -0
- package/ARCHITECTURE.md +1245 -0
- package/TESTING.md +278 -0
- package/deploy/init-db.sql +5 -0
- package/deploy/scripts/setup-fly-workspaces.sh +69 -0
- package/deploy/scripts/setup-railway.sh +75 -0
- package/deploy/workspace/codex.config.toml +15 -0
- package/deploy/workspace/entrypoint-browser.sh +118 -0
- package/deploy/workspace/entrypoint.sh +508 -0
- package/deploy/workspace/git-credential-relay +126 -0
- package/dist/bridge/spawner.d.ts +7 -0
- package/dist/bridge/spawner.js +40 -9
- package/dist/bridge/types.d.ts +2 -0
- package/dist/cli/index.js +260 -1
- package/dist/cloud/api/admin.d.ts +8 -0
- package/dist/cloud/api/admin.js +212 -0
- package/dist/cloud/api/auth.js +8 -0
- package/dist/cloud/api/billing.d.ts +0 -10
- package/dist/cloud/api/billing.js +278 -67
- package/dist/cloud/api/codex-auth-helper.d.ts +21 -0
- package/dist/cloud/api/codex-auth-helper.js +307 -0
- package/dist/cloud/api/coordinators.js +402 -0
- package/dist/cloud/api/daemons.js +15 -11
- package/dist/cloud/api/git.js +127 -19
- package/dist/cloud/api/github-app.js +42 -8
- package/dist/cloud/api/nango-auth.js +297 -16
- package/dist/cloud/api/onboarding.js +112 -35
- package/dist/cloud/api/providers.js +12 -16
- package/dist/cloud/api/repos.d.ts +1 -0
- package/dist/cloud/api/repos.js +311 -49
- package/dist/cloud/api/test-helpers.js +40 -0
- package/dist/cloud/api/usage.js +13 -0
- package/dist/cloud/api/webhooks.d.ts +1 -0
- package/dist/cloud/api/webhooks.js +149 -0
- package/dist/cloud/api/workspaces.d.ts +18 -0
- package/dist/cloud/api/workspaces.js +1042 -21
- package/dist/cloud/billing/plans.js +19 -19
- package/dist/cloud/config.d.ts +8 -0
- package/dist/cloud/config.js +15 -0
- package/dist/cloud/db/drizzle.d.ts +5 -2
- package/dist/cloud/db/drizzle.js +27 -20
- package/dist/cloud/db/schema.d.ts +19 -51
- package/dist/cloud/db/schema.js +5 -4
- package/dist/cloud/index.d.ts +0 -1
- package/dist/cloud/index.js +0 -1
- package/dist/cloud/provisioner/index.d.ts +125 -1
- package/dist/cloud/provisioner/index.js +939 -53
- package/dist/cloud/server.js +161 -16
- package/dist/cloud/services/compute-enforcement.d.ts +57 -0
- package/dist/cloud/services/compute-enforcement.js +175 -0
- package/dist/cloud/services/index.d.ts +2 -0
- package/dist/cloud/services/index.js +4 -0
- package/dist/cloud/services/intro-expiration.d.ts +55 -0
- package/dist/cloud/services/intro-expiration.js +211 -0
- package/dist/cloud/services/nango.d.ts +74 -0
- package/dist/cloud/services/nango.js +218 -5
- package/dist/cloud/services/planLimits.d.ts +22 -0
- package/dist/cloud/services/planLimits.js +58 -5
- package/dist/cloud/services/ssh-security.d.ts +31 -0
- package/dist/cloud/services/ssh-security.js +63 -0
- package/dist/continuity/manager.d.ts +5 -0
- package/dist/continuity/manager.js +56 -2
- package/dist/daemon/api.d.ts +2 -0
- package/dist/daemon/api.js +214 -5
- package/dist/daemon/cli-auth.d.ts +13 -1
- package/dist/daemon/cli-auth.js +166 -47
- package/dist/daemon/connection.d.ts +7 -1
- package/dist/daemon/connection.js +15 -0
- package/dist/daemon/orchestrator.d.ts +2 -0
- package/dist/daemon/orchestrator.js +26 -0
- package/dist/daemon/repo-manager.d.ts +116 -0
- package/dist/daemon/repo-manager.js +384 -0
- package/dist/daemon/router.d.ts +60 -1
- package/dist/daemon/router.js +281 -20
- package/dist/daemon/user-directory.d.ts +111 -0
- package/dist/daemon/user-directory.js +233 -0
- package/dist/dashboard/out/404.html +1 -1
- package/dist/dashboard/out/_next/static/T1tgCqVWHFIkV7ClEtzD7/_ssgManifest.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/532-bace199897eeab37.js +9 -0
- package/dist/dashboard/out/_next/static/chunks/766-b54f0853794b78c3.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/83-b51836037078006c.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/891-6cd50de1224f70bb.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/899-bb19a9b3d9b39ea6.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/app/onboarding/page-8939b0fc700f7eca.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/app/page-5af1b6b439858aa6.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/metrics/page-ac39dc0cc3c26fa7.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/{page-daf87e86f783f980.js → page-4a5938c18a11a654.js} +1 -1
- package/dist/dashboard/out/_next/static/chunks/app/providers/page-ac3a6ac433fd6001.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/app/providers/setup/[provider]/page-09f9caae98a18c09.js +1 -0
- package/dist/dashboard/out/_next/static/chunks/{main-97850e03d723ea8c.js → main-2ee6beb2ae96d210.js} +1 -1
- package/dist/dashboard/out/_next/static/css/85d2af9c7ac74d62.css +1 -0
- package/dist/dashboard/out/_next/static/css/fe4b28883eeff359.css +1 -0
- package/dist/dashboard/out/app/onboarding.html +1 -0
- package/dist/dashboard/out/app/onboarding.txt +7 -0
- package/dist/dashboard/out/app.html +1 -1
- package/dist/dashboard/out/app.txt +3 -3
- package/dist/dashboard/out/apple-icon.png +0 -0
- package/dist/dashboard/out/connect-repos.html +1 -1
- package/dist/dashboard/out/connect-repos.txt +3 -3
- package/dist/dashboard/out/history.html +1 -1
- package/dist/dashboard/out/history.txt +3 -3
- package/dist/dashboard/out/index.html +1 -1
- package/dist/dashboard/out/index.txt +3 -3
- package/dist/dashboard/out/login.html +2 -2
- package/dist/dashboard/out/login.txt +3 -3
- package/dist/dashboard/out/metrics.html +1 -1
- package/dist/dashboard/out/metrics.txt +3 -3
- package/dist/dashboard/out/pricing.html +3 -3
- package/dist/dashboard/out/pricing.txt +3 -3
- package/dist/dashboard/out/providers/setup/claude.html +1 -0
- package/dist/dashboard/out/providers/setup/claude.txt +8 -0
- package/dist/dashboard/out/providers/setup/codex.html +1 -0
- package/dist/dashboard/out/providers/setup/codex.txt +8 -0
- package/dist/dashboard/out/providers.html +1 -1
- package/dist/dashboard/out/providers.txt +3 -3
- package/dist/dashboard/out/signup.html +2 -2
- package/dist/dashboard/out/signup.txt +3 -3
- package/dist/dashboard-server/server.js +316 -12
- package/dist/dashboard-server/user-bridge.d.ts +103 -0
- package/dist/dashboard-server/user-bridge.js +189 -0
- package/dist/protocol/channels.d.ts +205 -0
- package/dist/protocol/channels.js +154 -0
- package/dist/protocol/types.d.ts +13 -1
- package/dist/resiliency/provider-context.js +2 -0
- package/dist/shared/cli-auth-config.d.ts +19 -0
- package/dist/shared/cli-auth-config.js +58 -2
- package/dist/utils/agent-config.js +1 -1
- package/dist/wrapper/auth-detection.d.ts +49 -0
- package/dist/wrapper/auth-detection.js +192 -0
- package/dist/wrapper/base-wrapper.d.ts +153 -0
- package/dist/wrapper/base-wrapper.js +393 -0
- package/dist/wrapper/client.d.ts +7 -1
- package/dist/wrapper/client.js +3 -0
- package/dist/wrapper/index.d.ts +1 -0
- package/dist/wrapper/index.js +4 -3
- package/dist/wrapper/pty-wrapper.d.ts +62 -84
- package/dist/wrapper/pty-wrapper.js +154 -180
- package/dist/wrapper/tmux-wrapper.d.ts +41 -66
- package/dist/wrapper/tmux-wrapper.js +90 -134
- package/package.json +5 -12
- package/scripts/postinstall.js +11 -155
- package/scripts/test-interactive-terminal.sh +248 -0
- package/test-push.txt +1 -0
- package/bin/tmux +0 -0
- package/dist/bridge/config.d.ts.map +0 -1
- package/dist/bridge/config.js.map +0 -1
- package/dist/bridge/index.d.ts.map +0 -1
- package/dist/bridge/index.js.map +0 -1
- package/dist/bridge/multi-project-client.d.ts.map +0 -1
- package/dist/bridge/multi-project-client.js.map +0 -1
- package/dist/bridge/shadow-cli.d.ts.map +0 -1
- package/dist/bridge/shadow-cli.js.map +0 -1
- package/dist/bridge/shadow-config.d.ts.map +0 -1
- package/dist/bridge/shadow-config.js.map +0 -1
- package/dist/bridge/spawner.d.ts.map +0 -1
- package/dist/bridge/spawner.js.map +0 -1
- package/dist/bridge/teams-config.d.ts.map +0 -1
- package/dist/bridge/teams-config.js.map +0 -1
- package/dist/bridge/types.d.ts.map +0 -1
- package/dist/bridge/types.js.map +0 -1
- package/dist/bridge/utils.d.ts.map +0 -1
- package/dist/bridge/utils.js.map +0 -1
- package/dist/cli/index.d.ts.map +0 -1
- package/dist/cli/index.js.map +0 -1
- package/dist/cloud/api/auth.d.ts.map +0 -1
- package/dist/cloud/api/auth.js.map +0 -1
- package/dist/cloud/api/billing.d.ts.map +0 -1
- package/dist/cloud/api/billing.js.map +0 -1
- package/dist/cloud/api/cli-pty-runner.d.ts.map +0 -1
- package/dist/cloud/api/cli-pty-runner.js.map +0 -1
- package/dist/cloud/api/coordinators.d.ts.map +0 -1
- package/dist/cloud/api/coordinators.js.map +0 -1
- package/dist/cloud/api/daemons.d.ts.map +0 -1
- package/dist/cloud/api/daemons.js.map +0 -1
- package/dist/cloud/api/generic-webhooks.d.ts.map +0 -1
- package/dist/cloud/api/generic-webhooks.js.map +0 -1
- package/dist/cloud/api/git.d.ts.map +0 -1
- package/dist/cloud/api/git.js.map +0 -1
- package/dist/cloud/api/github-app.d.ts.map +0 -1
- package/dist/cloud/api/github-app.js.map +0 -1
- package/dist/cloud/api/middleware/planLimits.d.ts.map +0 -1
- package/dist/cloud/api/middleware/planLimits.js.map +0 -1
- package/dist/cloud/api/monitoring.d.ts.map +0 -1
- package/dist/cloud/api/monitoring.js.map +0 -1
- package/dist/cloud/api/nango-auth.d.ts.map +0 -1
- package/dist/cloud/api/nango-auth.js.map +0 -1
- package/dist/cloud/api/onboarding.d.ts.map +0 -1
- package/dist/cloud/api/onboarding.js.map +0 -1
- package/dist/cloud/api/policy.d.ts.map +0 -1
- package/dist/cloud/api/policy.js.map +0 -1
- package/dist/cloud/api/providers.d.ts.map +0 -1
- package/dist/cloud/api/providers.js.map +0 -1
- package/dist/cloud/api/repos.d.ts.map +0 -1
- package/dist/cloud/api/repos.js.map +0 -1
- package/dist/cloud/api/teams.d.ts.map +0 -1
- package/dist/cloud/api/teams.js.map +0 -1
- package/dist/cloud/api/test-helpers.d.ts.map +0 -1
- package/dist/cloud/api/test-helpers.js.map +0 -1
- package/dist/cloud/api/usage.d.ts.map +0 -1
- package/dist/cloud/api/usage.js.map +0 -1
- package/dist/cloud/api/webhooks.d.ts.map +0 -1
- package/dist/cloud/api/webhooks.js.map +0 -1
- package/dist/cloud/api/workspaces.d.ts.map +0 -1
- package/dist/cloud/api/workspaces.js.map +0 -1
- package/dist/cloud/billing/index.d.ts.map +0 -1
- package/dist/cloud/billing/index.js.map +0 -1
- package/dist/cloud/billing/plans.d.ts.map +0 -1
- package/dist/cloud/billing/plans.js.map +0 -1
- package/dist/cloud/billing/service.d.ts.map +0 -1
- package/dist/cloud/billing/service.js.map +0 -1
- package/dist/cloud/billing/types.d.ts.map +0 -1
- package/dist/cloud/billing/types.js.map +0 -1
- package/dist/cloud/config.d.ts.map +0 -1
- package/dist/cloud/config.js.map +0 -1
- package/dist/cloud/db/drizzle.d.ts.map +0 -1
- package/dist/cloud/db/drizzle.js.map +0 -1
- package/dist/cloud/db/index.d.ts.map +0 -1
- package/dist/cloud/db/index.js.map +0 -1
- package/dist/cloud/db/schema.d.ts.map +0 -1
- package/dist/cloud/db/schema.js.map +0 -1
- package/dist/cloud/index.d.ts.map +0 -1
- package/dist/cloud/index.js.map +0 -1
- package/dist/cloud/provisioner/index.d.ts.map +0 -1
- package/dist/cloud/provisioner/index.js.map +0 -1
- package/dist/cloud/server.d.ts.map +0 -1
- package/dist/cloud/server.js.map +0 -1
- package/dist/cloud/services/auto-scaler.d.ts.map +0 -1
- package/dist/cloud/services/auto-scaler.js.map +0 -1
- package/dist/cloud/services/capacity-manager.d.ts.map +0 -1
- package/dist/cloud/services/capacity-manager.js.map +0 -1
- package/dist/cloud/services/ci-agent-spawner.d.ts.map +0 -1
- package/dist/cloud/services/ci-agent-spawner.js.map +0 -1
- package/dist/cloud/services/coordinator.d.ts.map +0 -1
- package/dist/cloud/services/coordinator.js.map +0 -1
- package/dist/cloud/services/index.d.ts.map +0 -1
- package/dist/cloud/services/index.js.map +0 -1
- package/dist/cloud/services/mention-handler.d.ts.map +0 -1
- package/dist/cloud/services/mention-handler.js.map +0 -1
- package/dist/cloud/services/nango.d.ts.map +0 -1
- package/dist/cloud/services/nango.js.map +0 -1
- package/dist/cloud/services/persistence.d.ts.map +0 -1
- package/dist/cloud/services/persistence.js.map +0 -1
- package/dist/cloud/services/planLimits.d.ts.map +0 -1
- package/dist/cloud/services/planLimits.js.map +0 -1
- package/dist/cloud/services/scaling-orchestrator.d.ts.map +0 -1
- package/dist/cloud/services/scaling-orchestrator.js.map +0 -1
- package/dist/cloud/services/scaling-policy.d.ts.map +0 -1
- package/dist/cloud/services/scaling-policy.js.map +0 -1
- package/dist/cloud/vault/index.d.ts +0 -76
- package/dist/cloud/vault/index.d.ts.map +0 -1
- package/dist/cloud/vault/index.js +0 -219
- package/dist/cloud/vault/index.js.map +0 -1
- package/dist/cloud/webhooks/index.d.ts.map +0 -1
- package/dist/cloud/webhooks/index.js.map +0 -1
- package/dist/cloud/webhooks/parsers/github.d.ts.map +0 -1
- package/dist/cloud/webhooks/parsers/github.js.map +0 -1
- package/dist/cloud/webhooks/parsers/index.d.ts.map +0 -1
- package/dist/cloud/webhooks/parsers/index.js.map +0 -1
- package/dist/cloud/webhooks/parsers/linear.d.ts.map +0 -1
- package/dist/cloud/webhooks/parsers/linear.js.map +0 -1
- package/dist/cloud/webhooks/parsers/slack.d.ts.map +0 -1
- package/dist/cloud/webhooks/parsers/slack.js.map +0 -1
- package/dist/cloud/webhooks/responders/github.d.ts.map +0 -1
- package/dist/cloud/webhooks/responders/github.js.map +0 -1
- package/dist/cloud/webhooks/responders/index.d.ts.map +0 -1
- package/dist/cloud/webhooks/responders/index.js.map +0 -1
- package/dist/cloud/webhooks/responders/linear.d.ts.map +0 -1
- package/dist/cloud/webhooks/responders/linear.js.map +0 -1
- package/dist/cloud/webhooks/responders/slack.d.ts.map +0 -1
- package/dist/cloud/webhooks/responders/slack.js.map +0 -1
- package/dist/cloud/webhooks/router.d.ts.map +0 -1
- package/dist/cloud/webhooks/router.js.map +0 -1
- package/dist/cloud/webhooks/rules-engine.d.ts.map +0 -1
- package/dist/cloud/webhooks/rules-engine.js.map +0 -1
- package/dist/cloud/webhooks/types.d.ts.map +0 -1
- package/dist/cloud/webhooks/types.js.map +0 -1
- package/dist/continuity/formatter.d.ts.map +0 -1
- package/dist/continuity/formatter.js.map +0 -1
- package/dist/continuity/handoff-store.d.ts.map +0 -1
- package/dist/continuity/handoff-store.js.map +0 -1
- package/dist/continuity/index.d.ts.map +0 -1
- package/dist/continuity/index.js.map +0 -1
- package/dist/continuity/ledger-store.d.ts.map +0 -1
- package/dist/continuity/ledger-store.js.map +0 -1
- package/dist/continuity/manager.d.ts.map +0 -1
- package/dist/continuity/manager.js.map +0 -1
- package/dist/continuity/parser.d.ts.map +0 -1
- package/dist/continuity/parser.js.map +0 -1
- package/dist/continuity/types.d.ts.map +0 -1
- package/dist/continuity/types.js.map +0 -1
- package/dist/daemon/agent-manager.d.ts.map +0 -1
- package/dist/daemon/agent-manager.js.map +0 -1
- package/dist/daemon/agent-registry.d.ts.map +0 -1
- package/dist/daemon/agent-registry.js.map +0 -1
- package/dist/daemon/api.d.ts.map +0 -1
- package/dist/daemon/api.js.map +0 -1
- package/dist/daemon/auth.d.ts.map +0 -1
- package/dist/daemon/auth.js.map +0 -1
- package/dist/daemon/cli-auth.d.ts.map +0 -1
- package/dist/daemon/cli-auth.js.map +0 -1
- package/dist/daemon/cloud-sync.d.ts.map +0 -1
- package/dist/daemon/cloud-sync.js.map +0 -1
- package/dist/daemon/connection.d.ts.map +0 -1
- package/dist/daemon/connection.js.map +0 -1
- package/dist/daemon/index.d.ts.map +0 -1
- package/dist/daemon/index.js.map +0 -1
- package/dist/daemon/orchestrator.d.ts.map +0 -1
- package/dist/daemon/orchestrator.js.map +0 -1
- package/dist/daemon/registry.d.ts.map +0 -1
- package/dist/daemon/registry.js.map +0 -1
- package/dist/daemon/router.d.ts.map +0 -1
- package/dist/daemon/router.js.map +0 -1
- package/dist/daemon/server.d.ts.map +0 -1
- package/dist/daemon/server.js.map +0 -1
- package/dist/daemon/services/browser-testing.d.ts.map +0 -1
- package/dist/daemon/services/browser-testing.js.map +0 -1
- package/dist/daemon/services/container-spawner.d.ts.map +0 -1
- package/dist/daemon/services/container-spawner.js.map +0 -1
- package/dist/daemon/types.d.ts.map +0 -1
- package/dist/daemon/types.js.map +0 -1
- package/dist/daemon/workspace-manager.d.ts.map +0 -1
- package/dist/daemon/workspace-manager.js.map +0 -1
- package/dist/dashboard/out/_next/static/H5aWG0udPB4iOUIl_gytz/_ssgManifest.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/480-2d4111711d4e473c.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/724-73c1ee5f60abe860.js +0 -9
- package/dist/dashboard/out/_next/static/chunks/766-c3a14283c88d815b.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/app/app/page-7120be68bea622f3.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/app/metrics/page-1081dd190a331a91.js +0 -1
- package/dist/dashboard/out/_next/static/chunks/app/providers/page-b68a681526eb145e.js +0 -1
- package/dist/dashboard/out/_next/static/css/29852f26181969a0.css +0 -1
- package/dist/dashboard/out/_next/static/css/411ce23ffeae9f76.css +0 -1
- package/dist/dashboard-server/metrics.d.ts.map +0 -1
- package/dist/dashboard-server/metrics.js.map +0 -1
- package/dist/dashboard-server/needs-attention.d.ts.map +0 -1
- package/dist/dashboard-server/needs-attention.js.map +0 -1
- package/dist/dashboard-server/server.d.ts.map +0 -1
- package/dist/dashboard-server/server.js.map +0 -1
- package/dist/dashboard-server/start.d.ts.map +0 -1
- package/dist/dashboard-server/start.js.map +0 -1
- package/dist/hooks/emitter.d.ts.map +0 -1
- package/dist/hooks/emitter.js.map +0 -1
- package/dist/hooks/inbox-check/hook.d.ts.map +0 -1
- package/dist/hooks/inbox-check/hook.js.map +0 -1
- package/dist/hooks/inbox-check/index.d.ts.map +0 -1
- package/dist/hooks/inbox-check/index.js.map +0 -1
- package/dist/hooks/inbox-check/types.d.ts.map +0 -1
- package/dist/hooks/inbox-check/types.js.map +0 -1
- package/dist/hooks/inbox-check/utils.d.ts.map +0 -1
- package/dist/hooks/inbox-check/utils.js.map +0 -1
- package/dist/hooks/index.d.ts.map +0 -1
- package/dist/hooks/index.js.map +0 -1
- package/dist/hooks/registry.d.ts.map +0 -1
- package/dist/hooks/registry.js.map +0 -1
- package/dist/hooks/trajectory-hooks.d.ts.map +0 -1
- package/dist/hooks/trajectory-hooks.js.map +0 -1
- package/dist/hooks/types.d.ts.map +0 -1
- package/dist/hooks/types.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/memory/adapters/index.d.ts.map +0 -1
- package/dist/memory/adapters/index.js.map +0 -1
- package/dist/memory/adapters/inmemory.d.ts.map +0 -1
- package/dist/memory/adapters/inmemory.js.map +0 -1
- package/dist/memory/adapters/supermemory.d.ts.map +0 -1
- package/dist/memory/adapters/supermemory.js.map +0 -1
- package/dist/memory/factory.d.ts.map +0 -1
- package/dist/memory/factory.js.map +0 -1
- package/dist/memory/index.d.ts.map +0 -1
- package/dist/memory/index.js.map +0 -1
- package/dist/memory/memory-hooks.d.ts.map +0 -1
- package/dist/memory/memory-hooks.js.map +0 -1
- package/dist/memory/service.d.ts.map +0 -1
- package/dist/memory/service.js.map +0 -1
- package/dist/memory/types.d.ts.map +0 -1
- package/dist/memory/types.js.map +0 -1
- package/dist/policy/agent-policy.d.ts.map +0 -1
- package/dist/policy/agent-policy.js.map +0 -1
- package/dist/policy/cloud-policy-fetcher.d.ts.map +0 -1
- package/dist/policy/cloud-policy-fetcher.js.map +0 -1
- package/dist/protocol/framing.d.ts.map +0 -1
- package/dist/protocol/framing.js.map +0 -1
- package/dist/protocol/index.d.ts.map +0 -1
- package/dist/protocol/index.js.map +0 -1
- package/dist/protocol/types.d.ts.map +0 -1
- package/dist/protocol/types.js.map +0 -1
- package/dist/resiliency/context-persistence.d.ts.map +0 -1
- package/dist/resiliency/context-persistence.js.map +0 -1
- package/dist/resiliency/crash-insights.d.ts.map +0 -1
- package/dist/resiliency/crash-insights.js.map +0 -1
- package/dist/resiliency/gossip-health.d.ts.map +0 -1
- package/dist/resiliency/gossip-health.js.map +0 -1
- package/dist/resiliency/health-monitor.d.ts.map +0 -1
- package/dist/resiliency/health-monitor.js.map +0 -1
- package/dist/resiliency/index.d.ts.map +0 -1
- package/dist/resiliency/index.js.map +0 -1
- package/dist/resiliency/leader-watchdog.d.ts.map +0 -1
- package/dist/resiliency/leader-watchdog.js.map +0 -1
- package/dist/resiliency/logger.d.ts.map +0 -1
- package/dist/resiliency/logger.js.map +0 -1
- package/dist/resiliency/memory-monitor.d.ts.map +0 -1
- package/dist/resiliency/memory-monitor.js.map +0 -1
- package/dist/resiliency/metrics.d.ts.map +0 -1
- package/dist/resiliency/metrics.js.map +0 -1
- package/dist/resiliency/provider-context.d.ts.map +0 -1
- package/dist/resiliency/provider-context.js.map +0 -1
- package/dist/resiliency/stateless-lead.d.ts.map +0 -1
- package/dist/resiliency/stateless-lead.js.map +0 -1
- package/dist/resiliency/supervisor.d.ts.map +0 -1
- package/dist/resiliency/supervisor.js.map +0 -1
- package/dist/shared/cli-auth-config.d.ts.map +0 -1
- package/dist/shared/cli-auth-config.js.map +0 -1
- package/dist/state/agent-state.d.ts.map +0 -1
- package/dist/state/agent-state.js.map +0 -1
- package/dist/storage/adapter.d.ts.map +0 -1
- package/dist/storage/adapter.js.map +0 -1
- package/dist/storage/sqlite-adapter.d.ts.map +0 -1
- package/dist/storage/sqlite-adapter.js.map +0 -1
- package/dist/trajectory/config.d.ts.map +0 -1
- package/dist/trajectory/config.js.map +0 -1
- package/dist/trajectory/index.d.ts.map +0 -1
- package/dist/trajectory/index.js.map +0 -1
- package/dist/trajectory/integration.d.ts.map +0 -1
- package/dist/trajectory/integration.js.map +0 -1
- package/dist/utils/agent-config.d.ts.map +0 -1
- package/dist/utils/agent-config.js.map +0 -1
- package/dist/utils/command-resolver.d.ts.map +0 -1
- package/dist/utils/command-resolver.js.map +0 -1
- package/dist/utils/index.d.ts.map +0 -1
- package/dist/utils/index.js.map +0 -1
- package/dist/utils/logger.d.ts.map +0 -1
- package/dist/utils/logger.js.map +0 -1
- package/dist/utils/name-generator.d.ts.map +0 -1
- package/dist/utils/name-generator.js.map +0 -1
- package/dist/utils/project-namespace.d.ts.map +0 -1
- package/dist/utils/project-namespace.js.map +0 -1
- package/dist/utils/tmux-resolver.d.ts.map +0 -1
- package/dist/utils/tmux-resolver.js.map +0 -1
- package/dist/utils/update-checker.d.ts.map +0 -1
- package/dist/utils/update-checker.js.map +0 -1
- package/dist/wrapper/client.d.ts.map +0 -1
- package/dist/wrapper/client.js.map +0 -1
- package/dist/wrapper/inbox.d.ts.map +0 -1
- package/dist/wrapper/inbox.js.map +0 -1
- package/dist/wrapper/index.d.ts.map +0 -1
- package/dist/wrapper/index.js.map +0 -1
- package/dist/wrapper/parser.d.ts.map +0 -1
- package/dist/wrapper/parser.js.map +0 -1
- package/dist/wrapper/pty-wrapper.d.ts.map +0 -1
- package/dist/wrapper/pty-wrapper.js.map +0 -1
- package/dist/wrapper/shared.d.ts.map +0 -1
- package/dist/wrapper/shared.js.map +0 -1
- package/dist/wrapper/tmux-wrapper.d.ts.map +0 -1
- package/dist/wrapper/tmux-wrapper.js.map +0 -1
- package/docs/AGENTS.md +0 -513
- package/docs/ARCHITECTURE_DECISIONS.md +0 -175
- package/docs/CLOUD-ARCHITECTURE.md +0 -804
- package/docs/CLOUD-ONBOARDING-DESIGN.md +0 -1983
- package/docs/CONTRIBUTING.md +0 -151
- package/docs/HOOKS_API.md +0 -394
- package/docs/INTEGRATION-GUIDE.md +0 -926
- package/docs/PROTOCOL.md +0 -325
- package/docs/WRAPPER_EVENTS.md +0 -358
- package/docs/agent-policy-snippet.md +0 -40
- package/docs/agent-relay-protocol.md +0 -238
- package/docs/agent-relay-snippet.md +0 -174
- package/docs/archive/CHANGELOG.md +0 -11
- package/docs/archive/CLI-SIMPLIFICATION-COMPLETE.md +0 -48
- package/docs/archive/DESIGN_BRIDGE_STAFFING.md +0 -878
- package/docs/archive/DESIGN_V2.md +0 -1079
- package/docs/archive/EXECUTIVE_SUMMARY.md +0 -358
- package/docs/archive/MONETIZATION.md +0 -1679
- package/docs/archive/PROPOSAL-trajectories.md +0 -1582
- package/docs/archive/ROADMAP.md +0 -329
- package/docs/archive/SCALING_ANALYSIS.md +0 -280
- package/docs/archive/TESTING_PRESENCE_FEATURES.md +0 -327
- package/docs/archive/TMUX_IMPLEMENTATION_NOTES.md +0 -364
- package/docs/archive/TMUX_IMPROVEMENTS.md +0 -968
- package/docs/archive/dashboard-v2-plan.md +0 -179
- package/docs/archive/removable-code-analysis.md +0 -24
- package/docs/competitive/GASTOWN.md +0 -451
- package/docs/competitive/MCP_AGENT_MAIL.md +0 -389
- package/docs/competitive/OVERVIEW.md +0 -898
- package/docs/competitive/README.md +0 -34
- package/docs/competitive/TMUX_ORCHESTRATOR.md +0 -605
- package/docs/dashboard.png +0 -0
- package/docs/design/ci-failure-webhooks.md +0 -812
- package/docs/design/comprehensive-integrations.md +0 -238
- package/docs/design/e2b-sandbox-integration.md +0 -504
- package/docs/design/github-app-permissions.md +0 -264
- package/docs/guides/CLOUD.md +0 -236
- package/docs/guides/LOCAL.md +0 -535
- package/docs/guides/SELF-HOSTED.md +0 -494
- package/docs/local-testing.md +0 -428
- package/docs/proposals/continuous-claude-integration.md +0 -622
- package/docs/proposals/custom-commands.md +0 -368
- package/docs/proposals/shadow-as-subagent.md +0 -765
- package/docs/proposals/slack-bot-integration.md +0 -1457
- package/docs/tasks/global-skills-system.tasks.md +0 -230
- package/docs/tasks/webhook-integrations.tasks.md +0 -184
- package/docs/tasks/workspace-capabilities.tasks.md +0 -121
- package/docs/testing/RESILIENCY-TEST-PLAN-2026-01-01.md +0 -366
- package/scripts/cloud-setup.sh +0 -96
- package/scripts/dev/PUBLIC_RELEASE_PLAN.md +0 -88
- package/scripts/dev/dev-team-setup.sh +0 -431
- package/scripts/e2e-test.sh +0 -119
- package/scripts/games/game-protocol.md +0 -79
- package/scripts/games/hearts-setup.sh +0 -264
- package/scripts/manual-qa.sh +0 -293
- package/scripts/run-cloud-qa.sh +0 -220
- package/scripts/test-cli-auth/Dockerfile +0 -44
- package/scripts/test-cli-auth/Dockerfile.real +0 -79
- package/scripts/test-cli-auth/README.md +0 -286
- package/scripts/test-cli-auth/ci-test-real-clis.ts +0 -251
- package/scripts/test-cli-auth/ci-test-runner.ts +0 -263
- package/scripts/test-cli-auth/mock-cli.sh +0 -147
- package/scripts/test-cli-auth/package.json +0 -14
- package/scripts/test-cli-auth/test-oauth-flow.ts +0 -220
- package/scripts/test-pty-input-auto.js +0 -222
- package/scripts/test-pty-input.js +0 -150
- package/scripts/tictactoe-setup.sh +0 -181
- /package/dist/dashboard/out/_next/static/{H5aWG0udPB4iOUIl_gytz → T1tgCqVWHFIkV7ClEtzD7}/_buildManifest.js +0 -0
- /package/dist/dashboard/out/_next/static/chunks/{117-b100311aff8d5c61.js → 117-f7b8ab0809342e77.js} +0 -0
- /package/dist/dashboard/out/_next/static/chunks/{648-a13d3c2b1be45466.js → 648-5cc6e1921389a58a.js} +0 -0
- /package/dist/dashboard/out/_next/static/chunks/app/_not-found/{page-a4973f3e3c82fb67.js → page-53b8a69f76db17d0.js} +0 -0
- /package/dist/dashboard/out/_next/static/chunks/app/connect-repos/{page-dc2e3a1a22478efc.js → page-f45ecbc3e06134fc.js} +0 -0
- /package/dist/dashboard/out/_next/static/chunks/app/history/{page-56a8b4616a90dc43.js → page-8c8bed33beb2bf1c.js} +0 -0
- /package/dist/dashboard/out/_next/static/chunks/app/login/{page-3eac37ea6f5dd153.js → page-16f3b49e55b1e0ed.js} +0 -0
- /package/dist/dashboard/out/_next/static/chunks/app/pricing/{page-4d72d5a5d8a9b618.js → page-982a7000fee44014.js} +0 -0
- /package/dist/dashboard/out/_next/static/chunks/app/signup/{page-fee4ed1709070bcd.js → page-547dd0ca55ecd0ba.js} +0 -0
- /package/dist/dashboard/out/_next/static/chunks/{fd9d1056-bf46c09eb57e019c.js → fd9d1056-609918ca7b6280bb.js} +0 -0
|
@@ -2,25 +2,367 @@
|
|
|
2
2
|
* Workspaces API Routes
|
|
3
3
|
*
|
|
4
4
|
* One-click workspace provisioning and management.
|
|
5
|
+
* Includes auto-access based on GitHub repo permissions.
|
|
5
6
|
*/
|
|
6
7
|
import { Router } from 'express';
|
|
7
8
|
import { requireAuth } from './auth.js';
|
|
8
9
|
import { db } from '../db/index.js';
|
|
9
|
-
import { getProvisioner } from '../provisioner/index.js';
|
|
10
|
+
import { getProvisioner, getProvisioningStage } from '../provisioner/index.js';
|
|
10
11
|
import { checkWorkspaceLimit } from './middleware/planLimits.js';
|
|
12
|
+
import { getConfig } from '../config.js';
|
|
13
|
+
import { nangoService } from '../services/nango.js';
|
|
14
|
+
// Simple in-memory cache for workspace access checks
|
|
15
|
+
// Key: `${userId}:${workspaceId}`
|
|
16
|
+
const workspaceAccessCache = new Map();
|
|
17
|
+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
18
|
+
function getCachedAccess(userId, workspaceId) {
|
|
19
|
+
const key = `${userId}:${workspaceId}`;
|
|
20
|
+
const cached = workspaceAccessCache.get(key);
|
|
21
|
+
if (!cached)
|
|
22
|
+
return null;
|
|
23
|
+
// Check if expired
|
|
24
|
+
if (Date.now() - cached.cachedAt > CACHE_TTL_MS) {
|
|
25
|
+
workspaceAccessCache.delete(key);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return cached;
|
|
29
|
+
}
|
|
30
|
+
function setCachedAccess(userId, workspaceId, access) {
|
|
31
|
+
const key = `${userId}:${workspaceId}`;
|
|
32
|
+
workspaceAccessCache.set(key, { ...access, cachedAt: Date.now() });
|
|
33
|
+
}
|
|
34
|
+
function _invalidateCachedAccess(userId, workspaceId) {
|
|
35
|
+
if (workspaceId) {
|
|
36
|
+
workspaceAccessCache.delete(`${userId}:${workspaceId}`);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
// Invalidate all cache entries for this user
|
|
40
|
+
for (const key of workspaceAccessCache.keys()) {
|
|
41
|
+
if (key.startsWith(`${userId}:`)) {
|
|
42
|
+
workspaceAccessCache.delete(key);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Cache keyed by nangoConnectionId
|
|
48
|
+
const userReposCache = new Map();
|
|
49
|
+
const USER_REPOS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes - hard expiry
|
|
50
|
+
const STALE_WHILE_REVALIDATE_MS = 5 * 60 * 1000; // Trigger background refresh after 5 minutes
|
|
51
|
+
const MAX_CACHE_ENTRIES = 500; // Prevent unbounded growth
|
|
52
|
+
/**
|
|
53
|
+
* Evict oldest cache entries if we exceed the limit
|
|
54
|
+
*/
|
|
55
|
+
function evictOldestCacheEntries() {
|
|
56
|
+
if (userReposCache.size <= MAX_CACHE_ENTRIES)
|
|
57
|
+
return;
|
|
58
|
+
// Convert to array, sort by cachedAt (oldest first), delete oldest entries
|
|
59
|
+
const entries = Array.from(userReposCache.entries())
|
|
60
|
+
.sort((a, b) => a[1].cachedAt - b[1].cachedAt);
|
|
61
|
+
const toEvict = entries.slice(0, entries.length - MAX_CACHE_ENTRIES);
|
|
62
|
+
for (const [key] of toEvict) {
|
|
63
|
+
console.log(`[repos-cache] Evicting oldest cache entry: ${key.substring(0, 8)}`);
|
|
64
|
+
userReposCache.delete(key);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Background refresh function that paginates through ALL user repos
|
|
69
|
+
*/
|
|
70
|
+
async function refreshUserReposInBackground(nangoConnectionId) {
|
|
71
|
+
const cached = userReposCache.get(nangoConnectionId);
|
|
72
|
+
// Don't start if refresh already in progress
|
|
73
|
+
if (cached?.refreshInProgress) {
|
|
74
|
+
console.log(`[repos-cache] Background refresh already in progress for ${nangoConnectionId.substring(0, 8)}`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// Mark as refreshing
|
|
78
|
+
if (cached) {
|
|
79
|
+
cached.refreshInProgress = true;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
// Create placeholder entry
|
|
83
|
+
userReposCache.set(nangoConnectionId, {
|
|
84
|
+
repositories: [],
|
|
85
|
+
cachedAt: Date.now(),
|
|
86
|
+
isComplete: false,
|
|
87
|
+
refreshInProgress: true,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
console.log(`[repos-cache] Starting background refresh for ${nangoConnectionId.substring(0, 8)}`);
|
|
91
|
+
try {
|
|
92
|
+
const allRepos = [];
|
|
93
|
+
let page = 1;
|
|
94
|
+
let hasMore = true;
|
|
95
|
+
const MAX_PAGES = 20; // Safety limit: 20 pages * 100 repos = 2000 repos max
|
|
96
|
+
while (hasMore && page <= MAX_PAGES) {
|
|
97
|
+
const result = await nangoService.listUserAccessibleRepos(nangoConnectionId, {
|
|
98
|
+
perPage: 100,
|
|
99
|
+
page,
|
|
100
|
+
type: 'all',
|
|
101
|
+
});
|
|
102
|
+
allRepos.push(...result.repositories.map(r => ({
|
|
103
|
+
fullName: r.fullName,
|
|
104
|
+
permissions: r.permissions,
|
|
105
|
+
})));
|
|
106
|
+
hasMore = result.hasMore;
|
|
107
|
+
page++;
|
|
108
|
+
// Small delay between pages to avoid rate limiting
|
|
109
|
+
if (hasMore) {
|
|
110
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
console.log(`[repos-cache] Background refresh complete for ${nangoConnectionId.substring(0, 8)}: ${allRepos.length} repos across ${page - 1} pages`);
|
|
114
|
+
userReposCache.set(nangoConnectionId, {
|
|
115
|
+
repositories: allRepos,
|
|
116
|
+
cachedAt: Date.now(),
|
|
117
|
+
isComplete: true,
|
|
118
|
+
refreshInProgress: false,
|
|
119
|
+
});
|
|
120
|
+
evictOldestCacheEntries();
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
console.error(`[repos-cache] Background refresh failed for ${nangoConnectionId.substring(0, 8)}:`, err);
|
|
124
|
+
// Mark refresh as done even on error, keep existing data if any
|
|
125
|
+
const existing = userReposCache.get(nangoConnectionId);
|
|
126
|
+
if (existing) {
|
|
127
|
+
existing.refreshInProgress = false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Get cached user repos, triggering background refresh if stale
|
|
133
|
+
* Returns null if no cache exists (caller should fetch first page synchronously)
|
|
134
|
+
*/
|
|
135
|
+
function getCachedUserRepos(nangoConnectionId) {
|
|
136
|
+
const cached = userReposCache.get(nangoConnectionId);
|
|
137
|
+
if (!cached)
|
|
138
|
+
return null;
|
|
139
|
+
const age = Date.now() - cached.cachedAt;
|
|
140
|
+
// If expired, delete and return null
|
|
141
|
+
if (age > USER_REPOS_CACHE_TTL_MS) {
|
|
142
|
+
console.log(`[repos-cache] Cache expired for ${nangoConnectionId.substring(0, 8)}`);
|
|
143
|
+
userReposCache.delete(nangoConnectionId);
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
// If stale but valid, trigger background refresh
|
|
147
|
+
if (age > STALE_WHILE_REVALIDATE_MS && !cached.refreshInProgress) {
|
|
148
|
+
console.log(`[repos-cache] Cache stale for ${nangoConnectionId.substring(0, 8)}, triggering background refresh`);
|
|
149
|
+
// Fire and forget - don't await
|
|
150
|
+
refreshUserReposInBackground(nangoConnectionId).catch(() => { });
|
|
151
|
+
}
|
|
152
|
+
return cached;
|
|
153
|
+
}
|
|
154
|
+
// Track in-flight initializations to prevent duplicate API calls
|
|
155
|
+
const initializingConnections = new Set();
|
|
156
|
+
/**
|
|
157
|
+
* Initialize cache with first page and trigger background refresh for rest
|
|
158
|
+
* Returns the first page of repos immediately
|
|
159
|
+
*/
|
|
160
|
+
async function initializeUserReposCache(nangoConnectionId) {
|
|
161
|
+
// Check if another request is already initializing this connection
|
|
162
|
+
if (initializingConnections.has(nangoConnectionId)) {
|
|
163
|
+
console.log(`[repos-cache] Another request is initializing ${nangoConnectionId.substring(0, 8)}, waiting...`);
|
|
164
|
+
// Wait a bit and check cache again
|
|
165
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
166
|
+
const cached = userReposCache.get(nangoConnectionId);
|
|
167
|
+
if (cached) {
|
|
168
|
+
return cached.repositories;
|
|
169
|
+
}
|
|
170
|
+
// Still no cache, fall through to initialize (previous request may have failed)
|
|
171
|
+
}
|
|
172
|
+
initializingConnections.add(nangoConnectionId);
|
|
173
|
+
try {
|
|
174
|
+
// Fetch first page synchronously
|
|
175
|
+
const firstPage = await nangoService.listUserAccessibleRepos(nangoConnectionId, {
|
|
176
|
+
perPage: 100,
|
|
177
|
+
page: 1,
|
|
178
|
+
type: 'all',
|
|
179
|
+
});
|
|
180
|
+
const repos = firstPage.repositories.map(r => ({
|
|
181
|
+
fullName: r.fullName,
|
|
182
|
+
permissions: r.permissions,
|
|
183
|
+
}));
|
|
184
|
+
// Store first page immediately
|
|
185
|
+
userReposCache.set(nangoConnectionId, {
|
|
186
|
+
repositories: repos,
|
|
187
|
+
cachedAt: Date.now(),
|
|
188
|
+
isComplete: !firstPage.hasMore,
|
|
189
|
+
refreshInProgress: firstPage.hasMore, // Will be refreshing if there's more
|
|
190
|
+
});
|
|
191
|
+
evictOldestCacheEntries();
|
|
192
|
+
// If there are more pages, trigger background refresh to get the rest
|
|
193
|
+
if (firstPage.hasMore) {
|
|
194
|
+
console.log(`[repos-cache] First page has ${repos.length} repos, more available - triggering background pagination`);
|
|
195
|
+
// Fire and forget - reuse the shared background refresh function
|
|
196
|
+
// But start from page 2 with the existing repos
|
|
197
|
+
(async () => {
|
|
198
|
+
try {
|
|
199
|
+
const allRepos = [...repos];
|
|
200
|
+
let page = 2;
|
|
201
|
+
let hasMore = true;
|
|
202
|
+
const MAX_PAGES = 20;
|
|
203
|
+
while (hasMore && page <= MAX_PAGES) {
|
|
204
|
+
const result = await nangoService.listUserAccessibleRepos(nangoConnectionId, {
|
|
205
|
+
perPage: 100,
|
|
206
|
+
page,
|
|
207
|
+
type: 'all',
|
|
208
|
+
});
|
|
209
|
+
allRepos.push(...result.repositories.map(r => ({
|
|
210
|
+
fullName: r.fullName,
|
|
211
|
+
permissions: r.permissions,
|
|
212
|
+
})));
|
|
213
|
+
hasMore = result.hasMore;
|
|
214
|
+
page++;
|
|
215
|
+
if (hasMore) {
|
|
216
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
console.log(`[repos-cache] Background pagination complete: ${allRepos.length} total repos`);
|
|
220
|
+
userReposCache.set(nangoConnectionId, {
|
|
221
|
+
repositories: allRepos,
|
|
222
|
+
cachedAt: Date.now(),
|
|
223
|
+
isComplete: true,
|
|
224
|
+
refreshInProgress: false,
|
|
225
|
+
});
|
|
226
|
+
evictOldestCacheEntries();
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
console.error('[repos-cache] Background pagination failed:', err);
|
|
230
|
+
const existing = userReposCache.get(nangoConnectionId);
|
|
231
|
+
if (existing) {
|
|
232
|
+
existing.refreshInProgress = false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
})();
|
|
236
|
+
}
|
|
237
|
+
return repos;
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
initializingConnections.delete(nangoConnectionId);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// ============================================================================
|
|
244
|
+
// Workspace Access Middleware
|
|
245
|
+
// ============================================================================
|
|
246
|
+
/**
|
|
247
|
+
* Check if user has access to a workspace via:
|
|
248
|
+
* 1. Workspace ownership (userId matches)
|
|
249
|
+
* 2. Explicit workspace_members record
|
|
250
|
+
* 3. GitHub repo access (just-in-time check via Nango)
|
|
251
|
+
*/
|
|
252
|
+
export async function checkWorkspaceAccess(userId, workspaceId) {
|
|
253
|
+
// Check cache first
|
|
254
|
+
const cached = getCachedAccess(userId, workspaceId);
|
|
255
|
+
if (cached) {
|
|
256
|
+
return { hasAccess: cached.hasAccess, accessType: cached.accessType, permission: cached.permission };
|
|
257
|
+
}
|
|
258
|
+
// 1. Check if user is workspace owner
|
|
259
|
+
const workspace = await db.workspaces.findById(workspaceId);
|
|
260
|
+
if (!workspace) {
|
|
261
|
+
return { hasAccess: false, accessType: 'none' };
|
|
262
|
+
}
|
|
263
|
+
if (workspace.userId === userId) {
|
|
264
|
+
setCachedAccess(userId, workspaceId, { hasAccess: true, accessType: 'owner', permission: 'admin' });
|
|
265
|
+
return { hasAccess: true, accessType: 'owner', permission: 'admin' };
|
|
266
|
+
}
|
|
267
|
+
// 2. Check explicit workspace_members
|
|
268
|
+
const member = await db.workspaceMembers.findMembership(workspaceId, userId);
|
|
269
|
+
if (member && member.acceptedAt) {
|
|
270
|
+
const permission = member.role === 'admin' ? 'admin' : member.role === 'member' ? 'write' : 'read';
|
|
271
|
+
setCachedAccess(userId, workspaceId, { hasAccess: true, accessType: 'member', permission });
|
|
272
|
+
return { hasAccess: true, accessType: 'member', permission };
|
|
273
|
+
}
|
|
274
|
+
// 3. Check GitHub repo access (just-in-time)
|
|
275
|
+
const user = await db.users.findById(userId);
|
|
276
|
+
if (!user?.nangoConnectionId) {
|
|
277
|
+
setCachedAccess(userId, workspaceId, { hasAccess: false, accessType: 'none' });
|
|
278
|
+
return { hasAccess: false, accessType: 'none' };
|
|
279
|
+
}
|
|
280
|
+
const repos = await db.repositories.findByWorkspaceId(workspaceId);
|
|
281
|
+
if (repos.length === 0) {
|
|
282
|
+
setCachedAccess(userId, workspaceId, { hasAccess: false, accessType: 'none' });
|
|
283
|
+
return { hasAccess: false, accessType: 'none' };
|
|
284
|
+
}
|
|
285
|
+
// Check if user has access to ANY repo in this workspace
|
|
286
|
+
for (const repo of repos) {
|
|
287
|
+
try {
|
|
288
|
+
const [owner, repoName] = repo.githubFullName.split('/');
|
|
289
|
+
const accessResult = await nangoService.checkUserRepoAccess(user.nangoConnectionId, owner, repoName);
|
|
290
|
+
if (accessResult.hasAccess && accessResult.permission && accessResult.permission !== 'none') {
|
|
291
|
+
setCachedAccess(userId, workspaceId, {
|
|
292
|
+
hasAccess: true,
|
|
293
|
+
accessType: 'contributor',
|
|
294
|
+
permission: accessResult.permission
|
|
295
|
+
});
|
|
296
|
+
return { hasAccess: true, accessType: 'contributor', permission: accessResult.permission };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
// Continue to next repo on error
|
|
301
|
+
console.warn(`[workspace-access] Failed to check repo access for ${repo.githubFullName}:`, err);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// No access found
|
|
305
|
+
setCachedAccess(userId, workspaceId, { hasAccess: false, accessType: 'none' });
|
|
306
|
+
return { hasAccess: false, accessType: 'none' };
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Middleware to require workspace access.
|
|
310
|
+
* Checks ownership, membership, or GitHub repo access.
|
|
311
|
+
*/
|
|
312
|
+
export function requireWorkspaceAccess(req, res, next) {
|
|
313
|
+
const userId = req.session.userId;
|
|
314
|
+
const workspaceId = req.params.id || req.params.workspaceId;
|
|
315
|
+
if (!userId) {
|
|
316
|
+
res.status(401).json({ error: 'Authentication required' });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (!workspaceId) {
|
|
320
|
+
res.status(400).json({ error: 'Workspace ID required' });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
checkWorkspaceAccess(userId, workspaceId)
|
|
324
|
+
.then((result) => {
|
|
325
|
+
if (result.hasAccess) {
|
|
326
|
+
// Attach access info to request for downstream use
|
|
327
|
+
req.workspaceAccess = {
|
|
328
|
+
accessType: result.accessType,
|
|
329
|
+
permission: result.permission,
|
|
330
|
+
};
|
|
331
|
+
next();
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
res.status(403).json({ error: 'No access to this workspace' });
|
|
335
|
+
}
|
|
336
|
+
})
|
|
337
|
+
.catch((err) => {
|
|
338
|
+
console.error('[workspace-access] Error checking access:', err);
|
|
339
|
+
res.status(500).json({ error: 'Failed to check workspace access' });
|
|
340
|
+
});
|
|
341
|
+
}
|
|
11
342
|
export const workspacesRouter = Router();
|
|
12
343
|
// All routes require authentication
|
|
13
344
|
workspacesRouter.use(requireAuth);
|
|
14
345
|
/**
|
|
15
346
|
* GET /api/workspaces
|
|
16
|
-
* List user's workspaces
|
|
347
|
+
* List user's workspaces (owned + member workspaces)
|
|
17
348
|
*/
|
|
18
349
|
workspacesRouter.get('/', async (req, res) => {
|
|
19
350
|
const userId = req.session.userId;
|
|
20
351
|
try {
|
|
21
|
-
|
|
352
|
+
// Get owned workspaces
|
|
353
|
+
const ownedWorkspaces = await db.workspaces.findByUserId(userId);
|
|
354
|
+
// Get workspaces where user is a member
|
|
355
|
+
const memberships = await db.workspaceMembers.findByUserId(userId);
|
|
356
|
+
const ownedWorkspaceIds = new Set(ownedWorkspaces.map((w) => w.id));
|
|
357
|
+
const memberWorkspaceIds = memberships
|
|
358
|
+
.map((m) => m.workspaceId)
|
|
359
|
+
.filter((wsId) => !ownedWorkspaceIds.has(wsId)); // Exclude owned to prevent duplicates
|
|
360
|
+
// Fetch member workspaces (optimize with Promise.all instead of loop)
|
|
361
|
+
const memberWorkspaces = (await Promise.all(memberWorkspaceIds.map((wsId) => db.workspaces.findById(wsId)))).filter((ws) => ws !== null);
|
|
362
|
+
// Combine and sort by creation date
|
|
363
|
+
const allWorkspaces = [...ownedWorkspaces, ...memberWorkspaces].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
22
364
|
res.json({
|
|
23
|
-
workspaces:
|
|
365
|
+
workspaces: allWorkspaces.map((w) => ({
|
|
24
366
|
id: w.id,
|
|
25
367
|
name: w.name,
|
|
26
368
|
status: w.status,
|
|
@@ -28,6 +370,7 @@ workspacesRouter.get('/', async (req, res) => {
|
|
|
28
370
|
providers: w.config.providers,
|
|
29
371
|
repositories: w.config.repositories,
|
|
30
372
|
createdAt: w.createdAt,
|
|
373
|
+
isOwner: w.userId === userId, // Flag to indicate ownership
|
|
31
374
|
})),
|
|
32
375
|
});
|
|
33
376
|
}
|
|
@@ -53,6 +396,33 @@ workspacesRouter.post('/', checkWorkspaceLimit, async (req, res) => {
|
|
|
53
396
|
if (!repositories || !Array.isArray(repositories)) {
|
|
54
397
|
return res.status(400).json({ error: 'Repositories array is required' });
|
|
55
398
|
}
|
|
399
|
+
// Check if any of the repos already have a workspace the user can access
|
|
400
|
+
// This prevents creating duplicate workspaces for the same repo
|
|
401
|
+
for (const repoFullName of repositories) {
|
|
402
|
+
const existingRepos = await db.repositories.findByGithubFullName(repoFullName);
|
|
403
|
+
for (const existingRepo of existingRepos) {
|
|
404
|
+
if (existingRepo.workspaceId) {
|
|
405
|
+
const accessResult = await checkWorkspaceAccess(userId, existingRepo.workspaceId);
|
|
406
|
+
if (accessResult.hasAccess) {
|
|
407
|
+
const existingWorkspace = await db.workspaces.findById(existingRepo.workspaceId);
|
|
408
|
+
if (existingWorkspace) {
|
|
409
|
+
console.log(`[workspaces/create] User ${userId.substring(0, 8)} has access to existing workspace ${existingWorkspace.id.substring(0, 8)} for repo ${repoFullName}`);
|
|
410
|
+
return res.status(409).json({
|
|
411
|
+
error: 'A workspace already exists for one of these repositories',
|
|
412
|
+
existingWorkspace: {
|
|
413
|
+
id: existingWorkspace.id,
|
|
414
|
+
name: existingWorkspace.name,
|
|
415
|
+
publicUrl: existingWorkspace.publicUrl,
|
|
416
|
+
accessType: accessResult.accessType,
|
|
417
|
+
},
|
|
418
|
+
conflictingRepo: repoFullName,
|
|
419
|
+
message: `You already have ${accessResult.accessType} access to workspace "${existingWorkspace.name}" which includes ${repoFullName}.`,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
56
426
|
// Verify user has credentials for all providers
|
|
57
427
|
const credentials = await db.credentials.findByUserId(userId);
|
|
58
428
|
const connectedProviders = new Set(credentials.map((c) => c.provider));
|
|
@@ -92,13 +462,23 @@ workspacesRouter.post('/', checkWorkspaceLimit, async (req, res) => {
|
|
|
92
462
|
});
|
|
93
463
|
/**
|
|
94
464
|
* GET /api/workspaces/summary
|
|
95
|
-
* Get summary of all user workspaces for dashboard status indicator
|
|
465
|
+
* Get summary of all user workspaces for dashboard status indicator (owned + member workspaces)
|
|
96
466
|
* NOTE: This route MUST be before /:id to avoid being caught by parameterized route
|
|
97
467
|
*/
|
|
98
468
|
workspacesRouter.get('/summary', async (req, res) => {
|
|
99
469
|
const userId = req.session.userId;
|
|
100
470
|
try {
|
|
101
|
-
|
|
471
|
+
// Get owned workspaces
|
|
472
|
+
const ownedWorkspaces = await db.workspaces.findByUserId(userId);
|
|
473
|
+
// Get workspaces where user is a member
|
|
474
|
+
const memberships = await db.workspaceMembers.findByUserId(userId);
|
|
475
|
+
const ownedWorkspaceIds = new Set(ownedWorkspaces.map((w) => w.id));
|
|
476
|
+
const memberWorkspaceIds = memberships
|
|
477
|
+
.map((m) => m.workspaceId)
|
|
478
|
+
.filter((wsId) => !ownedWorkspaceIds.has(wsId));
|
|
479
|
+
// Fetch member workspaces (optimize with Promise.all)
|
|
480
|
+
const memberWorkspaces = (await Promise.all(memberWorkspaceIds.map((wsId) => db.workspaces.findById(wsId)))).filter((ws) => ws !== null);
|
|
481
|
+
const workspaces = [...ownedWorkspaces, ...memberWorkspaces];
|
|
102
482
|
const provisioner = getProvisioner();
|
|
103
483
|
// Get live status for each workspace
|
|
104
484
|
const workspaceSummaries = await Promise.all(workspaces.map(async (w) => {
|
|
@@ -151,14 +531,24 @@ workspacesRouter.get('/summary', async (req, res) => {
|
|
|
151
531
|
});
|
|
152
532
|
/**
|
|
153
533
|
* GET /api/workspaces/primary
|
|
154
|
-
* Get the user's primary workspace (first/default) with live status
|
|
534
|
+
* Get the user's primary workspace (first/default) with live status (owned + member workspaces)
|
|
155
535
|
* Used by dashboard to show quick status indicator
|
|
156
536
|
* NOTE: This route MUST be before /:id to avoid being caught by parameterized route
|
|
157
537
|
*/
|
|
158
538
|
workspacesRouter.get('/primary', async (req, res) => {
|
|
159
539
|
const userId = req.session.userId;
|
|
160
540
|
try {
|
|
161
|
-
|
|
541
|
+
// Get owned workspaces
|
|
542
|
+
const ownedWorkspaces = await db.workspaces.findByUserId(userId);
|
|
543
|
+
// Get workspaces where user is a member
|
|
544
|
+
const memberships = await db.workspaceMembers.findByUserId(userId);
|
|
545
|
+
const ownedWorkspaceIds = new Set(ownedWorkspaces.map((w) => w.id));
|
|
546
|
+
const memberWorkspaceIds = memberships
|
|
547
|
+
.map((m) => m.workspaceId)
|
|
548
|
+
.filter((wsId) => !ownedWorkspaceIds.has(wsId));
|
|
549
|
+
// Fetch member workspaces (optimize with Promise.all)
|
|
550
|
+
const memberWorkspaces = (await Promise.all(memberWorkspaceIds.map((wsId) => db.workspaces.findById(wsId)))).filter((ws) => ws !== null);
|
|
551
|
+
const workspaces = [...ownedWorkspaces, ...memberWorkspaces];
|
|
162
552
|
if (workspaces.length === 0) {
|
|
163
553
|
return res.json({
|
|
164
554
|
exists: false,
|
|
@@ -210,21 +600,147 @@ workspacesRouter.get('/primary', async (req, res) => {
|
|
|
210
600
|
res.status(500).json({ error: 'Failed to get primary workspace' });
|
|
211
601
|
}
|
|
212
602
|
});
|
|
603
|
+
/**
|
|
604
|
+
* GET /api/workspaces/accessible
|
|
605
|
+
* List all workspaces the user can access:
|
|
606
|
+
* - Owned workspaces
|
|
607
|
+
* - Workspaces where user is a member
|
|
608
|
+
* - Workspaces with repos the user has GitHub access to
|
|
609
|
+
* NOTE: This route MUST be before /:id to avoid being caught by parameterized route
|
|
610
|
+
*/
|
|
611
|
+
workspacesRouter.get('/accessible', async (req, res) => {
|
|
612
|
+
const userId = req.session.userId;
|
|
613
|
+
try {
|
|
614
|
+
const user = await db.users.findById(userId);
|
|
615
|
+
if (!user) {
|
|
616
|
+
return res.status(404).json({ error: 'User not found' });
|
|
617
|
+
}
|
|
618
|
+
// 1. Get owned workspaces
|
|
619
|
+
const ownedWorkspaces = await db.workspaces.findByUserId(userId);
|
|
620
|
+
// 2. Get workspaces where user is a member (excluding owned ones to prevent duplicates)
|
|
621
|
+
const ownedWorkspaceIds = new Set(ownedWorkspaces.map((w) => w.id));
|
|
622
|
+
const memberships = await db.workspaceMembers.findByUserId(userId);
|
|
623
|
+
const memberWorkspaceIds = memberships
|
|
624
|
+
.map((m) => m.workspaceId)
|
|
625
|
+
.filter((wsId) => !ownedWorkspaceIds.has(wsId)); // Exclude owned workspaces
|
|
626
|
+
// Fetch member workspaces
|
|
627
|
+
const memberWorkspaces = [];
|
|
628
|
+
for (const wsId of memberWorkspaceIds) {
|
|
629
|
+
const ws = await db.workspaces.findById(wsId);
|
|
630
|
+
if (ws)
|
|
631
|
+
memberWorkspaces.push(ws);
|
|
632
|
+
}
|
|
633
|
+
// 3. Get workspaces via GitHub repo access (if user has Nango connection)
|
|
634
|
+
// Uses background caching to handle users with many repos (>100)
|
|
635
|
+
const contributorWorkspaces = [];
|
|
636
|
+
let cacheStatus = 'miss';
|
|
637
|
+
if (user.nangoConnectionId) {
|
|
638
|
+
try {
|
|
639
|
+
console.log(`[workspaces/accessible] Checking GitHub repo access for user ${userId.substring(0, 8)} with nangoConnectionId ${user.nangoConnectionId.substring(0, 8)}...`);
|
|
640
|
+
// Try to get cached repos first
|
|
641
|
+
let userRepos;
|
|
642
|
+
const cached = getCachedUserRepos(user.nangoConnectionId);
|
|
643
|
+
if (cached) {
|
|
644
|
+
userRepos = cached.repositories;
|
|
645
|
+
cacheStatus = 'hit';
|
|
646
|
+
console.log(`[workspaces/accessible] Cache ${cached.isComplete ? 'hit (complete)' : 'hit (partial)'}: ${userRepos.length} repos`);
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
// No cache - initialize with first page and trigger background refresh
|
|
650
|
+
userRepos = await initializeUserReposCache(user.nangoConnectionId);
|
|
651
|
+
cacheStatus = 'initializing';
|
|
652
|
+
console.log(`[workspaces/accessible] Cache miss - initialized with ${userRepos.length} repos (background refresh may add more)`);
|
|
653
|
+
}
|
|
654
|
+
// Get workspaces that aren't owned or membered
|
|
655
|
+
// Reuse ownedWorkspaceIds and add member workspace IDs
|
|
656
|
+
const knownWorkspaceIds = new Set([
|
|
657
|
+
...ownedWorkspaceIds,
|
|
658
|
+
...memberWorkspaceIds,
|
|
659
|
+
]);
|
|
660
|
+
// Get all repo full names from user's accessible repos
|
|
661
|
+
for (const repo of userRepos) {
|
|
662
|
+
// Find repos in our DB that match this full name (case-insensitive)
|
|
663
|
+
const dbRepos = await db.repositories.findByGithubFullName(repo.fullName);
|
|
664
|
+
if (dbRepos.length > 0) {
|
|
665
|
+
console.log(`[workspaces/accessible] Found ${dbRepos.length} DB records for repo ${repo.fullName}`);
|
|
666
|
+
}
|
|
667
|
+
for (const dbRepo of dbRepos) {
|
|
668
|
+
if (dbRepo.workspaceId && !knownWorkspaceIds.has(dbRepo.workspaceId)) {
|
|
669
|
+
const ws = await db.workspaces.findById(dbRepo.workspaceId);
|
|
670
|
+
if (ws) {
|
|
671
|
+
console.log(`[workspaces/accessible] Granting contributor access to workspace ${ws.id.substring(0, 8)} via repo ${repo.fullName}`);
|
|
672
|
+
// Determine permission level
|
|
673
|
+
const permission = repo.permissions.admin
|
|
674
|
+
? 'admin'
|
|
675
|
+
: repo.permissions.push
|
|
676
|
+
? 'write'
|
|
677
|
+
: 'read';
|
|
678
|
+
contributorWorkspaces.push({ ...ws, accessPermission: permission });
|
|
679
|
+
knownWorkspaceIds.add(ws.id);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
else if (!dbRepo.workspaceId) {
|
|
683
|
+
console.log(`[workspaces/accessible] Repo ${repo.fullName} found in DB but has no workspaceId`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
console.log(`[workspaces/accessible] Found ${contributorWorkspaces.length} contributor workspaces (cache: ${cacheStatus})`);
|
|
688
|
+
}
|
|
689
|
+
catch (err) {
|
|
690
|
+
console.warn('[workspaces/accessible] Failed to check GitHub repo access:', err);
|
|
691
|
+
// Continue without contributor workspaces
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
console.log(`[workspaces/accessible] User ${userId.substring(0, 8)} has no nangoConnectionId - skipping GitHub repo access check`);
|
|
696
|
+
}
|
|
697
|
+
// Format response - include all fields the dashboard expects
|
|
698
|
+
const formatWorkspace = (ws, accessType, permission) => ({
|
|
699
|
+
id: ws.id,
|
|
700
|
+
name: ws.name,
|
|
701
|
+
status: ws.status,
|
|
702
|
+
publicUrl: ws.publicUrl,
|
|
703
|
+
providers: ws.config?.providers,
|
|
704
|
+
repositories: ws.config?.repositories,
|
|
705
|
+
accessType,
|
|
706
|
+
permission: permission || (accessType === 'owner' ? 'admin' : 'read'),
|
|
707
|
+
createdAt: ws.createdAt,
|
|
708
|
+
});
|
|
709
|
+
res.json({
|
|
710
|
+
workspaces: [
|
|
711
|
+
...ownedWorkspaces.map((w) => formatWorkspace(w, 'owner', 'admin')),
|
|
712
|
+
...memberWorkspaces.map((w) => {
|
|
713
|
+
const membership = memberships.find((m) => m.workspaceId === w.id);
|
|
714
|
+
return formatWorkspace(w, 'member', membership?.role);
|
|
715
|
+
}),
|
|
716
|
+
...contributorWorkspaces.map((w) => formatWorkspace(w, 'contributor', w.accessPermission)),
|
|
717
|
+
],
|
|
718
|
+
summary: {
|
|
719
|
+
owned: ownedWorkspaces.length,
|
|
720
|
+
member: memberWorkspaces.length,
|
|
721
|
+
contributor: contributorWorkspaces.length,
|
|
722
|
+
total: ownedWorkspaces.length + memberWorkspaces.length + contributorWorkspaces.length,
|
|
723
|
+
},
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
catch (error) {
|
|
727
|
+
console.error('Error getting accessible workspaces:', error);
|
|
728
|
+
res.status(500).json({ error: 'Failed to get accessible workspaces' });
|
|
729
|
+
}
|
|
730
|
+
});
|
|
213
731
|
/**
|
|
214
732
|
* GET /api/workspaces/:id
|
|
215
733
|
* Get workspace details
|
|
734
|
+
* Uses requireWorkspaceAccess middleware for auto-access via GitHub repos
|
|
216
735
|
*/
|
|
217
|
-
workspacesRouter.get('/:id', async (req, res) => {
|
|
218
|
-
const userId = req.session.userId;
|
|
736
|
+
workspacesRouter.get('/:id', requireWorkspaceAccess, async (req, res) => {
|
|
219
737
|
const { id } = req.params;
|
|
738
|
+
const _workspaceAccess = req.workspaceAccess;
|
|
220
739
|
try {
|
|
221
740
|
const workspace = await db.workspaces.findById(id);
|
|
222
741
|
if (!workspace) {
|
|
223
742
|
return res.status(404).json({ error: 'Workspace not found' });
|
|
224
743
|
}
|
|
225
|
-
if (workspace.userId !== userId) {
|
|
226
|
-
return res.status(403).json({ error: 'Unauthorized' });
|
|
227
|
-
}
|
|
228
744
|
// Get repositories assigned to this workspace
|
|
229
745
|
const repositories = await db.repositories.findByWorkspaceId(id);
|
|
230
746
|
res.json({
|
|
@@ -267,7 +783,17 @@ workspacesRouter.get('/:id/status', async (req, res) => {
|
|
|
267
783
|
}
|
|
268
784
|
const provisioner = getProvisioner();
|
|
269
785
|
const status = await provisioner.getStatus(id);
|
|
270
|
-
|
|
786
|
+
// Include provisioning progress info if it exists (even after status changes to 'running')
|
|
787
|
+
// This allows the frontend to see all stages including 'complete'
|
|
788
|
+
const provisioningProgress = getProvisioningStage(id);
|
|
789
|
+
res.json({
|
|
790
|
+
status,
|
|
791
|
+
provisioning: provisioningProgress ? {
|
|
792
|
+
stage: provisioningProgress.stage,
|
|
793
|
+
startedAt: provisioningProgress.startedAt,
|
|
794
|
+
elapsedMs: Date.now() - provisioningProgress.startedAt,
|
|
795
|
+
} : null,
|
|
796
|
+
});
|
|
271
797
|
}
|
|
272
798
|
catch (error) {
|
|
273
799
|
console.error('Error getting workspace status:', error);
|
|
@@ -376,6 +902,307 @@ workspacesRouter.post('/:id/repos', async (req, res) => {
|
|
|
376
902
|
res.status(500).json({ error: 'Failed to add repositories' });
|
|
377
903
|
}
|
|
378
904
|
});
|
|
905
|
+
/**
|
|
906
|
+
* GET /api/workspaces/:id/repos
|
|
907
|
+
* List repositories linked to a workspace
|
|
908
|
+
*/
|
|
909
|
+
workspacesRouter.get('/:id/repos', async (req, res) => {
|
|
910
|
+
const userId = req.session.userId;
|
|
911
|
+
const { id } = req.params;
|
|
912
|
+
try {
|
|
913
|
+
const workspace = await db.workspaces.findById(id);
|
|
914
|
+
if (!workspace) {
|
|
915
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
916
|
+
}
|
|
917
|
+
// Check access (owner, member, or contributor)
|
|
918
|
+
const accessResult = await checkWorkspaceAccess(userId, id);
|
|
919
|
+
if (!accessResult.hasAccess) {
|
|
920
|
+
return res.status(403).json({ error: 'Unauthorized' });
|
|
921
|
+
}
|
|
922
|
+
// Get repos linked to this workspace
|
|
923
|
+
const repos = await db.repositories.findByWorkspaceId(id);
|
|
924
|
+
res.json({
|
|
925
|
+
repositories: repos.map(r => ({
|
|
926
|
+
id: r.id,
|
|
927
|
+
githubFullName: r.githubFullName,
|
|
928
|
+
defaultBranch: r.defaultBranch,
|
|
929
|
+
isPrivate: r.isPrivate,
|
|
930
|
+
syncStatus: r.syncStatus,
|
|
931
|
+
lastSyncedAt: r.lastSyncedAt,
|
|
932
|
+
})),
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
catch (error) {
|
|
936
|
+
console.error('Error listing workspace repos:', error);
|
|
937
|
+
res.status(500).json({ error: 'Failed to list repositories' });
|
|
938
|
+
}
|
|
939
|
+
});
|
|
940
|
+
/**
|
|
941
|
+
* GET /api/workspaces/:id/repo-collaborators
|
|
942
|
+
* Get all collaborators from repos linked to this workspace
|
|
943
|
+
* These are users who have access via GitHub repo permissions (grandfathered in)
|
|
944
|
+
*/
|
|
945
|
+
workspacesRouter.get('/:id/repo-collaborators', async (req, res) => {
|
|
946
|
+
const userId = req.session.userId;
|
|
947
|
+
const { id } = req.params;
|
|
948
|
+
try {
|
|
949
|
+
const workspace = await db.workspaces.findById(id);
|
|
950
|
+
if (!workspace) {
|
|
951
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
952
|
+
}
|
|
953
|
+
// Check access (owner, member, or contributor)
|
|
954
|
+
const accessResult = await checkWorkspaceAccess(userId, id);
|
|
955
|
+
if (!accessResult.hasAccess) {
|
|
956
|
+
return res.status(403).json({ error: 'Unauthorized' });
|
|
957
|
+
}
|
|
958
|
+
// Get repos linked to this workspace
|
|
959
|
+
const repos = await db.repositories.findByWorkspaceId(id);
|
|
960
|
+
if (repos.length === 0) {
|
|
961
|
+
return res.json({ collaborators: [] });
|
|
962
|
+
}
|
|
963
|
+
// Find a repo with a Nango connection (GitHub App)
|
|
964
|
+
const repoWithConnection = repos.find(r => r.nangoConnectionId);
|
|
965
|
+
if (!repoWithConnection?.nangoConnectionId) {
|
|
966
|
+
return res.json({
|
|
967
|
+
collaborators: [],
|
|
968
|
+
message: 'GitHub App not connected for this workspace',
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
// Get the workspace owner for filtering
|
|
972
|
+
const owner = await db.users.findById(workspace.userId);
|
|
973
|
+
// Fetch collaborators for each repo and deduplicate
|
|
974
|
+
const collaboratorsMap = new Map();
|
|
975
|
+
// Get existing workspace members to exclude them
|
|
976
|
+
const existingMembers = await db.workspaceMembers.findByWorkspaceId(id);
|
|
977
|
+
// Also get the workspace owner's GitHub ID to exclude
|
|
978
|
+
const ownerGithubId = owner?.githubId ? Number(owner.githubId) : null;
|
|
979
|
+
for (const repo of repos) {
|
|
980
|
+
// Use this repo's connection if it has one, otherwise use the shared connection
|
|
981
|
+
const connectionId = repo.nangoConnectionId || repoWithConnection.nangoConnectionId;
|
|
982
|
+
if (!connectionId)
|
|
983
|
+
continue;
|
|
984
|
+
try {
|
|
985
|
+
const [repoOwner, repoName] = repo.githubFullName.split('/');
|
|
986
|
+
const collabs = await nangoService.listRepoCollaborators(connectionId, repoOwner, repoName);
|
|
987
|
+
for (const collab of collabs) {
|
|
988
|
+
// Skip the workspace owner
|
|
989
|
+
if (ownerGithubId && collab.id === ownerGithubId) {
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
const existing = collaboratorsMap.get(collab.id);
|
|
993
|
+
if (existing) {
|
|
994
|
+
// Add this repo to their list
|
|
995
|
+
if (!existing.repos.includes(repo.githubFullName)) {
|
|
996
|
+
existing.repos.push(repo.githubFullName);
|
|
997
|
+
}
|
|
998
|
+
// Upgrade permission if this repo gives higher access
|
|
999
|
+
if (collab.permission === 'admin' && existing.permission !== 'admin') {
|
|
1000
|
+
existing.permission = 'admin';
|
|
1001
|
+
}
|
|
1002
|
+
else if (collab.permission === 'write' && existing.permission === 'read') {
|
|
1003
|
+
existing.permission = 'write';
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
else {
|
|
1007
|
+
collaboratorsMap.set(collab.id, {
|
|
1008
|
+
id: collab.id,
|
|
1009
|
+
login: collab.login,
|
|
1010
|
+
avatarUrl: collab.avatarUrl,
|
|
1011
|
+
permission: collab.permission,
|
|
1012
|
+
repos: [repo.githubFullName],
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
catch (err) {
|
|
1018
|
+
console.warn(`[workspace-collaborators] Failed to fetch collaborators for ${repo.githubFullName}:`, err);
|
|
1019
|
+
// Continue with other repos
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
// Filter out users who are already workspace members
|
|
1023
|
+
// We need to check by GitHub username since we don't have their user IDs
|
|
1024
|
+
const workspaceMemberUsernames = new Set();
|
|
1025
|
+
for (const member of existingMembers) {
|
|
1026
|
+
const memberUser = await db.users.findById(member.userId);
|
|
1027
|
+
if (memberUser?.githubUsername) {
|
|
1028
|
+
workspaceMemberUsernames.add(memberUser.githubUsername.toLowerCase());
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
// Also add workspace owner
|
|
1032
|
+
if (owner?.githubUsername) {
|
|
1033
|
+
workspaceMemberUsernames.add(owner.githubUsername.toLowerCase());
|
|
1034
|
+
}
|
|
1035
|
+
const collaborators = Array.from(collaboratorsMap.values())
|
|
1036
|
+
.filter(c => !workspaceMemberUsernames.has(c.login.toLowerCase()))
|
|
1037
|
+
.sort((a, b) => {
|
|
1038
|
+
// Sort by permission level (admin > write > read), then by username
|
|
1039
|
+
const permOrder = { admin: 0, write: 1, read: 2, none: 3 };
|
|
1040
|
+
if (permOrder[a.permission] !== permOrder[b.permission]) {
|
|
1041
|
+
return permOrder[a.permission] - permOrder[b.permission];
|
|
1042
|
+
}
|
|
1043
|
+
return a.login.localeCompare(b.login);
|
|
1044
|
+
});
|
|
1045
|
+
res.json({
|
|
1046
|
+
collaborators,
|
|
1047
|
+
totalRepos: repos.length,
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
catch (error) {
|
|
1051
|
+
console.error('Error fetching repo collaborators:', error);
|
|
1052
|
+
res.status(500).json({ error: 'Failed to fetch collaborators' });
|
|
1053
|
+
}
|
|
1054
|
+
});
|
|
1055
|
+
/**
|
|
1056
|
+
* DELETE /api/workspaces/:id/repos/:repoId
|
|
1057
|
+
* Remove a repository from a workspace
|
|
1058
|
+
*/
|
|
1059
|
+
workspacesRouter.delete('/:id/repos/:repoId', async (req, res) => {
|
|
1060
|
+
const userId = req.session.userId;
|
|
1061
|
+
const { id, repoId } = req.params;
|
|
1062
|
+
try {
|
|
1063
|
+
const workspace = await db.workspaces.findById(id);
|
|
1064
|
+
if (!workspace) {
|
|
1065
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
1066
|
+
}
|
|
1067
|
+
// Only owner can remove repos
|
|
1068
|
+
if (workspace.userId !== userId) {
|
|
1069
|
+
return res.status(403).json({ error: 'Only workspace owner can remove repositories' });
|
|
1070
|
+
}
|
|
1071
|
+
// Unlink repo from workspace (set workspaceId to null)
|
|
1072
|
+
await db.repositories.assignToWorkspace(repoId, null);
|
|
1073
|
+
// Also update workspace config to remove from repositories array
|
|
1074
|
+
const currentRepos = workspace.config.repositories || [];
|
|
1075
|
+
const repo = await db.repositories.findById(repoId);
|
|
1076
|
+
if (repo) {
|
|
1077
|
+
const updatedRepos = currentRepos.filter(r => r.toLowerCase() !== repo.githubFullName.toLowerCase());
|
|
1078
|
+
await db.workspaces.update(id, {
|
|
1079
|
+
config: { ...workspace.config, repositories: updatedRepos },
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
res.json({ success: true, message: 'Repository removed from workspace' });
|
|
1083
|
+
}
|
|
1084
|
+
catch (error) {
|
|
1085
|
+
console.error('Error removing repo from workspace:', error);
|
|
1086
|
+
res.status(500).json({ error: 'Failed to remove repository' });
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
/**
|
|
1090
|
+
* PATCH /api/workspaces/:id
|
|
1091
|
+
* Update workspace settings (name, etc.)
|
|
1092
|
+
*/
|
|
1093
|
+
workspacesRouter.patch('/:id', async (req, res) => {
|
|
1094
|
+
const userId = req.session.userId;
|
|
1095
|
+
const { id } = req.params;
|
|
1096
|
+
const { name } = req.body;
|
|
1097
|
+
try {
|
|
1098
|
+
const workspace = await db.workspaces.findById(id);
|
|
1099
|
+
if (!workspace) {
|
|
1100
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
1101
|
+
}
|
|
1102
|
+
// Only owner can rename
|
|
1103
|
+
if (workspace.userId !== userId) {
|
|
1104
|
+
return res.status(403).json({ error: 'Only workspace owner can update settings' });
|
|
1105
|
+
}
|
|
1106
|
+
// Validate name if provided
|
|
1107
|
+
if (name !== undefined) {
|
|
1108
|
+
if (typeof name !== 'string' || name.trim().length === 0) {
|
|
1109
|
+
return res.status(400).json({ error: 'Name must be a non-empty string' });
|
|
1110
|
+
}
|
|
1111
|
+
if (name.length > 100) {
|
|
1112
|
+
return res.status(400).json({ error: 'Name must be 100 characters or less' });
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
// Update workspace
|
|
1116
|
+
await db.workspaces.update(id, {
|
|
1117
|
+
...(name && { name: name.trim() }),
|
|
1118
|
+
});
|
|
1119
|
+
const updated = await db.workspaces.findById(id);
|
|
1120
|
+
res.json({
|
|
1121
|
+
success: true,
|
|
1122
|
+
workspace: {
|
|
1123
|
+
id: updated.id,
|
|
1124
|
+
name: updated.name,
|
|
1125
|
+
status: updated.status,
|
|
1126
|
+
publicUrl: updated.publicUrl,
|
|
1127
|
+
},
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
catch (error) {
|
|
1131
|
+
console.error('Error updating workspace:', error);
|
|
1132
|
+
res.status(500).json({ error: 'Failed to update workspace' });
|
|
1133
|
+
}
|
|
1134
|
+
});
|
|
1135
|
+
/**
|
|
1136
|
+
* POST /api/workspaces/:id/autoscale
|
|
1137
|
+
* Trigger auto-scaling based on current agent count
|
|
1138
|
+
* Supports both user session auth and workspace token auth
|
|
1139
|
+
* Called by workspace container when spawning new agents
|
|
1140
|
+
*/
|
|
1141
|
+
workspacesRouter.post('/:id/autoscale', async (req, res) => {
|
|
1142
|
+
const { id } = req.params;
|
|
1143
|
+
const { agentCount } = req.body;
|
|
1144
|
+
if (typeof agentCount !== 'number' || agentCount < 0) {
|
|
1145
|
+
return res.status(400).json({ error: 'agentCount must be a non-negative number' });
|
|
1146
|
+
}
|
|
1147
|
+
try {
|
|
1148
|
+
const workspace = await db.workspaces.findById(id);
|
|
1149
|
+
if (!workspace) {
|
|
1150
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
1151
|
+
}
|
|
1152
|
+
// Verify auth: either user session or workspace token
|
|
1153
|
+
const userId = req.session?.userId;
|
|
1154
|
+
const authHeader = req.get('authorization');
|
|
1155
|
+
if (userId) {
|
|
1156
|
+
// User session auth
|
|
1157
|
+
if (workspace.userId !== userId) {
|
|
1158
|
+
return res.status(403).json({ error: 'Unauthorized' });
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
else if (authHeader?.startsWith('Bearer ')) {
|
|
1162
|
+
// Workspace token auth (for calls from within the workspace)
|
|
1163
|
+
const crypto = await import('crypto');
|
|
1164
|
+
const config = getConfig();
|
|
1165
|
+
const providedToken = authHeader.slice(7);
|
|
1166
|
+
const expectedToken = crypto.default
|
|
1167
|
+
.createHmac('sha256', config.sessionSecret)
|
|
1168
|
+
.update(`workspace:${id}`)
|
|
1169
|
+
.digest('hex');
|
|
1170
|
+
const isValid = crypto.default.timingSafeEqual(Buffer.from(providedToken), Buffer.from(expectedToken));
|
|
1171
|
+
if (!isValid) {
|
|
1172
|
+
return res.status(401).json({ error: 'Invalid workspace token' });
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
else {
|
|
1176
|
+
return res.status(401).json({ error: 'Authentication required' });
|
|
1177
|
+
}
|
|
1178
|
+
const provisioner = getProvisioner();
|
|
1179
|
+
const currentTier = await provisioner.getCurrentTier(id);
|
|
1180
|
+
const recommendedTier = provisioner.getRecommendedTier(agentCount);
|
|
1181
|
+
// Check if scaling is needed
|
|
1182
|
+
if (recommendedTier.memoryMb <= currentTier.memoryMb) {
|
|
1183
|
+
return res.json({
|
|
1184
|
+
scaled: false,
|
|
1185
|
+
currentTier: currentTier.name,
|
|
1186
|
+
message: 'Current tier is sufficient',
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
// Perform the scale-up (respects plan limits)
|
|
1190
|
+
const result = await provisioner.autoScale(id, agentCount);
|
|
1191
|
+
res.json({
|
|
1192
|
+
scaled: result.scaled,
|
|
1193
|
+
previousTier: result.currentTier || currentTier.name,
|
|
1194
|
+
newTier: result.targetTier || currentTier.name,
|
|
1195
|
+
reason: result.reason,
|
|
1196
|
+
message: result.scaled
|
|
1197
|
+
? `Scaled up to ${result.targetTier} tier`
|
|
1198
|
+
: result.reason || 'Scaling not required',
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
catch (error) {
|
|
1202
|
+
console.error('Error auto-scaling workspace:', error);
|
|
1203
|
+
res.status(500).json({ error: 'Failed to auto-scale workspace' });
|
|
1204
|
+
}
|
|
1205
|
+
});
|
|
379
1206
|
/**
|
|
380
1207
|
* POST /api/workspaces/:id/domain
|
|
381
1208
|
* Add or update custom domain (Premium feature - Team/Enterprise only)
|
|
@@ -424,7 +1251,7 @@ workspacesRouter.post('/:id/domain', async (req, res) => {
|
|
|
424
1251
|
instructions: {
|
|
425
1252
|
type: 'CNAME',
|
|
426
1253
|
name: domain,
|
|
427
|
-
value: workspace.publicUrl?.replace('https://', '') || `${id}.
|
|
1254
|
+
value: workspace.publicUrl?.replace('https://', '') || `${id}.agent-relay.com`,
|
|
428
1255
|
ttl: 300,
|
|
429
1256
|
},
|
|
430
1257
|
verifyEndpoint: `/api/workspaces/${id}/domain/verify`,
|
|
@@ -458,7 +1285,7 @@ workspacesRouter.post('/:id/domain/verify', async (req, res) => {
|
|
|
458
1285
|
const dns = await import('dns').then(m => m.promises);
|
|
459
1286
|
try {
|
|
460
1287
|
const records = await dns.resolveCname(workspace.customDomain);
|
|
461
|
-
const expectedTarget = workspace.publicUrl?.replace('https://', '') || `${id}.
|
|
1288
|
+
const expectedTarget = workspace.publicUrl?.replace('https://', '') || `${id}.agent-relay.com`;
|
|
462
1289
|
if (records.some(r => r.includes(expectedTarget) || r.includes('agentrelay'))) {
|
|
463
1290
|
// DNS is configured, now provision SSL cert
|
|
464
1291
|
await db.workspaces.updateCustomDomainStatus(id, 'verifying');
|
|
@@ -593,17 +1420,37 @@ workspacesRouter.all('/:id/proxy/{*proxyPath}', async (req, res) => {
|
|
|
593
1420
|
if (!workspace) {
|
|
594
1421
|
return res.status(404).json({ error: 'Workspace not found' });
|
|
595
1422
|
}
|
|
1423
|
+
// Check if user is owner or has workspace membership
|
|
596
1424
|
if (workspace.userId !== userId) {
|
|
597
|
-
|
|
1425
|
+
// Check workspace membership
|
|
1426
|
+
const membership = await db.workspaceMembers.findMembership(id, userId);
|
|
1427
|
+
if (!membership || !membership.acceptedAt) {
|
|
1428
|
+
return res.status(403).json({ error: 'Unauthorized - not a workspace member' });
|
|
1429
|
+
}
|
|
1430
|
+
// Viewers can only proxy read-only requests
|
|
1431
|
+
if (membership.role === 'viewer' && req.method !== 'GET') {
|
|
1432
|
+
return res.status(403).json({ error: 'Viewers can only make read-only requests' });
|
|
1433
|
+
}
|
|
1434
|
+
// Members and admins can read and write
|
|
1435
|
+
// For now, allow all proxy requests for members and admins
|
|
598
1436
|
}
|
|
599
1437
|
if (workspace.status !== 'running' || !workspace.publicUrl) {
|
|
600
1438
|
return res.status(400).json({ error: 'Workspace is not running' });
|
|
601
1439
|
}
|
|
602
1440
|
// Determine the internal URL for proxying
|
|
603
|
-
// When running inside Docker
|
|
1441
|
+
// When running inside Docker or Fly.io, use internal networking
|
|
604
1442
|
let targetBaseUrl = workspace.publicUrl;
|
|
605
1443
|
const runningInDocker = process.env.RUNNING_IN_DOCKER === 'true';
|
|
606
|
-
|
|
1444
|
+
const runningOnFly = !!process.env.FLY_APP_NAME;
|
|
1445
|
+
if (runningOnFly && targetBaseUrl.includes('.fly.dev')) {
|
|
1446
|
+
// Use Fly.io internal networking (.internal uses IPv6, works by default)
|
|
1447
|
+
// ar-583f273b.fly.dev -> http://ar-583f273b.internal:3888
|
|
1448
|
+
const appName = targetBaseUrl.match(/https?:\/\/([^.]+)\.fly\.dev/)?.[1];
|
|
1449
|
+
if (appName) {
|
|
1450
|
+
targetBaseUrl = `http://${appName}.internal:3888`;
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
else if (runningInDocker && workspace.computeId && targetBaseUrl.includes('localhost')) {
|
|
607
1454
|
// Replace localhost URL with container name for Docker networking
|
|
608
1455
|
// workspace.computeId is the container name (e.g., "ar-abc12345")
|
|
609
1456
|
// The workspace port is 3888 inside the container
|
|
@@ -614,8 +1461,9 @@ workspacesRouter.all('/:id/proxy/{*proxyPath}', async (req, res) => {
|
|
|
614
1461
|
// Store targetUrl for error handling
|
|
615
1462
|
req._proxyTargetUrl = targetUrl;
|
|
616
1463
|
// Add timeout to prevent hanging requests
|
|
1464
|
+
// 45s timeout to accommodate Fly.io machine cold starts (can take 20-30s)
|
|
617
1465
|
const controller = new AbortController();
|
|
618
|
-
const timeout = setTimeout(() => controller.abort(),
|
|
1466
|
+
const timeout = setTimeout(() => controller.abort(), 45000);
|
|
619
1467
|
const fetchOptions = {
|
|
620
1468
|
method: req.method,
|
|
621
1469
|
headers: {
|
|
@@ -653,7 +1501,7 @@ workspacesRouter.all('/:id/proxy/{*proxyPath}', async (req, res) => {
|
|
|
653
1501
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
654
1502
|
res.status(504).json({
|
|
655
1503
|
error: 'Workspace request timed out',
|
|
656
|
-
details: 'The workspace did not respond within
|
|
1504
|
+
details: 'The workspace did not respond within 45 seconds',
|
|
657
1505
|
targetUrl: targetUrl,
|
|
658
1506
|
});
|
|
659
1507
|
return;
|
|
@@ -675,6 +1523,156 @@ workspacesRouter.all('/:id/proxy/{*proxyPath}', async (req, res) => {
|
|
|
675
1523
|
});
|
|
676
1524
|
}
|
|
677
1525
|
});
|
|
1526
|
+
// ============================================================================
|
|
1527
|
+
// Agent Management (proxied to workspace daemon)
|
|
1528
|
+
// ============================================================================
|
|
1529
|
+
/**
|
|
1530
|
+
* POST /api/workspaces/:id/agents
|
|
1531
|
+
* Spawn an agent in the workspace
|
|
1532
|
+
* Proxies to workspace daemon's /workspaces/:id/agents endpoint
|
|
1533
|
+
*/
|
|
1534
|
+
workspacesRouter.post('/:id/agents', async (req, res) => {
|
|
1535
|
+
const userId = req.session.userId;
|
|
1536
|
+
const { id } = req.params;
|
|
1537
|
+
const { name, provider, task, temporary, interactive } = req.body;
|
|
1538
|
+
if (!userId) {
|
|
1539
|
+
return res.status(401).json({ error: 'Not authenticated' });
|
|
1540
|
+
}
|
|
1541
|
+
if (!name) {
|
|
1542
|
+
return res.status(400).json({ error: 'Agent name is required' });
|
|
1543
|
+
}
|
|
1544
|
+
try {
|
|
1545
|
+
// Find workspace and verify access
|
|
1546
|
+
const workspace = await db.workspaces.findById(id);
|
|
1547
|
+
if (!workspace) {
|
|
1548
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
1549
|
+
}
|
|
1550
|
+
// Check access
|
|
1551
|
+
const accessResult = await checkWorkspaceAccess(userId, id);
|
|
1552
|
+
if (!accessResult.hasAccess) {
|
|
1553
|
+
return res.status(403).json({ error: 'Access denied to this workspace' });
|
|
1554
|
+
}
|
|
1555
|
+
// Ensure workspace is running
|
|
1556
|
+
if (workspace.status !== 'running' || !workspace.publicUrl) {
|
|
1557
|
+
return res.status(400).json({
|
|
1558
|
+
error: 'Workspace is not running',
|
|
1559
|
+
status: workspace.status,
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
// Proxy to workspace dashboard server's /api/spawn endpoint
|
|
1563
|
+
// The dashboard server expects 'cli' field (not 'provider')
|
|
1564
|
+
const targetUrl = `${workspace.publicUrl.replace(/\/$/, '')}/api/spawn`;
|
|
1565
|
+
console.log(`[workspaces] Proxying agent spawn to: ${targetUrl}`);
|
|
1566
|
+
const response = await fetch(targetUrl, {
|
|
1567
|
+
method: 'POST',
|
|
1568
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1569
|
+
body: JSON.stringify({
|
|
1570
|
+
name,
|
|
1571
|
+
cli: provider || 'claude', // Map provider to cli
|
|
1572
|
+
task: task || '', // Empty task = interactive mode, user responds to prompts
|
|
1573
|
+
interactive: interactive ?? true, // Default to interactive for setup flows
|
|
1574
|
+
}),
|
|
1575
|
+
signal: AbortSignal.timeout(30000),
|
|
1576
|
+
});
|
|
1577
|
+
const data = await response.json();
|
|
1578
|
+
if (!response.ok) {
|
|
1579
|
+
return res.status(response.status).json(data);
|
|
1580
|
+
}
|
|
1581
|
+
res.status(201).json(data);
|
|
1582
|
+
}
|
|
1583
|
+
catch (error) {
|
|
1584
|
+
console.error('[workspaces] Agent spawn error:', error);
|
|
1585
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
1586
|
+
if (errorMessage.includes('ECONNREFUSED') || errorMessage.includes('fetch failed')) {
|
|
1587
|
+
return res.status(503).json({
|
|
1588
|
+
error: 'Workspace is not reachable',
|
|
1589
|
+
details: 'The workspace container may not be running',
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
res.status(500).json({
|
|
1593
|
+
error: 'Failed to spawn agent',
|
|
1594
|
+
details: errorMessage,
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
});
|
|
1598
|
+
/**
|
|
1599
|
+
* GET /api/workspaces/:id/agents
|
|
1600
|
+
* List agents in the workspace
|
|
1601
|
+
*/
|
|
1602
|
+
workspacesRouter.get('/:id/agents', async (req, res) => {
|
|
1603
|
+
const userId = req.session.userId;
|
|
1604
|
+
const { id } = req.params;
|
|
1605
|
+
if (!userId) {
|
|
1606
|
+
return res.status(401).json({ error: 'Not authenticated' });
|
|
1607
|
+
}
|
|
1608
|
+
try {
|
|
1609
|
+
const workspace = await db.workspaces.findById(id);
|
|
1610
|
+
if (!workspace) {
|
|
1611
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
1612
|
+
}
|
|
1613
|
+
const accessResult = await checkWorkspaceAccess(userId, id);
|
|
1614
|
+
if (!accessResult.hasAccess) {
|
|
1615
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
1616
|
+
}
|
|
1617
|
+
if (workspace.status !== 'running' || !workspace.publicUrl) {
|
|
1618
|
+
return res.status(200).json({ agents: [], workspaceId: id });
|
|
1619
|
+
}
|
|
1620
|
+
// Use dashboard server's /api/spawned endpoint
|
|
1621
|
+
const targetUrl = `${workspace.publicUrl.replace(/\/$/, '')}/api/spawned`;
|
|
1622
|
+
const response = await fetch(targetUrl, {
|
|
1623
|
+
signal: AbortSignal.timeout(10000),
|
|
1624
|
+
});
|
|
1625
|
+
if (!response.ok) {
|
|
1626
|
+
return res.status(200).json({ agents: [], workspaceId: id });
|
|
1627
|
+
}
|
|
1628
|
+
const data = await response.json();
|
|
1629
|
+
// Transform to expected format
|
|
1630
|
+
res.json({ agents: data.agents || [], workspaceId: id });
|
|
1631
|
+
}
|
|
1632
|
+
catch (error) {
|
|
1633
|
+
console.error('[workspaces] List agents error:', error);
|
|
1634
|
+
res.status(200).json({ agents: [], workspaceId: id });
|
|
1635
|
+
}
|
|
1636
|
+
});
|
|
1637
|
+
/**
|
|
1638
|
+
* DELETE /api/workspaces/:id/agents/:agentName
|
|
1639
|
+
* Stop an agent in the workspace
|
|
1640
|
+
*/
|
|
1641
|
+
workspacesRouter.delete('/:id/agents/:agentName', async (req, res) => {
|
|
1642
|
+
const userId = req.session.userId;
|
|
1643
|
+
const { id, agentName } = req.params;
|
|
1644
|
+
if (!userId) {
|
|
1645
|
+
return res.status(401).json({ error: 'Not authenticated' });
|
|
1646
|
+
}
|
|
1647
|
+
try {
|
|
1648
|
+
const workspace = await db.workspaces.findById(id);
|
|
1649
|
+
if (!workspace) {
|
|
1650
|
+
return res.status(404).json({ error: 'Workspace not found' });
|
|
1651
|
+
}
|
|
1652
|
+
const accessResult = await checkWorkspaceAccess(userId, id);
|
|
1653
|
+
if (!accessResult.hasAccess) {
|
|
1654
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
1655
|
+
}
|
|
1656
|
+
if (workspace.status !== 'running' || !workspace.publicUrl) {
|
|
1657
|
+
return res.status(400).json({ error: 'Workspace is not running' });
|
|
1658
|
+
}
|
|
1659
|
+
// Use dashboard server's /api/spawned/:name endpoint
|
|
1660
|
+
const targetUrl = `${workspace.publicUrl.replace(/\/$/, '')}/api/spawned/${encodeURIComponent(agentName)}`;
|
|
1661
|
+
const response = await fetch(targetUrl, {
|
|
1662
|
+
method: 'DELETE',
|
|
1663
|
+
signal: AbortSignal.timeout(10000),
|
|
1664
|
+
});
|
|
1665
|
+
if (response.status === 204) {
|
|
1666
|
+
return res.status(204).send();
|
|
1667
|
+
}
|
|
1668
|
+
const data = await response.json();
|
|
1669
|
+
res.status(response.status).json(data);
|
|
1670
|
+
}
|
|
1671
|
+
catch (error) {
|
|
1672
|
+
console.error('[workspaces] Stop agent error:', error);
|
|
1673
|
+
res.status(500).json({ error: 'Failed to stop agent' });
|
|
1674
|
+
}
|
|
1675
|
+
});
|
|
678
1676
|
/**
|
|
679
1677
|
* POST /api/workspaces/quick
|
|
680
1678
|
* Quick provision: one-click with defaults
|
|
@@ -687,6 +1685,29 @@ workspacesRouter.post('/quick', checkWorkspaceLimit, async (req, res) => {
|
|
|
687
1685
|
return res.status(400).json({ error: 'Repository name is required' });
|
|
688
1686
|
}
|
|
689
1687
|
try {
|
|
1688
|
+
// Check if a workspace already exists for this repo
|
|
1689
|
+
// If so, check if user has access and return it instead of creating a duplicate
|
|
1690
|
+
const existingRepos = await db.repositories.findByGithubFullName(repositoryFullName);
|
|
1691
|
+
for (const existingRepo of existingRepos) {
|
|
1692
|
+
if (existingRepo.workspaceId) {
|
|
1693
|
+
// Check if user has access to this workspace
|
|
1694
|
+
const accessResult = await checkWorkspaceAccess(userId, existingRepo.workspaceId);
|
|
1695
|
+
if (accessResult.hasAccess) {
|
|
1696
|
+
const existingWorkspace = await db.workspaces.findById(existingRepo.workspaceId);
|
|
1697
|
+
if (existingWorkspace) {
|
|
1698
|
+
console.log(`[workspaces/quick] User ${userId.substring(0, 8)} has access to existing workspace ${existingWorkspace.id.substring(0, 8)} for repo ${repositoryFullName}`);
|
|
1699
|
+
return res.status(200).json({
|
|
1700
|
+
workspaceId: existingWorkspace.id,
|
|
1701
|
+
status: existingWorkspace.status,
|
|
1702
|
+
publicUrl: existingWorkspace.publicUrl,
|
|
1703
|
+
existingWorkspace: true,
|
|
1704
|
+
accessType: accessResult.accessType,
|
|
1705
|
+
message: `You already have ${accessResult.accessType} access to a workspace for this repository.`,
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
690
1711
|
// Get user's connected providers (optional now)
|
|
691
1712
|
const credentials = await db.credentials.findByUserId(userId);
|
|
692
1713
|
const providers = credentials
|