Przeglądaj źródła

webapi: remove api key management

Mike 6 dni temu
rodzic
commit
1014d498bc

+ 0 - 12
Makefile

@@ -173,18 +173,6 @@ macos-trust-ca: ## Trust $(CA_CERT) as a root in the macOS system keychain, repl
 	done
 	sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain $(CA_CERT)
 
-################################################################################
-# Web API Tools
-################################################################################
-
-.PHONY: webapi-keygen
-webapi-keygen: ## Build the Web API key generator tool
-	go build -o webapi_keygen ./cmd/webapi_keygen
-
-.PHONY: webapi-keygen-install
-webapi-keygen-install: ## Install the Web API key generator tool system-wide
-	go install ./cmd/webapi_keygen
-
 ################################################################################
 # Web Clients
 ################################################################################

+ 0 - 243
api.yml

@@ -809,213 +809,6 @@ paths:
                   message:
                     type: string
 
-  /admin/webapi/keys:
-    get:
-      summary: List all Web API keys
-      description: Retrieve a list of all Web API keys for the Web AIM API.
-      tags: [ Web API Management ]
-      responses:
-        '200':
-          description: Successful response containing a list of API keys.
-          content:
-            application/json:
-              schema:
-                type: array
-                items:
-                  $ref: '#/components/schemas/WebAPIKey'
-        '500':
-          description: Internal server error.
-          content:
-            application/json:
-              schema:
-                $ref: '#/components/schemas/MessageResponse'
-
-    post:
-      summary: Create a new Web API key
-      description: Create a new API key for Web AIM API authentication.
-      tags: [ Web API Management ]
-      requestBody:
-        required: true
-        content:
-          application/json:
-            schema:
-              type: object
-              required:
-                - app_name
-              properties:
-                app_name:
-                  type: string
-                  description: Name of the application using this API key.
-                  example: "My Web AIM Client"
-                allowed_origins:
-                  type: array
-                  items:
-                    type: string
-                  description: List of allowed CORS origins. Empty list allows all origins (useful for mobile apps).
-                  example: [ "https://example.com", "https://app.example.com" ]
-                rate_limit:
-                  type: integer
-                  description: Maximum requests per minute allowed for this key.
-                  default: 60
-                  example: 120
-                capabilities:
-                  type: array
-                  items:
-                    type: string
-                  description: List of capabilities/features enabled for this key. Empty list allows all capabilities.
-                  example: [ "aim.session", "presence.get", "im.send" ]
-      responses:
-        '201':
-          description: API key created successfully.
-          content:
-            application/json:
-              schema:
-                allOf:
-                  - $ref: '#/components/schemas/WebAPIKey'
-                  - type: object
-                    properties:
-                      dev_key:
-                        type: string
-                        description: The actual API key value. This is only shown once at creation time.
-                        example: "a1b2c3d4e5f6789012345678901234567890123456789012345678901234"
-        '400':
-          description: Bad request. Invalid input data.
-          content:
-            application/json:
-              schema:
-                $ref: '#/components/schemas/MessageResponse'
-        '409':
-          description: Conflict. An API key with this ID already exists.
-          content:
-            application/json:
-              schema:
-                $ref: '#/components/schemas/MessageResponse'
-        '500':
-          description: Internal server error.
-          content:
-            application/json:
-              schema:
-                $ref: '#/components/schemas/MessageResponse'
-
-  /admin/webapi/keys/{id}:
-    get:
-      summary: Get a specific Web API key
-      description: Retrieve details of a specific Web API key by its developer ID.
-      tags: [ Web API Management ]
-      parameters:
-        - name: id
-          in: path
-          description: The developer ID of the API key.
-          required: true
-          schema:
-            type: string
-            example: "dev_550e8400-e29b-41d4-a716-446655440000"
-      responses:
-        '200':
-          description: Successful response containing the API key details.
-          content:
-            application/json:
-              schema:
-                $ref: '#/components/schemas/WebAPIKey'
-        '404':
-          description: API key not found.
-          content:
-            application/json:
-              schema:
-                $ref: '#/components/schemas/MessageResponse'
-        '500':
-          description: Internal server error.
-          content:
-            application/json:
-              schema:
-                $ref: '#/components/schemas/MessageResponse'
-
-    put:
-      summary: Update a Web API key
-      description: Update settings for an existing Web API key.
-      tags: [ Web API Management ]
-      parameters:
-        - name: id
-          in: path
-          description: The developer ID of the API key.
-          required: true
-          schema:
-            type: string
-            example: "dev_550e8400-e29b-41d4-a716-446655440000"
-      requestBody:
-        required: true
-        content:
-          application/json:
-            schema:
-              type: object
-              properties:
-                app_name:
-                  type: string
-                  description: New application name.
-                is_active:
-                  type: boolean
-                  description: Enable or disable the API key.
-                rate_limit:
-                  type: integer
-                  description: New rate limit (requests per minute).
-                allowed_origins:
-                  type: array
-                  items:
-                    type: string
-                  description: New list of allowed CORS origins.
-                capabilities:
-                  type: array
-                  items:
-                    type: string
-                  description: New list of enabled capabilities.
-      responses:
-        '200':
-          description: API key updated successfully.
-          content:
-            application/json:
-              schema:
-                $ref: '#/components/schemas/WebAPIKey'
-        '404':
-          description: API key not found.
-          content:
-            application/json:
-              schema:
-                $ref: '#/components/schemas/MessageResponse'
-        '500':
-          description: Internal server error.
-          content:
-            application/json:
-              schema:
-                $ref: '#/components/schemas/MessageResponse'
-
-    delete:
-      summary: Delete a Web API key
-      description: Permanently delete a Web API key.
-      tags: [ Web API Management ]
-      parameters:
-        - name: id
-          in: path
-          description: The developer ID of the API key.
-          required: true
-          schema:
-            type: string
-            example: "dev_550e8400-e29b-41d4-a716-446655440000"
-      responses:
-        '204':
-          description: API key deleted successfully.
-        '404':
-          description: API key not found.
-          content:
-            application/json:
-              schema:
-                $ref: '#/components/schemas/MessageResponse'
-        '500':
-          description: Internal server error.
-          content:
-            application/json:
-              schema:
-                $ref: '#/components/schemas/MessageResponse'
-
   /bart:
     get:
       summary: Get BART entries by type
@@ -1596,39 +1389,3 @@ components:
         - 1026: encr_cert_chain (Cert chain for encryption certs)
         - 1027: sign_cert_chain (Cert chain for signing certs)
         - 1028: gateway_cert (Cert for enterprise gateway)
-
-    WebAPIKey:
-      type: object
-      properties:
-        dev_id:
-          type: string
-          description: Unique developer/application identifier.
-          example: "dev_550e8400-e29b-41d4-a716-446655440000"
-        app_name:
-          type: string
-          description: Name of the application using this API key.
-          example: "My Web AIM Client"
-        created_at:
-          type: string
-          format: date-time
-          description: Timestamp when the key was created.
-        is_active:
-          type: boolean
-          description: Whether the API key is currently active.
-          default: true
-        rate_limit:
-          type: integer
-          description: Maximum requests per minute allowed.
-          example: 60
-        allowed_origins:
-          type: array
-          items:
-            type: string
-          description: List of allowed CORS origins. Empty list allows all origins.
-          example: [ "https://example.com" ]
-        capabilities:
-          type: array
-          items:
-            type: string
-          description: List of enabled features/endpoints. Empty list allows all capabilities.
-          example: [ "aim.session", "presence.get" ]

+ 1 - 2
cmd/server/factory.go

@@ -414,7 +414,6 @@ func MgmtAPI(deps Container) *http.Server {
 		deps.sqLiteUserStore,        // feedbagManager
 		deps.sqLiteUserStore,        // accountManager
 		deps.sqLiteUserStore,        // profileRetriever
-		deps.sqLiteUserStore,        // webAPIKeyManager
 		deps.sqLiteUserStore,        // icqProfileManager
 		state.NewAccountCreator(deps.sqLiteUserStore.InsertUser),
 		logger,
@@ -601,7 +600,7 @@ func WebAPI(deps Container) *webapi.Server {
 		SNACRateLimits:     deps.snacRateLimits,
 	}
 
-	return webapi.NewServer(deps.cfg.WebAPIListeners, logger, handler, deps.sqLiteUserStore, deps.webAPISessionManager)
+	return webapi.NewServer(deps.cfg.WebAPIListeners, logger, handler, deps.webAPISessionManager)
 }
 
 // ICQLegacy creates a legacy ICQ server for v2-v5 protocols.

+ 0 - 430
cmd/webapi_keygen/main.go

