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, aClientis 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 technicalClientobject, these terms may be used interchangeably.
# Core Concepts
## The spaceport-uuid Cookie
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:
- Automatic Assignment: Set by Spaceport on the first request and immediately available
- Session Identifier: Used to retrieve the user's docking session data, even for unauthenticated users
- Persistence: Maintained across browser sessions until expiration or manual deletion
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:
userID: Unique identifier for this client (random for unauthenticated, username for authenticated)cargo: Client-scoped Cargo (shared across all sessions for this client)dock: Session-scoped Cargo objects, one perspaceport-uuidassociated with this clientdocument: The ClientDocument associated with this client (if authenticated)authenticationCookies: List ofspaceport-uuidcookies associated with this clientsockets: WebSocket connections currently attached to this clientauthenticated: Boolean indicating authentication status
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:
- Unauthenticated Clients: Dock stored in memory (lost on server restart)
- Authenticated Clients: Dock stored in the ClientDocument database (survives restarts)
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:
- Spaceport queries the
usersdatabase using a CouchDB view - Password is verified using BCrypt (passwords are hashed securely by default)
- If successful, an
on client authalert fires for hooking into login events - A new
Clientobject is created (or an existing one is retrieved) tied to the username
After authentication:
- Call
client.attachCookie(spaceportUUID)to link the Client to the current session - The Client is now considered authenticated for this session
- The ClientDocument is accessible via
client.document
TheClientobject pre-authentication is separate from the authenticatedClientreturned bygetAuthenticatedClient. 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:
- Automatic session creation via
spaceport-uuidcookies - Client objects representing both authenticated and anonymous users
- Multiple session support: one user, multiple devices, independent docking sessions
- Authentication system with easy login/logout and event hooks
- The dock: session-scoped storage that's automatically persistent for authenticated users
- Client cargo: client-wide storage shared across all sessions
- ClientDocument: database-backed user profiles with built-in password management
- Seamless integration with Launchpad templates and WebSockets
SPACEPORT DOCS