SPACEPORT DOCS

Sessions & Client Management

Spaceport provides a sophisticated session and client management system that seamlessly handles both authenticated and unauthenticated users. At the heart of this system is the Client object, which offers session-scoped data persistence using the dock—a Cargo object configured for each client's session. Authenticated users also gain access to a ClientDocument for storing user-specific data in the database, with additional features like password management and permissions.

Client vs. User: In Spaceport, a Client is an object that represents a session or collection of sessions tied to an authenticated user. The term user typically refers to the person using your application. When not discussing the technical Client object, these terms may be used interchangeably.

# Core Concepts

Every visitor to your Spaceport application automatically receives a spaceport-uuid cookie. This UUID serves as the primary identifier for the user's session and is created automatically on the first request if it doesn't exist. It persists for subsequent requests, provided the user doesn't clear their cookies.

Key Characteristics:

Cookie Properties:

Property Value
Name spaceport-uuid
Value Random UUID (e.g., 550e8400-e29b-41d4-a716-446655440000)
Domain Your application's domain
Path / (root)
Max Age 60 days (configurable)
Secure true in production, false in debug mode
HttpOnly true

Accessing the Cookie:

// Access the cookie in your code
def sessionId = r.context.cookies.'spaceport-uuid'

## Client Objects

A Client represents a user connected to your Spaceport application. Clients can be authenticated (logged in) or unauthenticated (anonymous visitors). Each client is tied to one or more spaceport-uuid cookies, allowing multiple concurrent sessions across different browsers or devices.

Key Properties:

Note: Authentication differs from authorization. Clients can be authenticated but still lack permissions for certain actions, which are typically managed through the ClientDocument.

Accessing the Client Object:

Within the scope of an HTTP request or WebSocket connection, use r.context.client:

@Alert('on /dashboard GET')
static void _dashboard(HttpResult r) {
    def client = r.context.client
    
    if (client?.authenticated) {
        def document = client.document
        println "Welcome back, ${document.name}!"
    } else {
        println "Welcome, guest!"
    }
}

In Launchpad templates, the client variable is automatically available:

<h1>Hello, ${ client.userID }!</h1>

<label>Status:
    <div>${ client.authenticated ? 'AUTHENTICATED' : 'GUEST' }</div>
</label>

## The Dock: Session-Scoped Storage

The dock is a session-scoped Cargo object that provides persistent storage tied to a specific spaceport-uuid. It's the primary mechanism for storing session data like shopping carts, user preferences, form drafts, and other temporary state specific to a user's session. Think of it as a "workspace" for each device or browser the user uses to interact with your application.

If you're unfamiliar with Cargo, see the Cargo documentation for details on its API and capabilities. Cargo offers a universal data structure, deeply integrated with Spaceport and built for reactivity and web application-specific needs.

Persistence Behavior:

The dock's persistence depends on whether the client is authenticated:

Accessing the Dock:

// In Launchpad templates
<% 
    dock.cartItems.get()
    dock.preferences.put('theme', 'dark')
%>

// Within the scope of an HTTP alert
@Alert('on /profile GET')
static void _profile(HttpResult r) {
    r.context.dock.lastVisited = System.currentTimeMillis()
}

// In source modules with a client and spaceport-uuid
def dock = client.getDock(r.context.cookies.'spaceport-uuid')
dock.recentSearches.addToSet('rockets')

In Launchpad templates, the dock variable is automatically provided and scoped to the current user's session. In source modules within the context of an HTTP request or WebSocket connection, access the dock via r.context.dock with an implicit tie to the current spaceport-uuid cookie.

If you're outside a request context (e.g., in a background job), retrieve the dock from the client using the specific spaceport-uuid cookie value: client.getDock(spaceportUUID).

## Client Cargo vs Dock