@@ -1,430 +0,0 @@
-// webapi_keygen generates and manages Web API keys for the RAS Web AIM API.
-// Usage: go run ./cmd/webapi_keygen [command] [options]
-package main
-
-import (
-	"context"
-	"crypto/rand"
-	"encoding/hex"
-	"encoding/json"
-	"flag"
-	"fmt"
-	"os"
-	"strings"
-	"text/tabwriter"
-	"time"
-
-	"github.com/google/uuid"
-	"github.com/joho/godotenv"
-
-	"github.com/mk6i/open-oscar-server/state"
-)
-
-const (
-	keyLength = 32 // 256 bits of entropy
-)
-
-func main() {
-	// Load environment configuration
-	if err := godotenv.Load("config/settings.env"); err != nil {
-		fmt.Printf("Config file not found, using environment variables\n")
-	}
-
-	if len(os.Args) < 2 {
-		printUsage()
-		os.Exit(1)
-	}
-
-	command := os.Args[1]
-	args := os.Args[2:]
-
-	switch command {
-	case "generate", "gen":
-		handleGenerate(args)
-	case "list", "ls":
-		handleList(args)
-	case "revoke", "delete", "rm":
-		handleRevoke(args)
-	case "activate":
-		handleActivate(args)
-	case "update":
-		handleUpdate(args)
-	case "show":
-		handleShow(args)
-	case "help", "-h", "--help":
-		printUsage()
-	default:
-		fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", command)
-		printUsage()
-		os.Exit(1)
-	}
-}
-
-func printUsage() {
-	fmt.Println("Web API Key Generator for RAS")
-	fmt.Println("\nUsage: webapi_keygen <command> [options]")
-	fmt.Println("\nCommands:")
-	fmt.Println("  generate, gen     Generate a new API key")
-	fmt.Println("  list, ls          List all API keys")
-	fmt.Println("  show              Show details of a specific key")
-	fmt.Println("  revoke, delete    Deactivate an API key")
-	fmt.Println("  activate          Reactivate an API key")
-	fmt.Println("  update            Update API key settings")
-	fmt.Println("  help              Show this help message")
-	fmt.Println("\nGenerate Options:")
-	fmt.Println("  --app-name        Application name (required)")
-	fmt.Println("  --origins         Comma-separated list of allowed origins")
-	fmt.Println("  --rate-limit      Requests per minute (default: 60)")
-	fmt.Println("  --capabilities    Comma-separated list of capabilities")
-	fmt.Println("\nUpdate Options:")
-	fmt.Println("  --dev-id          Developer ID to update (required)")
-	fmt.Println("  --app-name        New application name")
-	fmt.Println("  --origins         New comma-separated list of allowed origins")
-	fmt.Println("  --rate-limit      New requests per minute limit")
-	fmt.Println("  --capabilities    New comma-separated list of capabilities")
-	fmt.Println("\nExamples:")
-	fmt.Println("  webapi_keygen generate --app-name \"My Web Client\" --origins \"https://example.com,https://app.example.com\"")
-	fmt.Println("  webapi_keygen list")
-	fmt.Println("  webapi_keygen show --dev-id dev_abc123")
-	fmt.Println("  webapi_keygen revoke --dev-id dev_abc123")
-	fmt.Println("  webapi_keygen update --dev-id dev_abc123 --rate-limit 120")
-}
-
-func handleGenerate(args []string) {
-	fs := flag.NewFlagSet("generate", flag.ExitOnError)
-	appName := fs.String("app-name", "", "Application name (required)")
-	originsStr := fs.String("origins", "", "Comma-separated list of allowed origins")
-	rateLimit := fs.Int("rate-limit", 60, "Requests per minute")
-	capabilitiesStr := fs.String("capabilities", "", "Comma-separated list of capabilities")
-
-	if err := fs.Parse(args); err != nil {
-		fmt.Fprintf(os.Stderr, "Error parsing arguments: %v\n", err)
-		os.Exit(1)
-	}
-
-	if *appName == "" {
-		fmt.Fprintln(os.Stderr, "Error: --app-name is required")
-		os.Exit(1)
-	}
-
-	// Parse origins and capabilities
-	var origins []string
-	if *originsStr != "" {
-		origins = parseCSV(*originsStr)
-	}
-
-	var capabilities []string
-	if *capabilitiesStr != "" {
-		capabilities = parseCSV(*capabilitiesStr)
-	}
-
-	// Generate secure random key
-	keyBytes := make([]byte, keyLength)
-	if _, err := rand.Read(keyBytes); err != nil {
-		fmt.Fprintf(os.Stderr, "Error generating key: %v\n", err)
-		os.Exit(1)
-	}
-	devKey := hex.EncodeToString(keyBytes)
-
-	// Generate dev_id
-	devID := fmt.Sprintf("dev_%s", uuid.New().String())
-
-	// Create the API key record
-	apiKey := state.WebAPIKey{
-		DevID:          devID,
-		DevKey:         devKey,
-		AppName:        *appName,
-		CreatedAt:      time.Now(),
-		IsActive:       true,
-		RateLimit:      *rateLimit,
-		AllowedOrigins: origins,
-		Capabilities:   capabilities,
-	}
-
-	// Connect to database and insert the key
-	store, err := connectToStore()
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "Error connecting to database: %v\n", err)
-		os.Exit(1)
-	}
-
-	ctx := context.Background()
-	if err := store.CreateAPIKey(ctx, apiKey); err != nil {
-		fmt.Fprintf(os.Stderr, "Error creating API key: %v\n", err)
-		os.Exit(1)
-	}
-
-	// Output the generated key details
-	fmt.Println("Successfully generated Web API key:")
-	fmt.Println("=====================================")
-	fmt.Printf("Developer ID:  %s\n", devID)
-	fmt.Printf("API Key:       %s\n", devKey)
-	fmt.Printf("App Name:      %s\n", *appName)
-	fmt.Printf("Rate Limit:    %d requests/minute\n", *rateLimit)
-	if len(origins) > 0 {
-		fmt.Printf("Origins:       %s\n", strings.Join(origins, ", "))
-	}
-	if len(capabilities) > 0 {
-		fmt.Printf("Capabilities:  %s\n", strings.Join(capabilities, ", "))
-	}
-	fmt.Println("=====================================")
-	fmt.Println("\nIMPORTANT: Save the API key securely. It cannot be retrieved later.")
-}
-
-func handleList(args []string) {
-	store, err := connectToStore()
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "Error connecting to database: %v\n", err)
-		os.Exit(1)
-	}
-
-	ctx := context.Background()
-	keys, err := store.ListAPIKeys(ctx)
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "Error listing API keys: %v\n", err)
-		os.Exit(1)
-	}
-
-	if len(keys) == 0 {
-		fmt.Println("No API keys found.")
-		return
-	}
-
-	// Create a tabwriter for formatted output
-	w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
-	_, _ = fmt.Fprintln(w, "DEV ID\tAPP NAME\tACTIVE\tRATE LIMIT\tCREATED")
-	_, _ = fmt.Fprintln(w, "------\t--------\t------\t----------\t-------")
-
-	for _, key := range keys {
-		_, _ = fmt.Fprintf(w, "%s\t%s\t%v\t%d/min\t%s\n",
-			truncateString(key.DevID, 20),
-			truncateString(key.AppName, 20),
-			key.IsActive,
-			key.RateLimit,
-			key.CreatedAt.Format("2006-01-02"),
-		)
-	}
-	_ = w.Flush()
-}
-
-func handleShow(args []string) {
-	fs := flag.NewFlagSet("show", flag.ExitOnError)
-	devID := fs.String("dev-id", "", "Developer ID (required)")
-
-	if err := fs.Parse(args); err != nil {
-		fmt.Fprintf(os.Stderr, "Error parsing arguments: %v\n", err)
-		os.Exit(1)
-	}
-
-	if *devID == "" {
-		fmt.Fprintln(os.Stderr, "Error: --dev-id is required")
-		os.Exit(1)
-	}
-
-	store, err := connectToStore()
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "Error connecting to database: %v\n", err)
-		os.Exit(1)
-	}
-
-	ctx := context.Background()
-	key, err := store.GetAPIKeyByDevID(ctx, *devID)
-	if err != nil {
-		if err == state.ErrNoAPIKey {
-			fmt.Fprintf(os.Stderr, "Error: API key not found for dev_id: %s\n", *devID)
-		} else {
-			fmt.Fprintf(os.Stderr, "Error retrieving API key: %v\n", err)
-		}
-		os.Exit(1)
-	}
-
-	// Output detailed key information
-	fmt.Println("Web API Key Details:")
-	fmt.Println("=====================================")
-	fmt.Printf("Developer ID:  %s\n", key.DevID)
-	fmt.Printf("App Name:      %s\n", key.AppName)
-	fmt.Printf("Active:        %v\n", key.IsActive)
-	fmt.Printf("Rate Limit:    %d requests/minute\n", key.RateLimit)
-	fmt.Printf("Created:       %s\n", key.CreatedAt.Format("2006-01-02 15:04:05"))
-	if len(key.AllowedOrigins) > 0 {
-		fmt.Printf("Origins:       %s\n", strings.Join(key.AllowedOrigins, ", "))
-	} else {
-		fmt.Println("Origins:       All origins allowed")
-	}
-	if len(key.Capabilities) > 0 {
-		fmt.Printf("Capabilities:  %s\n", strings.Join(key.Capabilities, ", "))
-	} else {
-		fmt.Println("Capabilities:  All capabilities enabled")
-	}
-	fmt.Println("=====================================")
-}
-
-func handleRevoke(args []string) {
-	fs := flag.NewFlagSet("revoke", flag.ExitOnError)
-	devID := fs.String("dev-id", "", "Developer ID to revoke (required)")
-
-	if err := fs.Parse(args); err != nil {
-		fmt.Fprintf(os.Stderr, "Error parsing arguments: %v\n", err)
-		os.Exit(1)
-	}
-
-	if *devID == "" {
-		fmt.Fprintln(os.Stderr, "Error: --dev-id is required")
-		os.Exit(1)
-	}
-
-	store, err := connectToStore()
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "Error connecting to database: %v\n", err)
-		os.Exit(1)
-	}
-
-	ctx := context.Background()
-	isActive := false
-	update := state.WebAPIKeyUpdate{
-		IsActive: &isActive,
-	}
-
-	if err := store.UpdateAPIKey(ctx, *devID, update); err != nil {
-		if err == state.ErrNoAPIKey {
-			fmt.Fprintf(os.Stderr, "Error: API key not found for dev_id: %s\n", *devID)
-		} else {
-			fmt.Fprintf(os.Stderr, "Error revoking API key: %v\n", err)
-		}
-		os.Exit(1)
-	}
-
-	fmt.Printf("Successfully revoked API key: %s\n", *devID)
-}
-
-func handleActivate(args []string) {
-	fs := flag.NewFlagSet("activate", flag.ExitOnError)
-	devID := fs.String("dev-id", "", "Developer ID to activate (required)")
-
-	if err := fs.Parse(args); err != nil {
-		fmt.Fprintf(os.Stderr, "Error parsing arguments: %v\n", err)
-		os.Exit(1)
-	}
-
-	if *devID == "" {
-		fmt.Fprintln(os.Stderr, "Error: --dev-id is required")
-		os.Exit(1)
-	}
-
-	store, err := connectToStore()
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "Error connecting to database: %v\n", err)
-		os.Exit(1)
-	}
-
-	ctx := context.Background()
-	isActive := true
-	update := state.WebAPIKeyUpdate{
-		IsActive: &isActive,
-	}
-
-	if err := store.UpdateAPIKey(ctx, *devID, update); err != nil {
-		if err == state.ErrNoAPIKey {
-			fmt.Fprintf(os.Stderr, "Error: API key not found for dev_id: %s\n", *devID)
-		} else {
-			fmt.Fprintf(os.Stderr, "Error activating API key: %v\n", err)
-		}
-		os.Exit(1)
-	}
-
-	fmt.Printf("Successfully activated API key: %s\n", *devID)
-}
-
-func handleUpdate(args []string) {
-	fs := flag.NewFlagSet("update", flag.ExitOnError)
-	devID := fs.String("dev-id", "", "Developer ID to update (required)")
-	appName := fs.String("app-name", "", "New application name")
-	originsStr := fs.String("origins", "", "New comma-separated list of allowed origins")
-	rateLimit := fs.Int("rate-limit", -1, "New requests per minute limit")
-	capabilitiesStr := fs.String("capabilities", "", "New comma-separated list of capabilities")
-
-	if err := fs.Parse(args); err != nil {
-		fmt.Fprintf(os.Stderr, "Error parsing arguments: %v\n", err)
-		os.Exit(1)
-	}
-
-	if *devID == "" {
-		fmt.Fprintln(os.Stderr, "Error: --dev-id is required")
-		os.Exit(1)
-	}
-
-	update := state.WebAPIKeyUpdate{}
-
-	if *appName != "" {
-		update.AppName = appName
-	}
-
-	if *originsStr != "" {
-		origins := parseCSV(*originsStr)
-		update.AllowedOrigins = &origins
-	}
-
-	if *rateLimit > 0 {
-		update.RateLimit = rateLimit
-	}
-
-	if *capabilitiesStr != "" {
-		capabilities := parseCSV(*capabilitiesStr)
-		update.Capabilities = &capabilities
-	}
-
-	// Check if any updates were provided
-	updateJSON, _ := json.Marshal(update)
-	if string(updateJSON) == "{}" {
-		fmt.Fprintln(os.Stderr, "Error: No update fields provided")
-		os.Exit(1)
-	}
-
-	store, err := connectToStore()
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "Error connecting to database: %v\n", err)
-		os.Exit(1)
-	}
-
-	ctx := context.Background()
-	if err := store.UpdateAPIKey(ctx, *devID, update); err != nil {
-		if err == state.ErrNoAPIKey {
-			fmt.Fprintf(os.Stderr, "Error: API key not found for dev_id: %s\n", *devID)
-		} else {
-			fmt.Fprintf(os.Stderr, "Error updating API key: %v\n", err)
-		}
-		os.Exit(1)
-	}
-
-	fmt.Printf("Successfully updated API key: %s\n", *devID)
-}
-
-func connectToStore() (*state.SQLiteUserStore, error) {
-	dbPath := os.Getenv("DB_PATH")
-	if dbPath == "" {
-		dbPath = "oscar.sqlite"
-	}
-	return state.NewSQLiteUserStore(dbPath)
-}
-
-func parseCSV(input string) []string {
-	if input == "" {
-		return []string{}
-	}
-	parts := strings.Split(input, ",")
-	result := make([]string, 0, len(parts))
-	for _, part := range parts {
-		trimmed := strings.TrimSpace(part)
-		if trimmed != "" {
-			result = append(result, trimmed)
-		}
-	}
-	return result
-}
-
-func truncateString(s string, maxLen int) string {
-	if len(s) <= maxLen {
-		return s
-	}
-	return s[:maxLen-3] + "..."
-}

+ 1 - 20
server/http/mgmt_api.go

