Skip to content

Commit 871f3dd

Browse files
Merge pull request #170 from flocko-motion/feat/progress
fix: various issues
2 parents 9a2329c + 0718205 commit 871f3dd

File tree

17 files changed

+393
-20
lines changed

17 files changed

+393
-20
lines changed

server/api/routes/apikeys.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ func DeleteApiKey(w http.ResponseWriter, r *http.Request) {
376376
// (After deletion it may no longer be loadable.)
377377
share, err := db.GetApiKeyShareByID(r.Context(), user.ID, shareID)
378378
if err != nil {
379-
httpx.WriteError(w, http.StatusInternalServerError, "Failed to load share: "+err.Error())
379+
httpx.WriteError(w, http.StatusNotFound, "Share not found")
380380
return
381381
}
382382

server/api/routes/invites.go

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,7 @@ func AcceptInvite(w http.ResponseWriter, r *http.Request) {
465465
}
466466

467467
// Head, staff, individual enter workshop mode (keep their role, set active workshop)
468+
// Users without a role fall back to AcceptOpenInvite (joins as participant)
468469
invite, getErr := db.GetInviteByToken(r.Context(), uuid.Nil, idOrToken)
469470
if getErr != nil {
470471
if appErr, ok := getErr.(*obj.AppError); ok {
@@ -478,16 +479,26 @@ func AcceptInvite(w http.ResponseWriter, r *http.Request) {
478479
httpx.WriteAppError(w, obj.ErrValidation("this is not a workshop invite"))
479480
return
480481
}
481-
if setErr := db.SetActiveWorkshop(r.Context(), user.ID, *invite.WorkshopID); setErr != nil {
482-
if appErr, ok := setErr.(*obj.AppError); ok {
482+
setErr := db.SetActiveWorkshop(r.Context(), user.ID, *invite.WorkshopID)
483+
if setErr == nil {
484+
httpx.WriteJSON(w, http.StatusOK, AcceptInviteResponse{
485+
Message: "Workshop entered",
486+
})
487+
return
488+
}
489+
// SetActiveWorkshop failed (e.g., user has no role, or workshop not in their org)
490+
// Fall back to AcceptOpenInvite which assigns a participant role
491+
_, acceptErr := db.AcceptOpenInvite(r.Context(), idOrToken, user.ID)
492+
if acceptErr != nil {
493+
if appErr, ok := acceptErr.(*obj.AppError); ok {
483494
httpx.WriteAppError(w, appErr)
484495
return
485496
}
486-
httpx.WriteError(w, http.StatusInternalServerError, setErr.Error())
497+
httpx.WriteError(w, http.StatusInternalServerError, acceptErr.Error())
487498
return
488499
}
489500
httpx.WriteJSON(w, http.StatusOK, AcceptInviteResponse{
490-
Message: "Workshop entered",
501+
Message: "Invite accepted",
491502
})
492503
return
493504
}

server/api/routes/router.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ func NewMux() *http.ServeMux {
2626
mux.Handle("PATCH /api/system/settings/free-use-key", httpx.RequireAuth(SetSystemFreeUseApiKey))
2727

2828
// Games
29-
mux.Handle("GET /api/games", httpx.OptionalAuth(GetGames))
30-
mux.Handle("GET /api/games/{id}", httpx.OptionalAuth(GetGameByID))
29+
mux.Handle("GET /api/games", httpx.RequireAuth(GetGames))
30+
mux.Handle("GET /api/games/{id}", httpx.RequireAuth(GetGameByID))
3131
mux.Handle("GET /api/games/{id}/yaml", httpx.RequireAuth(GetGameYAML))
3232
mux.Handle("GET /api/games/{id}/sessions", httpx.RequireAuth(GetGameSessions))
3333
mux.Handle("POST /api/games/new", httpx.RequireAuth(CreateGame))
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
-- Migration 015: Add ON DELETE SET NULL to game sponsor FK constraints
2+
-- Fixes: "update or delete on table api_key_share violates foreign key constraint
3+
-- game_private_sponsored_api_key_share_id_fkey on table game"
4+
-- Without ON DELETE SET NULL, every code path that deletes an api_key_share row
5+
-- must manually clear the game reference first. Adding ON DELETE SET NULL makes
6+
-- PostgreSQL handle this automatically, eliminating the entire class of bugs.
7+
8+
ALTER TABLE game DROP CONSTRAINT IF EXISTS game_public_sponsored_api_key_share_id_fkey;
9+
ALTER TABLE game ADD CONSTRAINT game_public_sponsored_api_key_share_id_fkey
10+
FOREIGN KEY (public_sponsored_api_key_share_id) REFERENCES api_key_share(id) ON DELETE SET NULL;
11+
12+
ALTER TABLE game DROP CONSTRAINT IF EXISTS game_private_sponsored_api_key_share_id_fkey;
13+
ALTER TABLE game ADD CONSTRAINT game_private_sponsored_api_key_share_id_fkey
14+
FOREIGN KEY (private_sponsored_api_key_share_id) REFERENCES api_key_share(id) ON DELETE SET NULL;

server/db/schema.sql

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,11 +239,11 @@ CREATE TABLE game (
239239
-- Access rights and payments. public = true: discoverable on the website and playable by anyone.
240240
public boolean NOT NULL DEFAULT false,
241241
-- If public, a sponsored API key share can be provided to pay for any public plays.
242-
public_sponsored_api_key_share_id uuid NULL REFERENCES api_key_share(id),
242+
public_sponsored_api_key_share_id uuid NULL REFERENCES api_key_share(id) ON DELETE SET NULL,
243243
-- Private share links contain secret random tokens to limit access to the game.
244244
-- They are sponsored, so invited players don't require their own API key.
245245
private_share_hash text NULL,
246-
private_sponsored_api_key_share_id uuid NULL REFERENCES api_key_share(id),
246+
private_sponsored_api_key_share_id uuid NULL REFERENCES api_key_share(id) ON DELETE SET NULL,
247247
-- Remaining plays for private share links. NULL = unlimited, >0 = can play, 0 = exhausted.
248248
private_share_remaining integer NULL,
249249

server/game/game_logic.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ func extractAIErrorCode(err error) string {
4343
return obj.ErrCodeInsufficientQuota
4444
case strings.Contains(errStr, "content_policy") || strings.Contains(errStr, "content_filter"):
4545
return obj.ErrCodeContentFiltered
46+
case strings.Contains(errStr, "invalid_json_schema") || strings.Contains(errStr, "invalid schema"):
47+
return obj.ErrCodeInvalidJsonSchema
4648
default:
4749
// For any other AI API error, return generic AI error
4850
return obj.ErrCodeAiError

server/game/status/status.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ func FieldsToMap(fields []obj.StatusField) map[string]string {
5151
// preventing the AI from hallucinating extra fields or dropping existing ones.
5252
func BuildResponseSchema(statusFieldsJSON string) map[string]interface{} {
5353
fieldNames := FieldNames(statusFieldsJSON)
54+
if fieldNames == nil {
55+
fieldNames = []string{}
56+
}
5457

5558
// Build status properties with exact field names as keys
5659
statusProperties := make(map[string]interface{}, len(fieldNames))

server/obj/errors.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const (
2323

2424
// AI-specific error codes
2525
ErrCodeAiError = "ai_error"
26+
ErrCodeInvalidJsonSchema = "invalid_json_schema"
2627
ErrCodeInvalidApiKey = "invalid_api_key"
2728
ErrCodeBillingNotActive = "billing_not_active"
2829
ErrCodeOrgVerificationRequired = "organization_verification_required"
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package testing
2+
3+
import (
4+
"cgl/testing/testutil"
5+
"testing"
6+
7+
"github.com/stretchr/testify/suite"
8+
)
9+
10+
// ApiKeyCascadePrivateShareTestSuite tests that deleting an API key used to sponsor
11+
// a game with a private share link properly cascades and disables the share.
12+
type ApiKeyCascadePrivateShareTestSuite struct {
13+
testutil.BaseSuite
14+
}
15+
16+
func TestApiKeyCascadePrivateShareSuite(t *testing.T) {
17+
s := &ApiKeyCascadePrivateShareTestSuite{}
18+
s.SuiteName = "API Key Cascade Private Share Tests"
19+
suite.Run(t, s)
20+
}
21+
22+
// setupSponsoredGameWithPrivateShare creates a user, API key, game, enables sponsoring,
23+
// sets the game as public with a sponsor, and enables a private share link.
24+
// Returns (user, gameID, shareID, privateShareToken).
25+
func (s *ApiKeyCascadePrivateShareTestSuite) setupSponsoredGameWithPrivateShare(prefix string) (*testutil.UserClient, string, string, string) {
26+
user := s.CreateUser(prefix)
27+
keyShare := Must(user.AddApiKey("mock-"+prefix, prefix+" Key", "mock"))
28+
shareID := keyShare.ID.String()
29+
30+
// Enable sponsoring on the key
31+
Must(user.EnableShareSponsoring(shareID))
32+
33+
// Upload game and make it public
34+
game := Must(user.UploadGame("alien-first-contact"))
35+
gameID := game.ID.String()
36+
game.Public = true
37+
Must(user.UpdateGame(gameID, game))
38+
39+
// Set the key as sponsor for the game
40+
Must(user.SetGameSponsor(gameID, shareID))
41+
42+
// Enable private share link using the same key
43+
status := Must(user.EnablePrivateShare(gameID, shareID, nil))
44+
s.True(status.Enabled, "private share should be enabled")
45+
s.NotEmpty(status.Token, "share token should not be empty")
46+
47+
return user, gameID, shareID, status.Token
48+
}
49+
50+
// TestDeleteSponsorKeyDisablesPrivateShare verifies that deleting the API key
51+
// used to sponsor a game also disables the private share link.
52+
func (s *ApiKeyCascadePrivateShareTestSuite) TestDeleteSponsorKeyDisablesPrivateShare() {
53+
user, gameID, shareID, token := s.setupSponsoredGameWithPrivateShare("cascade-ps")
54+
55+
// Verify private share works before deletion
56+
pub := s.Public()
57+
info := Must(pub.GuestGetGameInfo(token))
58+
s.NotEmpty(info.Name, "guest should see game info before key deletion")
59+
s.T().Logf("Guest can access game before deletion: %s", info.Name)
60+
61+
// Delete the API key (cascade)
62+
MustSucceed(user.DeleteApiKey(shareID, true))
63+
s.T().Logf("Deleted sponsor API key")
64+
65+
// Private share should now be disabled
66+
status := Must(user.GetPrivateShareStatus(gameID))
67+
s.False(status.Enabled, "private share should be disabled after sponsor key deletion")
68+
s.T().Logf("Private share correctly disabled")
69+
70+
// Guest should no longer be able to access the game via token
71+
_, err := pub.GuestGetGameInfo(token)
72+
s.Error(err, "guest should not access game after sponsor key deletion")
73+
s.T().Logf("Guest correctly denied after key deletion: %v", err)
74+
}
75+
76+
// TestDeleteSponsorKeyAlsoRemovesPublicSponsorship verifies that deleting the
77+
// API key removes both the private share and the public sponsorship.
78+
func (s *ApiKeyCascadePrivateShareTestSuite) TestDeleteSponsorKeyAlsoRemovesPublicSponsorship() {
79+
user, gameID, shareID, _ := s.setupSponsoredGameWithPrivateShare("cascade-both")
80+
81+
// Verify game has sponsor before deletion
82+
game := Must(user.GetGameByID(gameID))
83+
s.NotEmpty(game.PublicSponsoredApiKeyShareID, "game should have sponsor before deletion")
84+
s.T().Logf("Game has sponsor: %s", *game.PublicSponsoredApiKeyShareID)
85+
86+
// Delete the API key (cascade)
87+
MustSucceed(user.DeleteApiKey(shareID, true))
88+
s.T().Logf("Deleted sponsor API key")
89+
90+
// Game should no longer have a sponsor
91+
game = Must(user.GetGameByID(gameID))
92+
s.Nil(game.PublicSponsoredApiKeyShareID, "game should not have sponsor after key deletion")
93+
s.T().Logf("Public sponsorship correctly removed")
94+
95+
// Private share should also be disabled
96+
status := Must(user.GetPrivateShareStatus(gameID))
97+
s.False(status.Enabled, "private share should be disabled")
98+
s.T().Logf("Private share correctly disabled")
99+
}
100+
101+
// TestGuestSessionFailsAfterSponsorKeyDeletion verifies that a guest cannot
102+
// create a new session after the sponsor key is deleted.
103+
func (s *ApiKeyCascadePrivateShareTestSuite) TestGuestSessionFailsAfterSponsorKeyDeletion() {
104+
user, _, shareID, token := s.setupSponsoredGameWithPrivateShare("cascade-sess")
105+
106+
// Guest creates a session before deletion — should succeed
107+
pub := s.Public()
108+
resp, err := pub.GuestCreateSession(token)
109+
s.NoError(err, "guest should create session before key deletion")
110+
s.NotNil(resp.GameSession, "session should not be nil")
111+
s.T().Logf("Guest created session before deletion: %s", resp.GameSession.ID)
112+
113+
// Delete the API key (cascade)
114+
MustSucceed(user.DeleteApiKey(shareID, true))
115+
s.T().Logf("Deleted sponsor API key")
116+
117+
// Guest should not be able to create a new session
118+
_, err = pub.GuestCreateSession(token)
119+
s.Error(err, "guest should not create session after sponsor key deletion")
120+
s.T().Logf("Guest correctly denied session creation: %v", err)
121+
}
122+
123+
// TestNonCascadeDeleteDoesNotAffectPrivateShare verifies that deleting the API key
124+
// share without cascade does NOT affect the private share (the underlying key still exists).
125+
func (s *ApiKeyCascadePrivateShareTestSuite) TestNonCascadeDeleteDoesNotAffectPrivateShare() {
126+
user := s.CreateUser("cascade-noaff")
127+
keyShare := Must(user.AddApiKey("mock-cascade-noaff", "Key", "mock"))
128+
shareID := keyShare.ID.String()
129+
130+
Must(user.EnableShareSponsoring(shareID))
131+
132+
game := Must(user.UploadGame("alien-first-contact"))
133+
gameID := game.ID.String()
134+
game.Public = true
135+
Must(user.UpdateGame(gameID, game))
136+
Must(user.SetGameSponsor(gameID, shareID))
137+
status := Must(user.EnablePrivateShare(gameID, shareID, nil))
138+
s.True(status.Enabled)
139+
140+
// Non-cascade delete of the share — the underlying API key may still exist
141+
// but the share used for sponsoring is removed
142+
err := user.DeleteApiKey(shareID, false)
143+
// This may or may not error depending on implementation
144+
// The key point is to verify the state after
145+
s.T().Logf("Non-cascade delete result: %v", err)
146+
147+
// Check private share status
148+
psStatus := Must(user.GetPrivateShareStatus(gameID))
149+
s.T().Logf("Private share enabled after non-cascade delete: %v", psStatus.Enabled)
150+
}

0 commit comments

Comments
 (0)