Understanding the difference between client.cargo and the dock is crucial for effective session management. While the dock is session-scoped (tied to a specific spaceport-uuid), client.cargo is shared across all sessions for that client. This makes it ideal for sharing state globally for the user, regardless of device or browser, without being global to the entire application.

Feature client.cargo client.dock
Scope Client-wide (all sessions) Session-specific (one UUID)
Persist Memory only Memory (unauth) or DB (auth)
Use Case Shared data across devices Session-specific data
Example User preferences, account stats Shopping cart, form drafts

Example:

def client = r.context.client
def spaceportUUID = r.context.cookies.'spaceport-uuid'

// Client-wide cargo (shared across all sessions)
client.cargo.favoriteColor.set('blue')
client.cargo.totalLogins.increment()

// Session-specific dock (this session/browser only)
def dock = client.getDock(spaceportUUID)
dock.currentPage.set('/dashboard')
dock.formDraft.set([ 'name': 'John', 'email': 'john@example.com' ])

Under the Hood:

For unauthenticated clients, the dock is stored in client.cargo under the key spaceport-docks.{spaceportUUID}. For authenticated clients, the dock is stored in the ClientDocument's cargo under the same key. Regardless of authentication status, client.cargo remains in memory only and is not persisted to the database.

Use client.cargo for temporary, client-wide data shared across all sessions. Use the dock for session-specific data like shopping carts. For permanently stored data for authenticated users, save it in the ClientDocument's cargo using either the dock (for session-specific data) or directly in the ClientDocument (for client-wide data).

# Client Lifecycle

## Creating Clients

Clients are created automatically when a user first connects, but you can also create them programmatically.

Automatic Creation:

@Alert('on page hit')
static void _ensureClient(HttpResult r) {
    // Spaceport automatically creates a Client if one doesn't exist
    def client = r.context.client // Always available
    
    if (!client) {
        println "No client found, something went wrong."
    }
}

Manual Creation:

You may need to create clients manually in some scenarios. Use Spaceport's Personnel System:

// Create a new client with random ID
def client = Client.getNewClient()

// Create and associate with a cookie
def client = Client.getNewClient(spaceportUUID)

// Get or create a client with specific ID
def client = Client.getClient('client-abc123')

## Retrieving Clients

Since Spaceport automatically manages clients based on the spaceport-uuid cookie, you typically access the client via r.context.client or the client variable in Launchpad templates. However, you can retrieve clients manually when needed.

By Cookie:

def spaceportUUID = r.context.cookies.'spaceport-uuid'
def client = Client.getClientByCookie(spaceportUUID)

By User ID:

def client = Client.getClient('client-abc123')

By WebSocket Handler:

@Alert('on socket data')
static void _handleSocket(SocketResult r) {
    def handler = r.getHandler()
    def client = Client.getClientByHandler(handler)
}

# Authentication

## Authenticating a Client

Authentication connects a Client to a ClientDocument in the database, enabling persistent storage of user data and session state. Here's how to authenticate a client using a username and password:

@Alert('on /login POST')
static void _login(HttpResult r) {
    def username = r.context.data.username
    def password = r.context.data.password
    
    // Authenticate the client
    def client = Client.getAuthenticatedClient(username, password)
    
    if (client) {
        // Authentication successful, tie it to the current session
        client.attachCookie(r.context.cookies.'spaceport-uuid')
        
        // Access the authenticated user's document
        def doc = client.document
        println "Welcome back, ${ doc.fields.name }!"
        
        r.setRedirectUrl('/dashboard')
    } else {
        r.writeToClient([ 'error': 'Invalid credentials' ], 401)
    }
}
The PORT-MERCURY starter kit includes a complete implementation of user authentication using Client and ClientDocument, along with a Migration to create new ClientDocuments.

What Happens During Authentication:

When calling Client.getAuthenticatedClient:

After authentication:

The Client object pre-authentication is separate from the authenticated Client returned by getAuthenticatedClient. The guest Client will be garbage collected if no longer referenced, so migrate any session data (e.g., shopping cart) from the guest dock to the authenticated dock if necessary.