@@ -18,14 +18,12 @@ import (
 	"strings"
 	"time"
 
-	"github.com/google/uuid"
-
 	"github.com/mk6i/open-oscar-server/config"
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
-func NewManagementAPI(bld config.Build, listener string, userManager UserManager, sessionRetriever SessionRetriever, buddyBroadcaster BuddyBroadcaster, chatRoomRetriever ChatRoomRetriever, chatRoomCreator ChatRoomCreator, chatRoomDeleter ChatRoomDeleter, chatSessionRetriever ChatSessionRetriever, directoryManager DirectoryManager, messageRelayer MessageRelayer, bartAssetManager BARTAssetManager, feedbagRetriever FeedBagRetriever, feedbagManager FeedbagManager, accountManager AccountManager, profileRetriever ProfileRetriever, webAPIKeyManager WebAPIKeyManager, icqProfileManager ICQProfileManager, createAccount state.CreateAccountFunc, logger *slog.Logger) *Server {
+func NewManagementAPI(bld config.Build, listener string, userManager UserManager, sessionRetriever SessionRetriever, buddyBroadcaster BuddyBroadcaster, chatRoomRetriever ChatRoomRetriever, chatRoomCreator ChatRoomCreator, chatRoomDeleter ChatRoomDeleter, chatSessionRetriever ChatSessionRetriever, directoryManager DirectoryManager, messageRelayer MessageRelayer, bartAssetManager BARTAssetManager, feedbagRetriever FeedBagRetriever, feedbagManager FeedbagManager, accountManager AccountManager, profileRetriever ProfileRetriever, icqProfileManager ICQProfileManager, createAccount state.CreateAccountFunc, logger *slog.Logger) *Server {
 	mux := http.NewServeMux()
 
 	// Handlers for '/user' route
@@ -109,23 +107,6 @@ func NewManagementAPI(bld config.Build, listener string, userManager UserManager
 		getVersionHandler(w, bld)
 	})
 
-	// Handlers for '/admin/webapi/keys' route - Web API key management
-	mux.HandleFunc("POST /admin/webapi/keys", func(w http.ResponseWriter, r *http.Request) {
-		postWebAPIKeyHandler(w, r, webAPIKeyManager, uuid.New, logger)
-	})
-	mux.HandleFunc("GET /admin/webapi/keys", func(w http.ResponseWriter, r *http.Request) {
-		getWebAPIKeysHandler(w, r, webAPIKeyManager, logger)
-	})
-	mux.HandleFunc("GET /admin/webapi/keys/{id}", func(w http.ResponseWriter, r *http.Request) {
-		getWebAPIKeyHandler(w, r, webAPIKeyManager, logger)
-	})
-	mux.HandleFunc("PUT /admin/webapi/keys/{id}", func(w http.ResponseWriter, r *http.Request) {
-		putWebAPIKeyHandler(w, r, webAPIKeyManager, logger)
-	})
-	mux.HandleFunc("DELETE /admin/webapi/keys/{id}", func(w http.ResponseWriter, r *http.Request) {
-		deleteWebAPIKeyHandler(w, r, webAPIKeyManager, logger)
-	})
-
 	// Handlers for '/directory/category' route
 	mux.HandleFunc("GET /directory/category", func(w http.ResponseWriter, r *http.Request) {
 		getDirectoryCategoryHandler(w, r, directoryManager, logger)

+ 0 - 28
server/http/types.go

@@ -315,26 +315,6 @@ func feedbagGroupFromItem(item wire.FeedbagItem) feedbagGroupHandle {
 	}
 }
 
-// Web API key management types
-
-type createWebAPIKeyRequest struct {
-	AppName        string   `json:"app_name"`
-	AllowedOrigins []string `json:"allowed_origins,omitempty"`
-	RateLimit      int      `json:"rate_limit,omitempty"`
-	Capabilities   []string `json:"capabilities,omitempty"`
-}
-
-type webAPIKeyResponse struct {
-	DevID          string    `json:"dev_id"`
-	DevKey         string    `json:"dev_key,omitempty"` // Only shown on creation
-	AppName        string    `json:"app_name"`
-	CreatedAt      time.Time `json:"created_at"`
-	IsActive       bool      `json:"is_active"`
-	RateLimit      int       `json:"rate_limit"`
-	AllowedOrigins []string  `json:"allowed_origins,omitempty"`
-	Capabilities   []string  `json:"capabilities,omitempty"`
-}
-
 // icqProfileHandle is the JSON representation of a full ICQ user profile.
 type icqProfileHandle struct {
 	UIN          uint32                `json:"uin"`
@@ -425,11 +405,3 @@ type icqPermissionsHandle struct {
 	WebAware     bool `json:"web_aware"`
 	AllowSpam    bool `json:"allow_spam"`
 }
-
-type updateWebAPIKeyRequest struct {
-	AppName        *string   `json:"app_name,omitempty"`
-	IsActive       *bool     `json:"is_active,omitempty"`
-	RateLimit      *int      `json:"rate_limit,omitempty"`
-	AllowedOrigins *[]string `json:"allowed_origins,omitempty"`
-	Capabilities   *[]string `json:"capabilities,omitempty"`
-}

+ 0 - 252
server/http/webapi_admin.go

@@ -1,252 +0,0 @@
-package http
-
-import (
-	"context"
-	"crypto/rand"
-	"encoding/hex"
-	"encoding/json"
-	"fmt"
-	"log/slog"
-	"net/http"
-	"time"
-
-	"github.com/google/uuid"
-
-	"github.com/mk6i/open-oscar-server/state"
-)
-
-// WebAPIKeyManager defines methods for managing Web API authentication keys.
-type WebAPIKeyManager interface {
-	// CreateAPIKey creates a new Web API key.
-	CreateAPIKey(ctx context.Context, key state.WebAPIKey) error
-
-	// GetAPIKeyByDevID retrieves an API key by its developer ID.
-	GetAPIKeyByDevID(ctx context.Context, devID string) (*state.WebAPIKey, error)
-
-	// ListAPIKeys returns all Web API keys.
-	ListAPIKeys(ctx context.Context) ([]state.WebAPIKey, error)
-
-	// UpdateAPIKey updates an existing Web API key.
-	UpdateAPIKey(ctx context.Context, devID string, updates state.WebAPIKeyUpdate) error
-
-	// DeleteAPIKey removes a Web API key.
-	DeleteAPIKey(ctx context.Context, devID string) error
-}
-
-// postWebAPIKeyHandler handles POST /admin/webapi/keys requests.
-func postWebAPIKeyHandler(w http.ResponseWriter, r *http.Request, keyManager WebAPIKeyManager, newUUID func() uuid.UUID, logger *slog.Logger) {
-	var req createWebAPIKeyRequest
-	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		http.Error(w, "malformed request body", http.StatusBadRequest)
-		return
-	}
-
-	// Validate required fields
-	if req.AppName == "" {
-		http.Error(w, "app_name is required", http.StatusBadRequest)
-		return
-	}
-
-	// Set defaults
-	if req.RateLimit <= 0 {
-		req.RateLimit = 60 // Default rate limit
-	}
-
-	// Generate secure API key
-	keyBytes := make([]byte, 32) // 256 bits
-	if _, err := rand.Read(keyBytes); err != nil {
-		logger.Error("failed to generate API key", "err", err.Error())
-		http.Error(w, "internal server error", http.StatusInternalServerError)
-		return
-	}
-	devKey := hex.EncodeToString(keyBytes)
-
-	// Generate developer ID
-	devID := fmt.Sprintf("dev_%s", newUUID().String())
-
-	// Create the API key record
-	apiKey := state.WebAPIKey{
-		DevID:          devID,
-		DevKey:         devKey,
-		AppName:        req.AppName,
-		CreatedAt:      time.Now(),
-		IsActive:       true,
-		RateLimit:      req.RateLimit,
-		AllowedOrigins: req.AllowedOrigins,
-		Capabilities:   req.Capabilities,
-	}
-
-	// Save to database
-	if err := keyManager.CreateAPIKey(r.Context(), apiKey); err != nil {
-		if err == state.ErrDupAPIKey {
-			http.Error(w, "API key already exists", http.StatusConflict)
-			return
-		}
-		logger.Error("failed to create API key", "err", err.Error())
-		http.Error(w, "internal server error", http.StatusInternalServerError)
-		return
-	}
-
-	// Return the created key (including the dev_key which is only shown once)
-	resp := webAPIKeyResponse{
-		DevID:          apiKey.DevID,
-		DevKey:         apiKey.DevKey, // Only shown on creation
-		AppName:        apiKey.AppName,
-		CreatedAt:      apiKey.CreatedAt,
-		IsActive:       apiKey.IsActive,
-		RateLimit:      apiKey.RateLimit,
-		AllowedOrigins: apiKey.AllowedOrigins,
-		Capabilities:   apiKey.Capabilities,
-	}
-
-	w.Header().Set("Content-Type", "application/json")
-	w.WriteHeader(http.StatusCreated)
-	if err := json.NewEncoder(w).Encode(resp); err != nil {
-		logger.Error("failed to encode response", "err", err.Error())
-	}
-}
-
-// getWebAPIKeysHandler handles GET /admin/webapi/keys requests.
-func getWebAPIKeysHandler(w http.ResponseWriter, r *http.Request, keyManager WebAPIKeyManager, logger *slog.Logger) {
-	keys, err := keyManager.ListAPIKeys(r.Context())
-	if err != nil {
-		logger.Error("failed to list API keys", "err", err.Error())
-		http.Error(w, "internal server error", http.StatusInternalServerError)
-		return
-	}
-
-	// Convert to response format (without dev_key)
-	resp := make([]webAPIKeyResponse, 0, len(keys))
-	for _, key := range keys {
-		resp = append(resp, webAPIKeyResponse{
-			DevID:          key.DevID,
-			AppName:        key.AppName,
-			CreatedAt:      key.CreatedAt,
-			IsActive:       key.IsActive,
-			RateLimit:      key.RateLimit,
-			AllowedOrigins: key.AllowedOrigins,
-			Capabilities:   key.Capabilities,
-		})
-	}
-
-	w.Header().Set("Content-Type", "application/json")
-	if err := json.NewEncoder(w).Encode(resp); err != nil {
-		logger.Error("failed to encode response", "err", err.Error())
-	}
-}
-
-// getWebAPIKeyHandler handles GET /admin/webapi/keys/{id} requests.
-func getWebAPIKeyHandler(w http.ResponseWriter, r *http.Request, keyManager WebAPIKeyManager, logger *slog.Logger) {
-	devID := r.PathValue("id")
-	if devID == "" {
-		http.Error(w, "missing developer ID", http.StatusBadRequest)
-		return
-	}
-
-	key, err := keyManager.GetAPIKeyByDevID(r.Context(), devID)
-	if err != nil {
-		if err == state.ErrNoAPIKey {
-			http.Error(w, "API key not found", http.StatusNotFound)
-			return
-		}
-		logger.Error("failed to get API key", "err", err.Error())
-		http.Error(w, "internal server error", http.StatusInternalServerError)
-		return
-	}
-
-	// Convert to response format (without dev_key)
-	resp := webAPIKeyResponse{
-		DevID:          key.DevID,
-		AppName:        key.AppName,
-		CreatedAt:      key.CreatedAt,
-		IsActive:       key.IsActive,
-		RateLimit:      key.RateLimit,
-		AllowedOrigins: key.AllowedOrigins,
-		Capabilities:   key.Capabilities,
-	}
-
-	w.Header().Set("Content-Type", "application/json")
-	if err := json.NewEncoder(w).Encode(resp); err != nil {
-		logger.Error("failed to encode response", "err", err.Error())
-	}
-}
-
-// putWebAPIKeyHandler handles PUT /admin/webapi/keys/{id} requests.
-func putWebAPIKeyHandler(w http.ResponseWriter, r *http.Request, keyManager WebAPIKeyManager, logger *slog.Logger) {
-	devID := r.PathValue("id")
-	if devID == "" {
-		http.Error(w, "missing developer ID", http.StatusBadRequest)
-		return
-	}
-
-	var req updateWebAPIKeyRequest
-	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-		http.Error(w, "malformed request body", http.StatusBadRequest)
-		return
-	}
-
-	// Convert request to update struct
-	updates := state.WebAPIKeyUpdate{
-		AppName:        req.AppName,
-		IsActive:       req.IsActive,
-		RateLimit:      req.RateLimit,
-		AllowedOrigins: req.AllowedOrigins,
-		Capabilities:   req.Capabilities,
-	}
-
-	// Update the key
-	if err := keyManager.UpdateAPIKey(r.Context(), devID, updates); err != nil {
-		if err == state.ErrNoAPIKey {
-			http.Error(w, "API key not found", http.StatusNotFound)
-			return
-		}
-		logger.Error("failed to update API key", "err", err.Error())
-		http.Error(w, "internal server error", http.StatusInternalServerError)
-		return
-	}
-
-	// Retrieve the updated key to return
-	key, err := keyManager.GetAPIKeyByDevID(r.Context(), devID)
-	if err != nil {
-		logger.Error("failed to retrieve updated API key", "err", err.Error())
-		http.Error(w, "internal server error", http.StatusInternalServerError)
-		return
-	}
-
-	// Convert to response format (without dev_key)
-	resp := webAPIKeyResponse{
-		DevID:          key.DevID,
-		AppName:        key.AppName,
-		CreatedAt:      key.CreatedAt,
-		IsActive:       key.IsActive,
-		RateLimit:      key.RateLimit,
-		AllowedOrigins: key.AllowedOrigins,
-		Capabilities:   key.Capabilities,
-	}
-
-	w.Header().Set("Content-Type", "application/json")
-	if err := json.NewEncoder(w).Encode(resp); err != nil {
-		logger.Error("failed to encode response", "err", err.Error())
-	}
-}
-
-// deleteWebAPIKeyHandler handles DELETE /admin/webapi/keys/{id} requests.
-func deleteWebAPIKeyHandler(w http.ResponseWriter, r *http.Request, keyManager WebAPIKeyManager, logger *slog.Logger) {
-	devID := r.PathValue("id")
-	if devID == "" {
-		http.Error(w, "missing developer ID", http.StatusBadRequest)
-		return
-	}
-
-	if err := keyManager.DeleteAPIKey(r.Context(), devID); err != nil {
-		if err == state.ErrNoAPIKey {
-			http.Error(w, "API key not found", http.StatusNotFound)
-			return
-		}
-		logger.Error("failed to delete API key", "err", err.Error())
-		http.Error(w, "internal server error", http.StatusInternalServerError)
-		return
-	}
-
-	w.WriteHeader(http.StatusNoContent)
-}

+ 1 - 41
server/webapi/aim_handler.go

@@ -133,13 +133,6 @@ type StartSessionData struct {
 func (h *AimHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 	ctx := r.Context()
 
-	// Get API key info from context (set by auth middleware)
-	apiKey, ok := ctx.Value(ContextKeyAPIKey).(*state.WebAPIKey)
-	if !ok {
-		SendEnvelopeStatus(w, r, http.StatusInternalServerError, "internal server error", h.Logger)
-		return
-	}
-
 	authToken := param(r, "a")
 
 	// Get client info
@@ -274,7 +267,7 @@ func (h *AimHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 	// read it.
 	baseURL := baseURLFromRequest(r)
 
-	session, err := h.SessionManager.CreateSession(screenName, apiKey.DevID, events, instance, baseURL, h.Logger)
+	session, err := h.SessionManager.CreateSession(screenName, events, instance, baseURL, h.Logger)
 	if err != nil {
 		h.Logger.ErrorContext(ctx, "failed to create session", "err", err.Error())
 		// CreateSession refuses once the manager is shut down, so this is the
@@ -497,7 +490,6 @@ func (h *AimHandler) StartSession(w http.ResponseWriter, r *http.Request) {
 	h.Logger.DebugContext(ctx, "session started",
 		"aimsid", session.AimSID,
 		"screen_name", screenName,
-		"dev_id", apiKey.DevID,
 		"events", events,
 		"format", r.URL.Query().Get("f"),
 	)
@@ -753,22 +745,6 @@ func (h *AimHandler) StartOSCARSession(w http.ResponseWriter, r *http.Request) {
 		"remote_addr", r.RemoteAddr,
 		"user_agent", r.UserAgent())
 
-	// Get API key info from context (set by auth middleware)
-	apiKey, ok := ctx.Value(ContextKeyAPIKey).(*state.WebAPIKey)
-	if !ok {
-		h.Logger.Error("API key not found in context")
-		SendError(w, r, http.StatusInternalServerError, "internal server error")
-		return
-	}
-
-	// Verify that this API key has permission to create OSCAR sessions
-	if !hasOSCARBridgeCapability(apiKey) {
-		h.Logger.Warn("API key lacks OSCAR bridge capability",
-			"dev_id", apiKey.DevID)
-		SendError(w, r, http.StatusForbidden, "OSCAR bridge not enabled for this application")
-		return
-	}
-
 	params := r.URL.Query()
 
 	token := params.Get("a")
@@ -838,22 +814,6 @@ func (h *AimHandler) StartOSCARSession(w http.ResponseWriter, r *http.Request) {
 		"use_tls", useTLS)
 }
 
-// hasOSCARBridgeCapability checks if the API key has permission to create OSCAR bridges.
-func hasOSCARBridgeCapability(apiKey *state.WebAPIKey) bool {
-	if len(apiKey.Capabilities) == 0 {
-		return true // No restrictions if capabilities not specified
-	}
-
-	// Check if OSCAR bridge is explicitly enabled
-	for _, cap := range apiKey.Capabilities {
-		if cap == "oscar_bridge" || cap == "*" {
-			return true
-		}
-	}
-
-	return false
-}
-
 // parseBoolParam parses a boolean parameter from query string.
 func parseBoolParam(value string) bool {
 	value = strings.ToLower(value)

+ 5 - 46
server/webapi/aim_handler_test.go

@@ -1,7 +1,6 @@
 package webapi
 
 import (
-	"context"
 	"encoding/base64"
 	"encoding/json"
 	"fmt"
@@ -324,14 +323,9 @@ func testListener(sslAvailable bool) config.ListenerGroup {
 	return g
 }
 
-// bridgeRequest builds a startOSCARSession request carrying the API key the
-// middleware would have put on the context.
-func bridgeRequest(query string, apiKey *state.WebAPIKey) *http.Request {
-	req := httptest.NewRequest(http.MethodGet, "/aim/startOSCARSession?"+query, nil)
-	if apiKey != nil {
-		req = req.WithContext(context.WithValue(req.Context(), ContextKeyAPIKey, apiKey))
-	}
-	return req
+// bridgeRequest builds a startOSCARSession request.
+func bridgeRequest(query string) *http.Request {
+	return httptest.NewRequest(http.MethodGet, "/aim/startOSCARSession?"+query, nil)
 }
 
 // bridgeData is the data object of a successful startOSCARSession response.
@@ -349,12 +343,10 @@ type bridgeData struct {
 
 func TestAimHandler_StartOSCARSession(t *testing.T) {
 	validToken := base64.URLEncoding.EncodeToString(signedCookieFor("testuser"))
-	unrestrictedKey := &state.WebAPIKey{DevID: "dev123"}
 
 	tests := []struct {
 		name         string
 		query        string
-		apiKey       *state.WebAPIKey
 		sslAvailable bool
 		expectedCode int
 		checkBody    func(t *testing.T, body string)
@@ -363,7 +355,6 @@ func TestAimHandler_StartOSCARSession(t *testing.T) {
 			// No tlsCertName, which is how the client reads "connect in the clear".
 			name:         "Success_Plaintext",
 			query:        "a=" + validToken,
-			apiKey:       unrestrictedKey,
 			expectedCode: http.StatusOK,
 			checkBody: func(t *testing.T, body string) {
 				got := decodeBridgeData(t, body)
@@ -376,7 +367,6 @@ func TestAimHandler_StartOSCARSession(t *testing.T) {
 		{
 			name:         "Success_TLS",
 			query:        "a=" + validToken + "&useTLS=1",
-			apiKey:       unrestrictedKey,
 			sslAvailable: true,
 			expectedCode: http.StatusOK,
 			checkBody: func(t *testing.T, body string) {
@@ -392,7 +382,6 @@ func TestAimHandler_StartOSCARSession(t *testing.T) {
 			// rather than failing the handoff.
 			name:         "TLSRequestedButUnavailable_DegradesToPlaintext",
 			query:        "a=" + validToken + "&useTLS=true",
-			apiKey:       unrestrictedKey,
 			sslAvailable: false,
 			expectedCode: http.StatusOK,
 			checkBody: func(t *testing.T, body string) {
@@ -404,7 +393,6 @@ func TestAimHandler_StartOSCARSession(t *testing.T) {
 		{
 			name:         "Error_MissingToken",
 			query:        "",
-			apiKey:       unrestrictedKey,
 			expectedCode: http.StatusUnauthorized,
 			checkBody: func(t *testing.T, body string) {
 				assert.Contains(t, body, "authentication token required")
@@ -413,7 +401,6 @@ func TestAimHandler_StartOSCARSession(t *testing.T) {
 		{
 			name:         "Error_TokenNotBase64",
 			query:        "a=not!valid!base64",
-			apiKey:       unrestrictedKey,
 			expectedCode: http.StatusUnauthorized,
 			checkBody: func(t *testing.T, body string) {
 				assert.Contains(t, body, "invalid or expired token")
@@ -424,39 +411,11 @@ func TestAimHandler_StartOSCARSession(t *testing.T) {
 			// past its expiry.
 			name:         "Error_TokenFailsSignatureCheck",
 			query:        "a=" + base64.URLEncoding.EncodeToString([]byte("forged")),
-			apiKey:       unrestrictedKey,
 			expectedCode: http.StatusUnauthorized,
 			checkBody: func(t *testing.T, body string) {
 				assert.Contains(t, body, "invalid or expired token")
 			},
 		},
-		{
-			name:         "Error_NoAPIKeyOnContext",
-			query:        "a=" + validToken,
-			apiKey:       nil,
-			expectedCode: http.StatusInternalServerError,
-			checkBody: func(t *testing.T, body string) {
-				assert.Contains(t, body, "internal server error")
-			},
-		},
-		{
-			name:         "Error_APIKeyLacksBridgeCapability",
-			query:        "a=" + validToken,
-			apiKey:       &state.WebAPIKey{DevID: "dev123", Capabilities: []string{"presence"}},
-			expectedCode: http.StatusForbidden,
-			checkBody: func(t *testing.T, body string) {
-				assert.Contains(t, body, "OSCAR bridge not enabled")
-			},
-		},
-		{
-			name:         "Success_APIKeyGrantsBridgeCapability",
-			query:        "a=" + validToken,
-			apiKey:       &state.WebAPIKey{DevID: "dev123", Capabilities: []string{"presence", "oscar_bridge"}},
-			expectedCode: http.StatusOK,
-			checkBody: func(t *testing.T, body string) {
-				assert.Equal(t, 200, decodeBridgeData(t, body).Response.StatusCode)
-			},
-		},
 	}
 
 	for _, tt := range tests {
@@ -468,7 +427,7 @@ func TestAimHandler_StartOSCARSession(t *testing.T) {
 			}
 
 			rr := httptest.NewRecorder()
-			handler.StartOSCARSession(rr, bridgeRequest(tt.query, tt.apiKey))
+			handler.StartOSCARSession(rr, bridgeRequest(tt.query))
 
 			assert.Equal(t, tt.expectedCode, rr.Code)
 			tt.checkBody(t, rr.Body.String())
@@ -498,7 +457,7 @@ func TestAimHandler_StartOSCARSession_ReencodesCookie(t *testing.T) {
 	}
 
 	rr := httptest.NewRecorder()
-	handler.StartOSCARSession(rr, bridgeRequest("a="+urlSafe, &state.WebAPIKey{DevID: "dev123"}))
+	handler.StartOSCARSession(rr, bridgeRequest("a="+urlSafe))
 
 	assert.Equal(t, http.StatusOK, rr.Code)
 	assert.Equal(t, rawCookie, cracked, "the baker sees the decoded cookie")

+ 1 - 1
server/webapi/buddylist_handler_test.go

@@ -561,7 +561,7 @@ func TestRequireSession(t *testing.T) {
 				SendResponse(w, r, resp, slog.Default())
 			}
 
-			authMiddleware := NewAuthMiddleware(nil, slog.Default())
+			authMiddleware := NewAuthMiddleware(slog.Default())
 			wrapped := authMiddleware.RequireSession(sm, next)
 
 			reqURL := "/buddylist/test"

+ 3 - 3
server/webapi/im_handler_test.go

@@ -21,14 +21,14 @@ import (
 
 // requireSession wraps next with the session-resolving auth middleware for tests.
 func requireSession(sm SessionResolver, next func(http.ResponseWriter, *http.Request, *Session)) http.Handler {
-	return NewAuthMiddleware(nil, slog.Default()).RequireSession(sm, next)
+	return NewAuthMiddleware(slog.Default()).RequireSession(sm, next)
 }
 
 // createTestSessionManager creates a SessionManager with a pre-populated session.
 // createTestSessionManagerWithOSCAR creates a SessionManager with an OSCAR session instance set.
 func createTestSessionManagerWithOSCAR(screenName string, oscarSession *state.SessionInstance) (*SessionManager, string) {
 	mgr := NewSessionManager()
-	session, _ := mgr.CreateSession(state.DisplayScreenName(screenName), "test-dev", []string{"im", "presence", "buddylist", "sentIM", "typing"}, oscarSession, "", slog.Default())
+	session, _ := mgr.CreateSession(state.DisplayScreenName(screenName), []string{"im", "presence", "buddylist", "sentIM", "typing"}, oscarSession, "", slog.Default())
 	return mgr, session.AimSID
 }
 
@@ -73,7 +73,7 @@ func sendIMForDest(t *testing.T, dest, locateName, alias string) []Event {
 		Return(nil, nil)
 
 	mgr := NewSessionManager()
-	session, err := mgr.CreateSession(state.DisplayScreenName("Ann Dupree"), "test-dev", []string{"im", "sentIM", "conversation"}, oscarInstance, "", slog.Default())
+	session, err := mgr.CreateSession(state.DisplayScreenName("Ann Dupree"), []string{"im", "sentIM", "conversation"}, oscarInstance, "", slog.Default())
 	require.NoError(t, err)
 
 	handler := &MessagingHandler{

+ 3 - 3
server/webapi/memberdir_handler_test.go

@@ -356,10 +356,10 @@ func TestMemberDirHandler_Update_QueryValuesAreNotUnescapedTwice(t *testing.T) {
 
 func TestServer_MemberDirUpdateIsRoutedForGETAndPOST(t *testing.T) {
 	// Go 1.22 mux patterns are method-exact, so registering only GET sends a POST to
-	// the catch-all 404. Neither request below carries credentials, so a routed one
-	// is rejected by the auth middleware (400) and an unrouted one 404s.
+	// the catch-all 404. Neither request below carries an aimsid, so a routed one
+	// is rejected by the session middleware (400) and an unrouted one 404s.
 	srv := NewServer([]string{"127.0.0.1:0"}, slog.Default(), Handler{Logger: slog.Default()},
-		nil, NewSessionManager())
+		NewSessionManager())
 	require.NotEmpty(t, srv.servers)
 	mux := srv.servers[0].Handler
 

+ 28 - 390
server/webapi/middleware.go

@@ -1,135 +1,25 @@
 package webapi
 
 import (
-	"context"
 	"fmt"
 	"log/slog"
 	"net/http"
-	"strings"
-	"sync"
 	"time"
 
-	"github.com/patrickmn/go-cache"
-	"golang.org/x/time/rate"
-
 	"github.com/mk6i/open-oscar-server/state"
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
-// contextKey is a custom type for context keys to avoid collisions.
-type contextKey string
-
-const (
-	// ContextKeyAPIKey is the context key for storing the validated API key.
-	ContextKeyAPIKey contextKey = "api_key"
-	// contextKeyResolvedAPIKey caches an API key lookup across middlewares
-	// handling the same request. Unexported: it is an internal memo, not
-	// something handlers should read.
-	contextKeyResolvedAPIKey contextKey = "resolved_api_key"
-)
-
-// RateLimitInfo contains rate limit metadata for a request.
-type RateLimitInfo struct {
-	Limit     int   // Total requests allowed per window
-	Remaining int   // Requests remaining in current window
-	Reset     int64 // Unix timestamp when the window resets
-	Allowed   bool  // Whether the request is allowed
-}
-
-// rateLimiterEntry tracks rate limiting data for a single devID.
-type rateLimiterEntry struct {
-	limiter    *rate.Limiter
-	limit      int
-	windowSize time.Duration
-	lastReset  time.Time
-}
-
-// RateLimiter manages per-devID rate limiting for the Web API.
-type RateLimiter struct {
-	limiters   *cache.Cache
-	mu         sync.RWMutex
-	windowSize time.Duration // Rate limit window size (default: 1 minute)
-}
-
-// NewRateLimiter creates a new rate limiter with automatic cleanup.
-func NewRateLimiter() *RateLimiter {
-	// Create cache with 5 minute expiration and 10 minute cleanup interval
-	c := cache.New(5*time.Minute, 10*time.Minute)
-	return &RateLimiter{
-		limiters:   c,
-		windowSize: time.Minute, // Default 1 minute window
-	}
-}
-
-// CheckRateLimit checks if a request from the given devID is allowed and returns rate limit info.
-func (r *RateLimiter) CheckRateLimit(devID string, limit int) RateLimitInfo {
-	if limit <= 0 {
-		return RateLimitInfo{
-			Reset:   time.Now().Add(r.windowSize).Unix(),
-			Allowed: true,
-		}
-	}
-
-	r.mu.Lock()
-	defer r.mu.Unlock()
-
-	now := time.Now()
-
-	// Get or create limiter entry for this devID
-	var entry *rateLimiterEntry
-	if val, found := r.limiters.Get(devID); found {
-		entry = val.(*rateLimiterEntry)
-		// Check if limit has changed
-		if entry.limit != limit {
-			// Recreate limiter with new limit
-			entry.limiter = rate.NewLimiter(rate.Every(r.windowSize/time.Duration(limit)), limit)
-			entry.limit = limit
-		}
-	} else {
-		// Create new limiter with burst equal to limit (allows initial burst)
-		entry = &rateLimiterEntry{
-			limiter:    rate.NewLimiter(rate.Every(r.windowSize/time.Duration(limit)), limit),
-			limit:      limit,
-			windowSize: r.windowSize,
-			lastReset:  now,
-		}
-		r.limiters.Set(devID, entry, cache.DefaultExpiration)
-	}
-
-	// Check if request is allowed
-	allowed := entry.limiter.Allow()
-
-	// Calculate remaining requests (approximate based on tokens available)
-	tokens := entry.limiter.Tokens()
-	remaining := int(tokens)
-	if remaining < 0 {
-		remaining = 0
-	}
-
-	// Calculate reset time (next window start)
-	resetTime := now.Add(r.windowSize).Unix()
-
-	return RateLimitInfo{
-		Limit:     limit,
-		Remaining: remaining,
-		Reset:     resetTime,
-		Allowed:   allowed,
-	}
-}
-
-// AuthMiddleware provides authentication and rate limiting for Web API endpoints.
+// AuthMiddleware provides session resolution and CORS handling for Web API
+// endpoints.
 type AuthMiddleware struct {
-	Validator   APIKeyValidator
-	RateLimiter *RateLimiter
-	Logger      *slog.Logger
+	Logger *slog.Logger
 }
 
 // NewAuthMiddleware creates a new authentication middleware instance.
-func NewAuthMiddleware(validator APIKeyValidator, logger *slog.Logger) *AuthMiddleware {
+func NewAuthMiddleware(logger *slog.Logger) *AuthMiddleware {
 	return &AuthMiddleware{
-		Validator:   validator,
-		RateLimiter: NewRateLimiter(),
-		Logger:      logger,
+		Logger: logger,
 	}
 }
 
@@ -139,9 +29,8 @@ func NewAuthMiddleware(validator APIKeyValidator, logger *slog.Logger) *AuthMidd
 // holds a long-polling client's session open (see the session lifecycle timeline
 // on state's Session manager).
 //
-// A session with a nil OSCARSession is rejected as a 500: startSession no longer
-// creates such sessions (anonymous guests are unsupported), so a nil is a broken
-// server invariant, not a client error. This lets downstream handlers treat
+// startSession is the only thing that creates a session and it always attaches an
+// OSCAR session (anonymous guests are unsupported), so downstream handlers treat
 // session.OSCARSession as non-nil.
 func (m *AuthMiddleware) RequireSession(sm SessionResolver, next func(http.ResponseWriter, *http.Request, *Session)) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -160,116 +49,37 @@ func (m *AuthMiddleware) RequireSession(sm SessionResolver, next func(http.Respo
 	})
 }
 
-// Authenticate is an HTTP middleware that validates API keys and enforces rate limits.
-func (m *AuthMiddleware) Authenticate(next http.Handler) http.Handler {
-	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		// Extract API key from 'k' parameter (query or form)
-		apiKey := param(r, "k")
-
-		if apiKey == "" {
-			SendEnvelopeStatus(w, r, http.StatusBadRequest, "required parameter 'k' is missing", m.Logger)
-			return
-		}
-
-		// Validate API key
-		key, r := m.resolveAPIKeyCached(r, apiKey)
-		ctx := r.Context()
-		if key == nil {
-			m.Logger.DebugContext(ctx, "invalid API key attempted", "key", apiKey[:min(8, len(apiKey))]+"...")
-			SendEnvelopeStatus(w, r, http.StatusForbidden, "invalid API key", m.Logger)
-			return
-		}
-
-		// Check rate limit
-		rateLimitInfo := m.RateLimiter.CheckRateLimit(key.DevID, key.RateLimit)
-
-		// Always add rate limit headers
-		w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", rateLimitInfo.Limit))
-		w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", rateLimitInfo.Remaining))
-		w.Header().Set("X-RateLimit-Reset", fmt.Sprintf("%d", rateLimitInfo.Reset))
-
-		if !rateLimitInfo.Allowed {
-			m.Logger.WarnContext(ctx, "rate limit exceeded", "dev_id", key.DevID, "limit", key.RateLimit)
-			// Add Retry-After header
-			retryAfter := rateLimitInfo.Reset - time.Now().Unix()
-			if retryAfter < 1 {
-				retryAfter = 1
-			}
-			w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
-			SendEnvelopeStatus(w, r, http.StatusTooManyRequests, "rate limit exceeded", m.Logger)
-			return
-		}
-
-		// Add API key info to context for use in handlers
-		ctx = context.WithValue(ctx, ContextKeyAPIKey, key)
-
-		// Log the API request
-		m.Logger.InfoContext(ctx, "API request authenticated",
-			"dev_id", key.DevID,
-			"app_name", key.AppName,
-			"method", r.Method,
-			"path", r.URL.Path,
-		)
-
-		// Pass to next handler with enriched context
-		next.ServeHTTP(w, r.WithContext(ctx))
-	})
-}
-
-// CORSMiddleware emits CORS headers and answers preflight requests.
+// CORSMiddleware emits CORS headers and answers preflight requests. Every origin
+// is allowed; the aimsid session is the security boundary.
 //
-// It must be the OUTERMOST middleware on every route. A response that the auth
+// It must be the OUTERMOST middleware on every route. A response that the session
 // layer rejects still needs an Access-Control-Allow-Origin header: without one
 // the browser blocks the response, and the Web AIM client reads a status-0 empty
 // response as "CORS blocked" and permanently downgrades its whole request
 // pipeline to JSONP (aim.client.js onXhrFailed_ clears its useXhr flag and never
-// sets it again). A single 400/403/429 from the auth layer is enough to latch it.
+// sets it again). A single 400/401 from the session layer is enough to latch it.
 //
-// Running ahead of authentication means the key is not in the request context
-// yet, so this resolves it itself to find the key's origin allowlist. The lookup
-// is memoized on the request context, so the auth middleware downstream reuses it
-// rather than hitting the store a second time.
+// It reads nothing from the request body, so a POST body reaches the handler
+// unconsumed.
 func (m *AuthMiddleware) CORSMiddleware(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		// Only the query parameter is consulted here: reading the form would
-		// consume a POST body before the handler sees it.
-		key, r := m.resolveAPIKeyCached(r, r.URL.Query().Get("k"))
-
-		// If there is no API key (e.g. using aimsid auth), allow all origins.
-		// This is safe because the actual authentication is handled by the session.
-		var allowedOrigins []string
-		if key != nil {
-			allowedOrigins = key.AllowedOrigins
-		} else {
-			// For session-based auth without API key, allow all origins
-			// The session itself provides the security boundary
-			m.Logger.DebugContext(r.Context(), "CORS handling for non-API-key auth (aimsid/token)")
-			allowedOrigins = []string{"*"}
-		}
-
 		origin := r.Header.Get("Origin")
 
 		// The response body varies with the request Origin, so it must not be
 		// cached under a single key across origins.
 		w.Header().Add("Vary", "Origin")
 
-		// Check if origin is allowed
-		if m.isOriginAllowed(origin, allowedOrigins) {
-			if len(allowedOrigins) == 1 && allowedOrigins[0] == "*" {
-				// For wildcard, set the actual origin to allow credentials
-				if origin != "" {
-					w.Header().Set("Access-Control-Allow-Origin", origin)
-				} else {
-					w.Header().Set("Access-Control-Allow-Origin", "*")
-				}
-			} else {
-				w.Header().Set("Access-Control-Allow-Origin", origin)
-			}
-			w.Header().Set("Access-Control-Allow-Credentials", "true")
-			w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
-			w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
-			w.Header().Set("Access-Control-Max-Age", "3600")
+		// Echoing the origin back, rather than sending "*", is what lets the
+		// client send credentials.
+		if origin != "" {
+			w.Header().Set("Access-Control-Allow-Origin", origin)
+		} else {
+			w.Header().Set("Access-Control-Allow-Origin", "*")
 		}
+		w.Header().Set("Access-Control-Allow-Credentials", "true")
+		w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
+		w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
+		w.Header().Set("Access-Control-Max-Age", "3600")
 
 		// Handle preflight requests
 		if r.Method == "OPTIONS" {
@@ -281,183 +91,6 @@ func (m *AuthMiddleware) CORSMiddleware(next http.Handler) http.Handler {
 	})
 }
 
-// isOriginAllowed checks if an origin is in the allowed list.
-func (m *AuthMiddleware) isOriginAllowed(origin string, allowedOrigins []string) bool {
-	// If no origins specified, allow all (for backward compatibility/development)
-	if len(allowedOrigins) == 0 {
-		return true
-	}
-
-	origin = strings.ToLower(origin)
-	for _, allowed := range allowedOrigins {
-		allowed = strings.ToLower(allowed)
-
-		// Exact match
-		if origin == allowed {
-			return true
-		}
-
-		// Wildcard support (e.g., "*.example.com")
-		if strings.HasPrefix(allowed, "*.") {
-			domain := allowed[2:]
-			if strings.HasSuffix(origin, domain) {
-				return true
-			}
-		}
-
-		// Allow all origins (development only)
-		if allowed == "*" {
-			m.Logger.Warn("wildcard origin (*) used - should not be used in production")
-			return true
-		}
-	}
-
-	return false
-}
-
-// AuthenticateFlexible is an HTTP middleware that supports multiple authentication methods:
-// 1. aimsid (session ID) - no k required
-// 2. a (AOL token) - no k required
-// 3. ts + sig_sha256 (signed request) - no k required
-// 4. k (API key) - fallback if no other auth provided
-// This follows the Web AIM API specification where k is not required when aimsid is present.
-func (m *AuthMiddleware) AuthenticateFlexible(next http.Handler) http.Handler {
-	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		ctx := r.Context()
-
-		// Priority 1: Check for session-based auth (aimsid)
-		// According to the spec, when aimsid is provided, k is not required
-		if aimsid := param(r, "aimsid"); aimsid != "" {
-			// The handler itself will validate the aimsid
-			// We just need to pass the request through without requiring k
-			m.Logger.DebugContext(ctx, "using aimsid authentication", "aimsid", aimsid[:min(16, len(aimsid))]+"...")
-			next.ServeHTTP(w, r)
-			return
-		}
-
-		// Priority 2: AOL token auth — user identity is in the token; k is optional.
-		if token := param(r, "a"); token != "" {
-			key, r := m.resolveAPIKeyCached(r, param(r, "k"))
-			ctx := r.Context()
-			if key == nil {
-				devKey := param(r, "k")
-				key = &state.WebAPIKey{
-					DevID:     "aim_web",
-					DevKey:    devKey,
-					AppName:   "AIM Web Client",
-					IsActive:  true,
-					RateLimit: 600,
-				}
-			}
-			ctx = context.WithValue(ctx, ContextKeyAPIKey, key)
-			m.Logger.DebugContext(ctx, "using token authentication", "dev_id", key.DevID)
-			next.ServeHTTP(w, r.WithContext(ctx))
-			return
-		}
-
-		// Priority 3: Check for signed request auth
-		if ts := param(r, "ts"); ts != "" {
-			if sig := param(r, "sig_sha256"); sig != "" {
-				// For now, signed requests still require 'k' parameter for API key validation
-				// The signature provides additional security on top of the API key
-				// When full signature validation is implemented, this can be made optional
-				m.Logger.DebugContext(ctx, "signed request detected, falling through to API key validation")
-				// Don't return here - continue to API key validation below
-			}
-		}
-
-		// Priority 4: Fall back to API key requirement
-		apiKey := param(r, "k")
-
-		if apiKey == "" {
-			SendEnvelopeStatus(w, r, http.StatusBadRequest, "authentication required: provide aimsid or k parameter", m.Logger)
-			return
-		}
-
-		key, r := m.resolveAPIKeyCached(r, apiKey)
-		ctx = r.Context()
-		if key == nil {
-			m.Logger.DebugContext(ctx, "invalid API key attempted", "key", apiKey[:min(8, len(apiKey))]+"...")
-			SendEnvelopeStatus(w, r, http.StatusForbidden, "invalid API key", m.Logger)
-			return
-		}
-
-		// Check rate limit
-		rateLimitInfo := m.RateLimiter.CheckRateLimit(key.DevID, key.RateLimit)
-
-		// Always add rate limit headers
-		w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", rateLimitInfo.Limit))
-		w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", rateLimitInfo.Remaining))
-		w.Header().Set("X-RateLimit-Reset", fmt.Sprintf("%d", rateLimitInfo.Reset))
-
-		if !rateLimitInfo.Allowed {
-			m.Logger.WarnContext(ctx, "rate limit exceeded", "dev_id", key.DevID, "limit", key.RateLimit)
-			// Add Retry-After header
-			retryAfter := rateLimitInfo.Reset - time.Now().Unix()
-			if retryAfter < 1 {
-				retryAfter = 1
-			}
-			w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
-			SendEnvelopeStatus(w, r, http.StatusTooManyRequests, "rate limit exceeded", m.Logger)
-			return
-		}
-
-		// Add API key info to context for use in handlers
-		ctx = context.WithValue(ctx, ContextKeyAPIKey, key)
-
-		// Log the API request
-		m.Logger.InfoContext(ctx, "API request authenticated via key",
-			"dev_id", key.DevID,
-			"app_name", key.AppName,
-			"method", r.Method,
-			"path", r.URL.Path,
-		)
-
-		// Pass to next handler with enriched context
-		next.ServeHTTP(w, r.WithContext(ctx))
-	})
-}
-
-// resolvedAPIKey memoizes one API key lookup for the lifetime of a request.
-// A nil key is a cached result too: it records that devKey is unknown or
-// inactive, which is what lets the auth layer skip a repeat lookup.
-type resolvedAPIKey struct {
-	devKey string
-	key    *state.WebAPIKey
-}
-
-// resolveAPIKeyCached resolves devKey, reusing the result of an earlier lookup on
-// the same request. It returns the key (nil when devKey is empty, unknown, or
-// inactive) along with a request carrying the memoized result, which callers must
-// pass down the chain for the caching to take effect.
-func (m *AuthMiddleware) resolveAPIKeyCached(r *http.Request, devKey string) (*state.WebAPIKey, *http.Request) {
-	if devKey == "" {
-		return nil, r
-	}
-	if cached, ok := r.Context().Value(contextKeyResolvedAPIKey).(*resolvedAPIKey); ok && cached.devKey == devKey {
-		return cached.key, r
-	}
-	key := m.resolveAPIKey(r.Context(), devKey)
-	ctx := context.WithValue(r.Context(), contextKeyResolvedAPIKey, &resolvedAPIKey{devKey: devKey, key: key})
-	return key, r.WithContext(ctx)
-}
-
-func (m *AuthMiddleware) resolveAPIKey(ctx context.Context, devKey string) *state.WebAPIKey {
-	if devKey == "" {
-		return nil
-	}
-	key, err := m.Validator.GetAPIKeyByDevKey(ctx, devKey)
-	if err != nil || key == nil || !key.IsActive {
-		return nil
-	}
-	return key
-}
-
-// minRetryAfter floors the Retry-After hint sent with a rate-limited response.
-// The computed wait can round down to nothing when a class is barely over its
-// limit, and a hint of zero invites an immediate retry.
-const minRetryAfter = 1 * time.Second
-
 // SessionHandlerFunc is the session-aware handler shape that
 // AuthMiddleware.RequireSession invokes once it has resolved an aimsid.
 type SessionHandlerFunc = func(http.ResponseWriter, *http.Request, *Session)
@@ -543,6 +176,11 @@ func (l *RateLimitMiddleware) OSCAR(foodGroup uint16, subGroup uint16) func(Sess
 	}
 }
 
+// minRetryAfter floors the Retry-After hint sent with a rate-limited response.
+// The computed wait can round down to nothing when a class is barely over its
+// limit, and a hint of zero invites an immediate retry.
+const minRetryAfter = 1 * time.Second
+
 // retryAfterFor returns how long the client must wait for its next request on
 // this class to clear the limit.
 //

+ 80 - 135
server/webapi/middleware_test.go

@@ -19,68 +19,50 @@ import (
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
-// stubValidator records how many times a key was looked up so the tests can
-// assert that CORSMiddleware and the auth layer share a single lookup.
-type stubValidator struct {
-	key    *state.WebAPIKey
-	lookup int
-}
-
-func (s *stubValidator) GetAPIKeyByDevKey(_ context.Context, devKey string) (*state.WebAPIKey, error) {
-	s.lookup++
-	if s.key == nil || s.key.DevKey != devKey {
-		return nil, nil
-	}
-	return s.key, nil
-}
-
-func newTestMiddleware(v APIKeyValidator) *AuthMiddleware {
-	return NewAuthMiddleware(v, slog.New(slog.NewTextHandler(io.Discard, nil)))
+func newTestMiddleware() *AuthMiddleware {
+	return NewAuthMiddleware(slog.New(slog.NewTextHandler(io.Discard, nil)))
 }
 
 // The Web AIM client permanently downgrades to JSONP when a cross-origin
 // response arrives without Access-Control-Allow-Origin, so every response the
-// auth layer rejects must still carry CORS headers. That only holds while
-// CORSMiddleware wraps the auth middleware.
-func TestCORSMiddleware_HeadersOnAuthRejection(t *testing.T) {
-	// The auth layer reports failures in the response envelope rather than the
+// session layer rejects must still carry CORS headers. That only holds while
+// CORSMiddleware wraps the session middleware.
+func TestCORSMiddleware_HeadersOnSessionRejection(t *testing.T) {
+	// The session layer reports failures in the response envelope rather than the
 	// HTTP status, so the envelope's statusCode is what identifies a rejection.
 	tests := []struct {
 		name         string
 		query        string
-		validator    *stubValidator
 		wantEnvelope string
 	}{
 		{
-			name:         "missing credentials",
+			name:         "missing aimsid",
 			query:        "?f=json",
-			validator:    &stubValidator{},
 			wantEnvelope: `"statusCode":400`,
 		},
 		{
-			name:         "unknown api key",
-			query:        "?k=nosuchkey",
-			validator:    &stubValidator{},
-			wantEnvelope: `"statusCode":403`,
+			name:         "unknown aimsid",
+			query:        "?f=json&aimsid=nosuchsession",
+			wantEnvelope: `"statusCode":401`,
 		},
 	}
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			m := newTestMiddleware(tt.validator)
+			m := newTestMiddleware()
 			var reachedHandler bool
-			h := m.CORSMiddleware(m.AuthenticateFlexible(
-				http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+			h := m.CORSMiddleware(m.RequireSession(&stubSessionResolver{},
+				func(w http.ResponseWriter, _ *http.Request, _ *Session) {
 					reachedHandler = true
 					w.WriteHeader(http.StatusOK)
-				})))
+				}))
 
 			r := httptest.NewRequest(http.MethodGet, "/im/sendIM"+tt.query, nil)
 			r.Header.Set("Origin", "http://localhost:8000")
 			w := httptest.NewRecorder()
 			h.ServeHTTP(w, r)
 
-			assert.False(t, reachedHandler, "auth layer should have rejected the request")
+			assert.False(t, reachedHandler, "session layer should have rejected the request")
 			assert.Contains(t, w.Body.String(), tt.wantEnvelope)
 			assert.Equal(t, "http://localhost:8000", w.Header().Get("Access-Control-Allow-Origin"))
 			assert.Equal(t, "Origin", w.Header().Get("Vary"))
@@ -92,7 +74,7 @@ func TestCORSMiddleware_HeadersOnAuthRejection(t *testing.T) {
 // /metrics/sendIM) must reach the client as a 404 rather than as a blocked
 // response.
 func TestCORSMiddleware_HeadersOn404(t *testing.T) {
-	m := newTestMiddleware(&stubValidator{})
+	m := newTestMiddleware()
 	h := m.CORSMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
 		w.WriteHeader(http.StatusNotFound)
 	}))
@@ -106,8 +88,40 @@ func TestCORSMiddleware_HeadersOn404(t *testing.T) {
 	assert.Equal(t, "http://localhost:8000", w.Header().Get("Access-Control-Allow-Origin"))
 }
 
+// Every origin is allowed now that per-key allowlists are gone, so an origin the
+// server has never heard of still gets a usable response rather than one the
+// browser blocks.
+func TestCORSMiddleware_AllowsEveryOrigin(t *testing.T) {
+	m := newTestMiddleware()
+	h := m.CORSMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+
+	t.Run("unknown origin is echoed back", func(t *testing.T) {
+		r := httptest.NewRequest(http.MethodGet, "/im/sendIM", nil)
+		r.Header.Set("Origin", "http://never.seen.example")
+		w := httptest.NewRecorder()
+		h.ServeHTTP(w, r)
+
+		// Echoing the origin rather than "*" is what makes the response usable
+		// with credentials.
+		assert.Equal(t, "http://never.seen.example", w.Header().Get("Access-Control-Allow-Origin"))
+		assert.Equal(t, "true", w.Header().Get("Access-Control-Allow-Credentials"))
+		assert.Equal(t, "Origin", w.Header().Get("Vary"))
+	})
+
+	// A same-origin request sends no Origin at all; there is nothing to echo.
+	t.Run("no origin header falls back to a wildcard", func(t *testing.T) {
+		r := httptest.NewRequest(http.MethodGet, "/im/sendIM", nil)
+		w := httptest.NewRecorder()
+		h.ServeHTTP(w, r)
+
+		assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin"))
+	})
+}
+
 func TestCORSMiddleware_PreflightShortCircuits(t *testing.T) {
-	m := newTestMiddleware(&stubValidator{})
+	m := newTestMiddleware()
 	var reachedNext bool
 	h := m.CORSMiddleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
 		reachedNext = true
@@ -124,80 +138,17 @@ func TestCORSMiddleware_PreflightShortCircuits(t *testing.T) {
 	assert.Contains(t, w.Header().Get("Access-Control-Allow-Methods"), "POST")
 }
 
-// CORSMiddleware runs ahead of authentication and so resolves the API key
-// itself; the auth layer behind it must reuse that lookup rather than repeat it.
-func TestCORSMiddleware_SharesKeyLookupWithAuth(t *testing.T) {
-	v := &stubValidator{key: &state.WebAPIKey{
-		DevID:     "dev1",
-		DevKey:    "goodkey",
-		IsActive:  true,
-		RateLimit: 100,
-	}}
-	m := newTestMiddleware(v)
-
-	var served bool
-	h := m.CORSMiddleware(m.Authenticate(
-		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-			served = true
-			key, ok := r.Context().Value(ContextKeyAPIKey).(*state.WebAPIKey)
-			require.True(t, ok, "handler should see the validated key")
-			assert.Equal(t, "dev1", key.DevID)
-			w.WriteHeader(http.StatusOK)
-		})))
-
-	r := httptest.NewRequest(http.MethodGet, "/aim/startOSCARSession?k=goodkey", nil)
-	r.Header.Set("Origin", "http://localhost:8000")
-	w := httptest.NewRecorder()
-	h.ServeHTTP(w, r)
-
-	assert.True(t, served)
-	assert.Equal(t, http.StatusOK, w.Code)
-	assert.Equal(t, 1, v.lookup, "key should be resolved once per request, not once per middleware")
-}
-
-// Per-key origin allowlists must keep working now that the origin decision is
-// made before authentication.
-func TestCORSMiddleware_PerKeyOriginAllowlist(t *testing.T) {
-	v := &stubValidator{key: &state.WebAPIKey{
-		DevID:          "dev1",
-		DevKey:         "goodkey",
-		IsActive:       true,
-		RateLimit:      100,
-		AllowedOrigins: []string{"http://allowed.example"},
-	}}
-	m := newTestMiddleware(v)
-	h := m.CORSMiddleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
-		w.WriteHeader(http.StatusOK)
-	}))
-
-	t.Run("allowed origin", func(t *testing.T) {
-		r := httptest.NewRequest(http.MethodGet, "/im/sendIM?k=goodkey", nil)
-		r.Header.Set("Origin", "http://allowed.example")
-		w := httptest.NewRecorder()
-		h.ServeHTTP(w, r)
-		assert.Equal(t, "http://allowed.example", w.Header().Get("Access-Control-Allow-Origin"))
-	})
-
-	t.Run("disallowed origin", func(t *testing.T) {
-		r := httptest.NewRequest(http.MethodGet, "/im/sendIM?k=goodkey", nil)
-		r.Header.Set("Origin", "http://evil.example")
-		w := httptest.NewRecorder()
-		h.ServeHTTP(w, r)
-		assert.Empty(t, w.Header().Get("Access-Control-Allow-Origin"))
-	})
-}
-
-// A POST body must survive the middleware chain: CORSMiddleware reads the API
-// key from the query string only, so it never parses the form.
+// A POST body must survive the middleware chain: CORSMiddleware reads nothing
+// from the request, so it never parses the form.
 func TestCORSMiddleware_DoesNotConsumePOSTBody(t *testing.T) {
-	m := newTestMiddleware(&stubValidator{})
-	h := m.CORSMiddleware(m.AuthenticateFlexible(
-		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+	m := newTestMiddleware()
+	h := m.CORSMiddleware(m.RequireSession(okSessionResolver{},
+		func(w http.ResponseWriter, r *http.Request, _ *Session) {
 			body, err := io.ReadAll(r.Body)
 			require.NoError(t, err)
 			assert.Equal(t, "message=hello", string(body))
 			w.WriteHeader(http.StatusOK)
-		})))
+		}))
 
 	r := httptest.NewRequest(http.MethodPost, "/im/sendIM?aimsid=abc", strings.NewReader("message=hello"))
 	r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -208,10 +159,10 @@ func TestCORSMiddleware_DoesNotConsumePOSTBody(t *testing.T) {
 	assert.Equal(t, http.StatusOK, w.Code)
 }
 
-// Some clients POST the whole parameter set in the body, so an auth layer reading
-// only the query string sees no credential. Reading the body here does not cost the
-// handler its parameters: ParseForm caches onto the request.
-func TestAuthenticateFlexible_ReadsCredentialsFromPOSTBody(t *testing.T) {
+// Some clients POST the whole parameter set in the body, so a session layer
+// reading only the query string sees no aimsid. Reading the body here does not
+// cost the handler its parameters: ParseForm caches onto the request.
+func TestRequireSession_ReadsAimsidFromPOSTBody(t *testing.T) {
 	tests := []struct {
 		name        string
 		body        string
@@ -231,14 +182,14 @@ func TestAuthenticateFlexible_ReadsCredentialsFromPOSTBody(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			m := newTestMiddleware(&stubValidator{})
+			m := newTestMiddleware()
 			reached := false
-			h := m.CORSMiddleware(m.AuthenticateFlexible(
-				http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			h := m.CORSMiddleware(m.RequireSession(okSessionResolver{},
+				func(w http.ResponseWriter, r *http.Request, _ *Session) {
 					reached = true
 					assert.Equal(t, "hello", param(r, "message"))
 					w.WriteHeader(http.StatusOK)
-				})))
+				}))
 
 			r := httptest.NewRequest(http.MethodPost, "/im/sendIM", strings.NewReader(tt.body))
 			if tt.contentType != "" {
@@ -253,10 +204,11 @@ func TestAuthenticateFlexible_ReadsCredentialsFromPOSTBody(t *testing.T) {
 	}
 }
 
-// The auth layer's own rejections must be JSONP-wrapped too, otherwise a client
-// already in JSONP mode gets a script-tag syntax error instead of the reason.
+// The session layer's own rejections must be JSONP-wrapped too, otherwise a
+// client already in JSONP mode gets a script-tag syntax error instead of the
+// reason.
 func TestAuthErrorsHonorJSONP(t *testing.T) {
-	m := newTestMiddleware(&stubValidator{})
+	m := newTestMiddleware()
 
 	t.Run("session error", func(t *testing.T) {
 		h := m.RequireSession(&stubSessionResolver{}, func(http.ResponseWriter, *http.Request, *Session) {
@@ -276,22 +228,6 @@ func TestAuthErrorsHonorJSONP(t *testing.T) {
 		assert.Equal(t, http.StatusOK, w.Code)
 	})
 
-	t.Run("missing credentials", func(t *testing.T) {
-		h := m.AuthenticateFlexible(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
-			t.Fatal("handler should not run")
-		}))
-
-		r := httptest.NewRequest(http.MethodGet, "/im/sendIM?c=cb&r=9", nil)
-		w := httptest.NewRecorder()
-		h.ServeHTTP(w, r)
-
-		body := w.Body.String()
-		assert.True(t, strings.HasPrefix(body, "cb("), "got %s", body)
-		assert.Contains(t, body, `"statusCode":400`)
-		assert.Contains(t, body, `"requestId":"9"`)
-		assert.Equal(t, http.StatusOK, w.Code)
-	})
-
 	t.Run("without a callback the session error keeps its HTTP status", func(t *testing.T) {
 		h := m.RequireSession(&stubSessionResolver{}, func(http.ResponseWriter, *http.Request, *Session) {
 			t.Fatal("handler should not run")
@@ -307,12 +243,12 @@ func TestAuthErrorsHonorJSONP(t *testing.T) {
 }
 
 // An XML client cannot parse a JSON error, so it reports an unreadable response
-// instead of the reason the auth layer rejected it.
+// instead of the reason the session layer rejected it.
 func TestAuthErrorsHonorXML(t *testing.T) {
-	m := newTestMiddleware(&stubValidator{})
-	h := m.Authenticate(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+	m := newTestMiddleware()
+	h := m.RequireSession(&stubSessionResolver{}, func(http.ResponseWriter, *http.Request, *Session) {
 		t.Fatal("handler should not run")
-	}))
+	})
 
 	t.Run("format in the query string", func(t *testing.T) {
 		r := httptest.NewRequest(http.MethodGet, "/aim/startOSCARSession?f=xml", nil)
@@ -348,6 +284,15 @@ func TestAuthErrorsHonorXML(t *testing.T) {
 	})
 }
 
+// okSessionResolver always resolves, so RequireSession reaches the handler.
+type okSessionResolver struct{}
+
+func (okSessionResolver) GetSession(context.Context, string) (*Session, error) {
+	return &Session{}, nil
+}
+
+func (okSessionResolver) TouchSession(context.Context, string) error { return nil }
+
 // stubSessionResolver never resolves a session, so RequireSession always rejects.
 type stubSessionResolver struct{}
 

+ 14 - 27
server/webapi/server.go

@@ -16,10 +16,10 @@ import (
 	"github.com/mk6i/open-oscar-server/wire"
 )
 
-func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyValidator APIKeyValidator, sessionManager *SessionManager) *Server {
+func NewServer(listeners []string, logger *slog.Logger, handler Handler, sessionManager *SessionManager) *Server {
 	servers := make([]*http.Server, 0, len(listeners))
 
-	authMiddleware := NewAuthMiddleware(apiKeyValidator, logger)
+	authMiddleware := NewAuthMiddleware(logger)
 	rateLimiter := NewRateLimitMiddleware(handler.SNACRateLimits, logger)
 
 	authHandler := &AuthHandler{
@@ -80,30 +80,27 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 	for _, l := range listeners {
 		mux := http.NewServeMux()
 
-		// CORSMiddleware wraps the auth layer rather than the other way around, so
-		// that responses the auth layer rejects (400 missing key, 403 bad key, 429
-		// rate limited) still carry Access-Control-Allow-Origin. A browser blocks a
-		// cross-origin response without that header, and the Web AIM client reads
-		// the resulting status-0 empty response as a CORS failure and permanently
-		// switches its whole request pipeline to JSONP.
+		// CORSMiddleware wraps the session layer rather than the other way around,
+		// so that responses the session layer rejects (400 missing aimsid, 401
+		// expired session) still carry Access-Control-Allow-Origin. A browser
+		// blocks a cross-origin response without that header, and the Web AIM
+		// client reads the resulting status-0 empty response as a CORS failure and
+		// permanently switches its whole request pipeline to JSONP.
 		//
 		// oscarRoute charges the request against the rate class for (foodGroup,
 		// subGroup) before the handler runs; sessionRoute and stubRoute reach no
 		// food group and so are not rate limited here.
 		oscarRoute := func(foodGroup uint16, subGroup uint16, h SessionHandlerFunc) http.Handler {
 			return authMiddleware.CORSMiddleware(
-				authMiddleware.AuthenticateFlexible(
-					authMiddleware.RequireSession(sessionManager,
-						rateLimiter.OSCAR(foodGroup, subGroup)(h))))
+				authMiddleware.RequireSession(sessionManager,
+					rateLimiter.OSCAR(foodGroup, subGroup)(h)))
 		}
 		sessionRoute := func(h SessionHandlerFunc) http.Handler {
 			return authMiddleware.CORSMiddleware(
-				authMiddleware.AuthenticateFlexible(
-					authMiddleware.RequireSession(sessionManager, h)))
+				authMiddleware.RequireSession(sessionManager, h))
 		}
 		stubRoute := func(h http.HandlerFunc) http.Handler {
-			return authMiddleware.CORSMiddleware(
-				authMiddleware.AuthenticateFlexible(h))
+			return authMiddleware.CORSMiddleware(h)
 		}
 
 		mux.Handle("GET /{$}", http.HandlerFunc(handler.GetHelloWorldHandler))
@@ -173,13 +170,10 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		mux.Handle("GET /_cqr/login/login.psp", loginPSP)
 		mux.Handle("POST /_cqr/login/login.psp", loginPSP)
 
-		startSession := authMiddleware.CORSMiddleware(
-			authMiddleware.AuthenticateFlexible(
-				http.HandlerFunc(aimHandler.StartSession)))
+		startSession := authMiddleware.CORSMiddleware(http.HandlerFunc(aimHandler.StartSession))
 		mux.Handle("GET /aim/startSession", startSession)
 		mux.Handle("POST /aim/startSession", startSession)
 
-		// End session - uses aimsid for auth, no k required
 		mux.Handle("GET /aim/endSession", sessionRoute(aimHandler.EndSession))
 
 		mux.Handle("GET /aim/fetchEvents", sessionRoute(aimHandler.FetchEvents))
@@ -194,9 +188,7 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		// OSCAR Bridge endpoint. Hands off to a BOS session rather than reaching
 		// a food group, so there is no OSCAR budget to charge.
 		mux.Handle("GET /aim/startOSCARSession",
-			authMiddleware.CORSMiddleware(
-				authMiddleware.Authenticate(
-					http.HandlerFunc(aimHandler.StartOSCARSession))))
+			authMiddleware.CORSMiddleware(http.HandlerFunc(aimHandler.StartOSCARSession)))
 
 		conversationStub := &ConversationStubHandler{
 			Logger: logger,
@@ -207,7 +199,6 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		mux.Handle("GET /imlog/fetchStoredIMs", sessionRoute(conversationStub.FetchStoredIMs))
 
 		// Presence and buddy list
-		// GetPresence supports aimsid-based auth, so we use flexible auth
 		mux.Handle("GET /presence/get", oscarRoute(wire.Feedbag, wire.FeedbagQuery, presenceHandler.GetPresence))
 
 		mux.Handle("GET /buddylist/addBuddy", oscarRoute(wire.Feedbag, wire.FeedbagInsertItem, buddyListHandler.AddBuddy))
@@ -219,7 +210,6 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		mux.Handle("GET /buddylist/setBuddyAttribute", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, buddyListHandler.SetBuddyAttribute))
 		mux.Handle("GET /buddylist/setGroupAttribute", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, buddyListHandler.SetGroupAttribute))
 
-		// sendIM supports aimsid-based auth, so we use flexible auth.
 		// The Web AIM client POSTs the message body (non-IE browsers); IE uses GET.
 		sendIMHandler := oscarRoute(wire.ICBM, wire.ICBMChannelMsgToHost, messagingHandler.SendIM)
 		mux.Handle("GET /im/sendIM", sendIMHandler)
@@ -227,10 +217,8 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 
 		mux.Handle("GET /im/setTyping", oscarRoute(wire.ICBM, wire.ICBMClientEvent, messagingHandler.SetTyping))
 
-		// SetState only requires aimsid, no k parameter needed
 		mux.Handle("GET /presence/setState", oscarRoute(wire.OService, wire.OServiceSetUserInfoFields, presenceHandler.SetState))
 
-		// These presence endpoints support aimsid-based auth where k is not required
 		mux.Handle("GET /presence/setStatus", oscarRoute(wire.OService, wire.OServiceSetUserInfoFields, presenceHandler.SetStatus))
 		mux.Handle("GET /presence/setProfile", oscarRoute(wire.Locate, wire.LocateSetInfo, presenceHandler.SetProfile))
 		mux.Handle("GET /presence/getProfile", oscarRoute(wire.Locate, wire.LocateUserInfoQuery, presenceHandler.GetProfile))
@@ -245,7 +233,6 @@ func NewServer(listeners []string, logger *slog.Logger, handler Handler, apiKeyV
 		mux.Handle("GET /memberDir/update", memberDirUpdate)
 		mux.Handle("POST /memberDir/update", memberDirUpdate)
 
-		// These endpoints support aimsid-based auth, so we use a flexible auth approach
 		mux.Handle("GET /preference/set", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, preferenceHandler.SetPreferences))
 		mux.Handle("GET /preference/get", oscarRoute(wire.Feedbag, wire.FeedbagQuery, preferenceHandler.GetPreferences))
 		mux.Handle("GET /preference/setPermitDeny", oscarRoute(wire.Feedbag, wire.FeedbagUpdateItem, preferenceHandler.SetPermitDeny))

+ 1 - 3
server/webapi/session.go

@@ -63,7 +63,6 @@ type Session struct {
 	BaseURL             string                                         // Web API base URL advertised to the web client, used to build absolute asset URLs
 	Events              []string                                       // Subscribed event types
 	EventQueue          *EventQueue                                    // Per-session event queue
-	DevID               string                                         // Developer ID that created this session
 	ClientName          string                                         // Client application name
 	ClientVersion       string                                         // Client application version
 	CreatedAt           time.Time                                      // SessionInstance creation time
@@ -693,7 +692,7 @@ func NewSessionManager() *SessionManager {
 // MyInfoRefresher, ...) and then call StartListeningToOSCARSession. Wiring them
 // after the listener starts would race the goroutine, which reads them as it
 // converts SNACs into events.
-func (m *SessionManager) CreateSession(screenName state.DisplayScreenName, devID string, events []string, oscarSession *state.SessionInstance, baseURL string, logger *slog.Logger) (*Session, error) {
+func (m *SessionManager) CreateSession(screenName state.DisplayScreenName, events []string, oscarSession *state.SessionInstance, baseURL string, logger *slog.Logger) (*Session, error) {
 	m.mu.Lock()
 	defer m.mu.Unlock()
 
@@ -720,7 +719,6 @@ func (m *SessionManager) CreateSession(screenName state.DisplayScreenName, devID
 		BaseURL:         baseURL,
 		Events:          events,
 		EventQueue:      NewEventQueue(1000), // Max 1000 events per session
-		DevID:           devID,
 		CreatedAt:       now,
 		LastAccessed:    now,
 		ExpiresAt:       now.Add(webAPISessionTTL),

+ 10 - 10
server/webapi/session_test.go

@@ -70,7 +70,7 @@ func TestSessionManager_CreateAfterShutdown(t *testing.T) {
 
 	_ = mgr.Shutdown(context.Background())
 
-	sess, err := mgr.CreateSession(state.DisplayScreenName("testuser"), "dev", []string{"presence"}, nil, "", nil)
+	sess, err := mgr.CreateSession(state.DisplayScreenName("testuser"), []string{"presence"}, nil, "", nil)
 	assert.Nil(t, sess)
 	assert.ErrorIs(t, err, ErrWebAPISessionManagerClosed)
 }
@@ -149,7 +149,7 @@ func TestSessionManager_GetSession_rejectsAfterRateLimitDisconnect(t *testing.T)
 	inst := state.NewSession().AddInstance()
 	inst.Session().SetRateClasses(time.Now(), wire.NewRateLimitClasses(classes))
 
-	sess, err := mgr.CreateSession(state.DisplayScreenName("advbot"), "dev", []string{"presence"}, inst, "", slog.Default())
+	sess, err := mgr.CreateSession(state.DisplayScreenName("advbot"), []string{"presence"}, inst, "", slog.Default())
 	require.NoError(t, err)
 
 	// Healthy session resolves.
@@ -189,9 +189,9 @@ func TestSessionManager_ShutdownDrainsAndClosesSessions(t *testing.T) {
 	inst1 := state.NewSession().AddInstance()
 	inst2 := state.NewSession().AddInstance()
 
-	s1, err := mgr.CreateSession(state.DisplayScreenName("alice"), "dev", []string{"presence"}, inst1, "", slog.Default())
+	s1, err := mgr.CreateSession(state.DisplayScreenName("alice"), []string{"presence"}, inst1, "", slog.Default())
 	assert.NoError(t, err)
-	s2, err := mgr.CreateSession(state.DisplayScreenName("bob"), "dev", []string{"presence"}, inst2, "", slog.Default())
+	s2, err := mgr.CreateSession(state.DisplayScreenName("bob"), []string{"presence"}, inst2, "", slog.Default())
 	assert.NoError(t, err)
 
 	assert.NoError(t, mgr.Shutdown(context.Background()))
@@ -222,9 +222,9 @@ func TestSessionManager_ReapExpired(t *testing.T) {
 	expiredInst := state.NewSession().AddInstance()
 	liveInst := state.NewSession().AddInstance()
 
-	expired, err := mgr.CreateSession("alice", "dev", []string{"presence"}, expiredInst, "", slog.Default())
+	expired, err := mgr.CreateSession("alice", []string{"presence"}, expiredInst, "", slog.Default())
 	assert.NoError(t, err)
-	live, err := mgr.CreateSession("bob", "dev", []string{"presence"}, liveInst, "", slog.Default())
+	live, err := mgr.CreateSession("bob", []string{"presence"}, liveInst, "", slog.Default())
 	assert.NoError(t, err)
 
 	// Force alice's session into the past; bob keeps its default future expiry.
@@ -814,7 +814,7 @@ func TestSessionManager_ShutdownBoundedByContext(t *testing.T) {
 	mgr := NewSessionManager()
 
 	inst := state.NewSession().AddInstance()
-	sess, err := mgr.CreateSession("alice", "dev", []string{"presence"}, inst, "", slog.Default())
+	sess, err := mgr.CreateSession("alice", []string{"presence"}, inst, "", slog.Default())
 	assert.NoError(t, err)
 
 	// Stand in for a listener wedged somewhere that never observes cancellation.
@@ -844,7 +844,7 @@ func TestSession_CloseCancelsSessionContext(t *testing.T) {
 	mgr := NewSessionManager()
 
 	inst := state.NewSession().AddInstance()
-	sess, err := mgr.CreateSession("alice", "dev", []string{"presence"}, inst, "", slog.Default())
+	sess, err := mgr.CreateSession("alice", []string{"presence"}, inst, "", slog.Default())
 	assert.NoError(t, err)
 
 	assert.NoError(t, sess.ctx.Err(), "session context should be live before Close")
@@ -949,7 +949,7 @@ func TestSession_BootReleasesParkedFetcherWithSessionEnded(t *testing.T) {
 	mgr := NewSessionManager()
 	inst := state.NewSession().AddInstance()
 
-	sess, err := mgr.CreateSession(state.DisplayScreenName("mike"), "dev", []string{"presence"}, inst, "", slog.Default())
+	sess, err := mgr.CreateSession(state.DisplayScreenName("mike"), []string{"presence"}, inst, "", slog.Default())
 	require.NoError(t, err)
 	sess.StartListeningToOSCARSession()
 
@@ -986,7 +986,7 @@ func TestSession_SelfCloseEmitsNoSessionEndedEvent(t *testing.T) {
 	mgr := NewSessionManager()
 	inst := state.NewSession().AddInstance()
 
-	sess, err := mgr.CreateSession(state.DisplayScreenName("mike"), "dev", []string{"presence"}, inst, "", slog.Default())
+	sess, err := mgr.CreateSession(state.DisplayScreenName("mike"), []string{"presence"}, inst, "", slog.Default())
 	require.NoError(t, err)
 	sess.StartListeningToOSCARSession()
 

+ 0 - 5
server/webapi/types.go

@@ -15,11 +15,6 @@ type SessionResolver interface {
 	TouchSession(ctx context.Context, aimsid string) error
 }
 
-// APIKeyValidator validates the dev_key a client sends as its "k" parameter.
-type APIKeyValidator interface {
-	GetAPIKeyByDevKey(ctx context.Context, devKey string) (*state.WebAPIKey, error)
-}
-
 // AuthService cracks auth cookies and registers the BOS sessions they name.
 type AuthService interface {
 	CrackCookie(authCookie []byte) (state.ServerCookie, time.Time, error)

+ 16 - 0
state/migrations/0043_drop_web_api_keys.down.sql

@@ -0,0 +1,16 @@
+-- Rollback: recreate the Web API key table as 0023 defined it, less the
+-- last_used column 0042 dropped.
+
+CREATE TABLE IF NOT EXISTS web_api_keys (
+    dev_id VARCHAR(255) PRIMARY KEY,
+    dev_key VARCHAR(255) UNIQUE NOT NULL,
+    app_name VARCHAR(255) NOT NULL,
+    created_at INTEGER NOT NULL,
+    is_active BOOLEAN DEFAULT 1,
+    rate_limit INTEGER DEFAULT 60,
+    allowed_origins TEXT, -- JSON array of allowed CORS origins
+    capabilities TEXT     -- JSON array of enabled features/endpoints
+);
+
+CREATE INDEX IF NOT EXISTS idx_web_api_keys_dev_key ON web_api_keys(dev_key);
+CREATE INDEX IF NOT EXISTS idx_web_api_keys_is_active ON web_api_keys(is_active);

+ 3 - 0
state/migrations/0043_drop_web_api_keys.up.sql

@@ -0,0 +1,3 @@
+DROP INDEX IF EXISTS idx_web_api_keys_dev_key;
+DROP INDEX IF EXISTS idx_web_api_keys_is_active;
+DROP TABLE IF EXISTS web_api_keys;

+ 0 - 323
state/web_api_store.go

@@ -1,323 +0,0 @@
-package state
-
-import (
-	"context"
-	"database/sql"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"time"
-)
-
-var (
-	// ErrDupAPIKey is returned when attempting to insert a duplicate API key.
-	ErrDupAPIKey = errors.New("API key already exists")
-	// ErrNoAPIKey is returned when an API key is not found.
-	ErrNoAPIKey = errors.New("API key not found")
-)
-
-// WebAPIKey represents a Web API authentication key.
-type WebAPIKey struct {
-	DevID          string    `json:"dev_id"`
-	DevKey         string    `json:"dev_key"`
-	AppName        string    `json:"app_name"`
-	CreatedAt      time.Time `json:"created_at"`
-	IsActive       bool      `json:"is_active"`
-	RateLimit      int       `json:"rate_limit"`
-	AllowedOrigins []string  `json:"allowed_origins"`
-	Capabilities   []string  `json:"capabilities"`
-}
-
-// WebAPIKeyUpdate represents fields that can be updated for an API key.
-type WebAPIKeyUpdate struct {
-	AppName        *string   `json:"app_name,omitempty"`
-	IsActive       *bool     `json:"is_active,omitempty"`
-	RateLimit      *int      `json:"rate_limit,omitempty"`
-	AllowedOrigins *[]string `json:"allowed_origins,omitempty"`
-	Capabilities   *[]string `json:"capabilities,omitempty"`
-}
-
-// CreateAPIKey inserts a new API key into the database.
-func (f SQLiteUserStore) CreateAPIKey(ctx context.Context, key WebAPIKey) error {
-	originsJSON, err := json.Marshal(key.AllowedOrigins)
-	if err != nil {
-		return fmt.Errorf("failed to marshal allowed origins: %w", err)
-	}
-
-	capabilitiesJSON, err := json.Marshal(key.Capabilities)
-	if err != nil {
-		return fmt.Errorf("failed to marshal capabilities: %w", err)
-	}
-
-	q := `
-		INSERT INTO web_api_keys (dev_id, dev_key, app_name, created_at, is_active, rate_limit, allowed_origins, capabilities)
-		VALUES (?, ?, ?, ?, ?, ?, ?, ?)
-		ON CONFLICT (dev_id) DO NOTHING
-	`
-
-	result, err := f.db.ExecContext(ctx,
-		q,
-		key.DevID,
-		key.DevKey,
-		key.AppName,
-		key.CreatedAt.Unix(),
-		key.IsActive,
-		key.RateLimit,
-		string(originsJSON),
-		string(capabilitiesJSON),
-	)
-	if err != nil {
-		return err
-	}
-
-	rowsAffected, err := result.RowsAffected()
-	if err != nil {
-		return err
-	}
-	if rowsAffected == 0 {
-		return ErrDupAPIKey
-	}
-
-	return nil
-}
-
-// GetAPIKeyByDevKey retrieves an API key by its dev_key value.
-func (f *SQLiteUserStore) GetAPIKeyByDevKey(ctx context.Context, devKey string) (*WebAPIKey, error) {
-	q := `
-		SELECT dev_id, dev_key, app_name, created_at, is_active, rate_limit, allowed_origins, capabilities
-		FROM web_api_keys
-		WHERE dev_key = ? AND is_active = 1
-	`
-
-	var key WebAPIKey
-	var createdAt sql.NullInt64
-	var originsJSON, capabilitiesJSON string
-
-	err := f.db.QueryRowContext(ctx, q, devKey).Scan(
-		&key.DevID,
-		&key.DevKey,
-		&key.AppName,
-		&createdAt,
-		&key.IsActive,
-		&key.RateLimit,
-		&originsJSON,
-		&capabilitiesJSON,
-	)
-
-	if err == sql.ErrNoRows {
-		return nil, ErrNoAPIKey
-	}
-	if err != nil {
-		return nil, err
-	}
-
-	key.CreatedAt = time.Unix(createdAt.Int64, 0)
-
-	if err := json.Unmarshal([]byte(originsJSON), &key.AllowedOrigins); err != nil {
-		return nil, fmt.Errorf("failed to unmarshal allowed origins: %w", err)
-	}
-
-	if err := json.Unmarshal([]byte(capabilitiesJSON), &key.Capabilities); err != nil {
-		return nil, fmt.Errorf("failed to unmarshal capabilities: %w", err)
-	}
-
-	return &key, nil
-}
-
-// GetAPIKeyByDevID retrieves an API key by its dev_id value.
-func (f SQLiteUserStore) GetAPIKeyByDevID(ctx context.Context, devID string) (*WebAPIKey, error) {
-	q := `
-		SELECT dev_id, dev_key, app_name, created_at, is_active, rate_limit, allowed_origins, capabilities
-		FROM web_api_keys
-		WHERE dev_id = ?
-	`
-
-	var key WebAPIKey
-	var createdAt sql.NullInt64
-	var originsJSON, capabilitiesJSON string
-
-	err := f.db.QueryRowContext(ctx, q, devID).Scan(
-		&key.DevID,
-		&key.DevKey,
-		&key.AppName,
-		&createdAt,
-		&key.IsActive,
-		&key.RateLimit,
-		&originsJSON,
-		&capabilitiesJSON,
-	)
-
-	if err == sql.ErrNoRows {
-		return nil, ErrNoAPIKey
-	}
-	if err != nil {
-		return nil, err
-	}
-
-	key.CreatedAt = time.Unix(createdAt.Int64, 0)
-
-	if err := json.Unmarshal([]byte(originsJSON), &key.AllowedOrigins); err != nil {
-		return nil, fmt.Errorf("failed to unmarshal allowed origins: %w", err)
-	}
-
-	if err := json.Unmarshal([]byte(capabilitiesJSON), &key.Capabilities); err != nil {
-		return nil, fmt.Errorf("failed to unmarshal capabilities: %w", err)
-	}
-
-	return &key, nil
-}
-
-// ListAPIKeys retrieves all API keys from the database.
-func (f SQLiteUserStore) ListAPIKeys(ctx context.Context) ([]WebAPIKey, error) {
-	q := `
-		SELECT dev_id, dev_key, app_name, created_at, is_active, rate_limit, allowed_origins, capabilities
-		FROM web_api_keys
-		ORDER BY created_at DESC
-	`
-
-	rows, err := f.db.QueryContext(ctx, q)
-	if err != nil {
-		return nil, err
-	}
-	defer rows.Close()
-
-	var keys []WebAPIKey
-	for rows.Next() {
-		var key WebAPIKey
-		var createdAt sql.NullInt64
-		var originsJSON, capabilitiesJSON string
-
-		err := rows.Scan(
-			&key.DevID,
-			&key.DevKey,
-			&key.AppName,
-			&createdAt,
-			&key.IsActive,
-			&key.RateLimit,
-			&originsJSON,
-			&capabilitiesJSON,
-		)
-		if err != nil {
-			return nil, err
-		}
-
-		key.CreatedAt = time.Unix(createdAt.Int64, 0)
-
-		if err := json.Unmarshal([]byte(originsJSON), &key.AllowedOrigins); err != nil {
-			return nil, fmt.Errorf("failed to unmarshal allowed origins: %w", err)
-		}
-
-		if err := json.Unmarshal([]byte(capabilitiesJSON), &key.Capabilities); err != nil {
-			return nil, fmt.Errorf("failed to unmarshal capabilities: %w", err)
-		}
-
-		keys = append(keys, key)
-	}
-
-	if err = rows.Err(); err != nil {
-		return nil, err
-	}
-
-	return keys, nil
-}
-
-// UpdateAPIKey updates an existing API key's fields.
-func (f SQLiteUserStore) UpdateAPIKey(ctx context.Context, devID string, updates WebAPIKeyUpdate) error {
-	// Build dynamic UPDATE query based on provided fields
-	var setClauses []string
-	var args []interface{}
-
-	if updates.AppName != nil {
-		setClauses = append(setClauses, "app_name = ?")
-		args = append(args, *updates.AppName)
-	}
-
-	if updates.IsActive != nil {
-		setClauses = append(setClauses, "is_active = ?")
-		args = append(args, *updates.IsActive)
-	}
-
-	if updates.RateLimit != nil {
-		setClauses = append(setClauses, "rate_limit = ?")
-		args = append(args, *updates.RateLimit)
-	}
-
-	if updates.AllowedOrigins != nil {
-		originsJSON, err := json.Marshal(*updates.AllowedOrigins)
-		if err != nil {
-			return fmt.Errorf("failed to marshal allowed origins: %w", err)
-		}
-		setClauses = append(setClauses, "allowed_origins = ?")
-		args = append(args, string(originsJSON))
-	}
-
-	if updates.Capabilities != nil {
-		capabilitiesJSON, err := json.Marshal(*updates.Capabilities)
-		if err != nil {
-			return fmt.Errorf("failed to marshal capabilities: %w", err)
-		}
-		setClauses = append(setClauses, "capabilities = ?")
-		args = append(args, string(capabilitiesJSON))
-	}
-
-	if len(setClauses) == 0 {
-		return nil // No updates to perform
-	}
-
-	// Add WHERE clause argument
-	args = append(args, devID)
-
-	q := fmt.Sprintf(`
-		UPDATE web_api_keys
-		SET %s
-		WHERE dev_id = ?
-	`, joinStrings(setClauses, ", "))
-
-	result, err := f.db.ExecContext(ctx, q, args...)
-	if err != nil {
-		return err
-	}
-
-	rowsAffected, err := result.RowsAffected()
-	if err != nil {
-		return err
-	}
-	if rowsAffected == 0 {
-		return ErrNoAPIKey
-	}
-
-	return nil
-}
-
-// DeleteAPIKey removes an API key from the database.
-func (f SQLiteUserStore) DeleteAPIKey(ctx context.Context, devID string) error {
-	q := `
-		DELETE FROM web_api_keys WHERE dev_id = ?
-	`
-	result, err := f.db.ExecContext(ctx, q, devID)
-	if err != nil {
-		return err
-	}
-
-	rowsAffected, err := result.RowsAffected()
-	if err != nil {
-		return err
-	}
-	if rowsAffected == 0 {
-		return ErrNoAPIKey
-	}
-
-	return nil
-}
-
-// joinStrings is a helper function to join strings with a separator.
-func joinStrings(strs []string, sep string) string {
-	if len(strs) == 0 {
-		return ""
-	}
-	result := strs[0]
-	for i := 1; i < len(strs); i++ {
-		result += sep + strs[i]
-	}
-	return result
-}