## Authentication Alerts

Spaceport fires alerts during the authentication process.

on client auth: Fired after successful authentication

This alert allows you to hook into successful login events. You can log activity, initialize session data, or cancel authentication if needed by setting r.cancelled = true.

@Alert('on client auth')
static void _logSuccessfulAuth(Result r) {
    def userId = r.context.userID
    def client = r.context.client
    
    println "User ${userId} authenticated at ${new Date()}"
    
    // Optional: Cancel authentication
    if (suspiciousActivity(userId)) {
        r.cancelled = true
    }
}

on client auth failed: Fired when authentication fails

This alert provides context about the attempted userID and whether the user exists in the database. It's useful for logging failed attempts, tracking security issues, or implementing rate limiting.

@Alert('on client auth failed')
static void _logFailedAuth(Result r) {
    def userId = r.context.userID
    def exists = r.context.exists  // Does the user exist?
    
    println "Failed login for ${userId} - User exists: ${exists}"
}
For simple applications, checking whether Client.getAuthenticatedClient returns a valid Client may be sufficient without using these alerts.

## Logging Out

To log a user out, remove their association with the spaceport-uuid cookie:

@Alert('on /logout GET')
static void _logout(HttpResult r) {
    def client = r.context.client
    def spaceportUUID = r.context.cookies.'spaceport-uuid'
    
    if (client?.authenticated) {
        client.removeCookie(spaceportUUID)
    }
    
    r.setRedirectUrl('/')
}

To log the user out of all sessions, remove all authentication cookies:

client.authenticationCookies.each { uuid ->
    client.removeCookie(uuid)
}

client.authenticated = false

# ClientDocument: User Data Storage

When a Client is authenticated, they gain access to a ClientDocument—a specialized Document subclass that stores user-specific data in the database. ClientDocument provides built-in support for common user fields, password management, and permissions.

ClientDocument is stored in the users database. See Documents for more on how ClientDocument works under the hood.

## Accessing the ClientDocument

Accessing the ClientDocument requires the user to be authenticated and a corresponding document to exist in the database:

def client = r.context.client

if (client.authenticated) {
    def doc = client.document
    
    // Access user fields
    println doc.name
    println doc.email
    println doc.status
    
    // Check permissions
    if (doc.hasPermission('spaceport-administrator')) {
        println "User is an admin"
    }
}

## Creating New Users

Creating a new user involves creating a new ClientDocument in the database. This is typically done during registration. Spaceport hashes the password securely using BCrypt. The username becomes the _id of the ClientDocument. If the username already exists, null is returned.

@Alert('on /register POST')
static void _register(HttpResult r) {
    def username = r.context.data.username
    def password = r.context.data.password
    def email = r.context.data.email
    
    // Create the ClientDocument
    def doc = ClientDocument.createNewClientDocument(username, password)
    
    if (doc) {
        // Set additional fields
        doc.setEmail(email)
        doc.setName(username)
        doc.updateStatus('active')
        
        // Authenticate
        def client = Client.getAuthenticatedClient(username, password)
        client.attachCookie(r.context.cookies.'spaceport-uuid')
        
        r.setRedirectUrl('/welcome')
    } else {
        r.writeToClient([ 'error': 'Username already exists' ], 409)
    }
}

## ClientDocument Properties

ClientDocument provides common methods for accessing frequently used user properties.

Built-in Fields (stored in fields map):

Field Access Method Purpose
name getName(), setName(String) User's display name
e-mail getEmail(), setEmail(String) User's email address
phone getPhone(), setPhone(String) User's phone number
status getStatus(), updateStatus(String) User account status

## Notes & Permissions

ClientDocument supports a notes system and permissions system for storing arbitrary notes and managing user permissions. Provide the UI and logic to utilize these features as needed.

def doc = client.document

// Notes system
doc.addNote("User signed up for newsletter")
def notes = doc.notes  // Map of timestamped notes

// Permissions system
doc.addPermission('view-reports')
doc.addPermission('edit-articles')
doc.removePermission('delete-users')

if (doc.hasPermission('admin')) {
    println "User has admin access"
}

## Password Management

ClientDocument uses BCrypt for secure password hashing with a salt and provides methods for changing and verifying passwords.

def doc = ClientDocument.getClientDocument('john-doe')

// Change password (automatically hashed)
doc.changePassword('newPassword123')

// Check password
if (doc.checkPassword('attemptedPassword')) {
    println "Password correct"
} else {
    println "Password incorrect"
}

// Store unhashed password (not recommended!)
doc.changePassword('plaintext', false)

# WebSocket Integration

Clients can have multiple WebSocket connections attached to them, allowing real-time communication across all sessions. Spaceport automatically associates WebSocket connections with the appropriate Client based on the spaceport-uuid cookie and removes them when connections close.

Interacting with WebSocket connections through the Client object is typically not recommended but may be useful for sending notifications or debugging. The client.sockets property contains weak references to all active WebSocket handlers for that client. Check if the handler is still valid before sending messages.

Sending Messages to All Sockets:

def client = r.context.client

client.sockets.each { weakRef ->
    def handler = weakRef.get()
    if (handler) {
        handler.send([ 'type': 'notification', 'message': 'Hello!' ])
    }
}

# ClientRegistry: Managing Active Clients

The ClientRegistry maintains a list of all currently active clients. While you typically don't interact with it directly, it can be useful for administrative tasks.

@Alert('on /admin/stats GET')
static void _showStats(HttpResult r) {
    def totalClients = ClientRegistry.activeClients.size()
    def authenticated = ClientRegistry.activeClients.count { it.authenticated }
    def anonymous = totalClients - authenticated
    
    r.writeToClient([
        'total': totalClients,
        'authenticated': authenticated,
        'anonymous': anonymous
    ])
}

# Migration from Traditional Sessions

If you're familiar with traditional HTTP Servlet session management, here's how Spaceport's system compares:

Traditional Approach Spaceport Approach
session.setAttribute() dock.set(key, value)
session.getAttribute() dock.key
session.invalidate() client.removeCookie(uuid)
User object in session client.document (if authenticated)
Session timeout Handled by spaceport-uuid cookie expiry
Spaceport's approach uses the spaceport-uuid cookie to manage session state via the dock and client objects. If necessary, traditional sessions can be utilized by accessing the raw HttpServletRequest and HttpServletResponse objects from the Alert context. Spaceport's docking session management is recommended for most use cases.

# Troubleshooting

## Dock Data Disappears After Server Restart

Cause: Client is not authenticated. Unauthenticated docks are stored in memory only.

Solution: Create and authenticate the client to persist dock data to the database:

def client = Client.getAuthenticatedClient(username, password)
client.attachCookie(spaceportUUID)
client.document.save()  // Dock now persists

## Can't Access Dock in a Source Module

Cause: Dock is tied to a specific spaceport-uuid, which comes from the request context.

Solution: Get the dock using the client and UUID:

def client = r.context.client
def spaceportUUID = r.context.cookies.'spaceport-uuid'
def dock = client.getDock(spaceportUUID)

## Client is Null

Cause: Request doesn't have a spaceport-uuid cookie, or the client wasn't created.

Solution: Ensure cookies are enabled and Spaceport's automatic client creation is working. If necessary, create the client manually:

def client = r.context.client
if (!client) {
    def uuid = r.context.cookies.'spaceport-uuid'
    client = Client.getNewClient(uuid)
}

# Summary

Spaceport's session management system provides a robust architecture for handling both anonymous and authenticated users seamlessly, removing much of the boilerplate code typically associated with session management.

Key Features:

# Next Steps