# Create Account Source: https://docs.snackbase.dev/api-reference/endpoints/accounts/create-account post /api/v1/accounts Create a new account. Creates a new account with auto-generated ID and optional slug. Only superadmins can access this endpoint. # Delete Account Source: https://docs.snackbase.dev/api-reference/endpoints/accounts/delete-account delete /api/v1/accounts/{account_id} Delete an account. Deletes the account and all associated users and data (cascade). System account (nil UUID) cannot be deleted. Only superadmins can access this endpoint. # Get Account Source: https://docs.snackbase.dev/api-reference/endpoints/accounts/get-account get /api/v1/accounts/{account_id} Get detailed account information. Returns account details including user count and collections used. Only superadmins can access this endpoint. # Get Account Users Source: https://docs.snackbase.dev/api-reference/endpoints/accounts/get-account-users get /api/v1/accounts/{account_id}/users Get users in an account. Returns a paginated list of users for the specified account. Only superadmins can access this endpoint. # List Accounts Source: https://docs.snackbase.dev/api-reference/endpoints/accounts/list-accounts get /api/v1/accounts List all accounts with pagination, sorting, and search. Returns a paginated list of accounts with user counts. Only superadmins can access this endpoint. # Update Account Source: https://docs.snackbase.dev/api-reference/endpoints/accounts/update-account put /api/v1/accounts/{account_id} Update an account. Updates the account name. Slug and ID are immutable. Only superadmins can access this endpoint. # Create Configuration Source: https://docs.snackbase.dev/api-reference/endpoints/admin/create-configuration post /api/v1/admin/configuration Create a new configuration record. # Delete Configuration Source: https://docs.snackbase.dev/api-reference/endpoints/admin/delete-configuration delete /api/v1/admin/configuration/{config_id} Delete a configuration. Cannot delete built-in providers. # Get Account Configurations Source: https://docs.snackbase.dev/api-reference/endpoints/admin/get-account-configurations get /api/v1/admin/configuration/account List all configurations for a specific account. Args: account_id: Account ID to fetch configurations for. category: Optional category filter. # Get Available Providers Source: https://docs.snackbase.dev/api-reference/endpoints/admin/get-available-providers get /api/v1/admin/configuration/providers List all available provider definitions. # Get Configuration Stats Source: https://docs.snackbase.dev/api-reference/endpoints/admin/get-configuration-stats get /api/v1/admin/configuration/stats Get configuration statistics for the dashboard. Returns counts of enabled system and account configurations grouped by category. # Get Configuration Values Source: https://docs.snackbase.dev/api-reference/endpoints/admin/get-configuration-values get /api/v1/admin/configuration/{config_id}/values Get decrypted configuration values with secrets masked. # Get Email Log Source: https://docs.snackbase.dev/api-reference/endpoints/admin/get-email-log get /api/v1/admin/email/logs/{log_id} Get email log by ID. Args: log_id: Log ID to retrieve. Returns: Email log details. Raises: HTTPException: 404 if log not found. # Get Email Template Source: https://docs.snackbase.dev/api-reference/endpoints/admin/get-email-template get /api/v1/admin/email/templates/{template_id} Get email template by ID. Args: template_id: Template ID to retrieve. Returns: Email template details. Raises: HTTPException: 404 if template not found. # Get Provider Schema Source: https://docs.snackbase.dev/api-reference/endpoints/admin/get-provider-schema get /api/v1/admin/configuration/schema/{category}/{provider_name} Get the JSON schema for a specific provider. # Get Recent Configurations Source: https://docs.snackbase.dev/api-reference/endpoints/admin/get-recent-configurations get /api/v1/admin/configuration/recent Get recently modified configurations. Args: limit: Number of records to return (default: 5) # Get System Configurations Source: https://docs.snackbase.dev/api-reference/endpoints/admin/get-system-configurations get /api/v1/admin/configuration/system List all system configurations. Args: category: Optional category filter. # List Email Logs Source: https://docs.snackbase.dev/api-reference/endpoints/admin/list-email-logs get /api/v1/admin/email/logs List email logs with optional filters and pagination. Args: status_filter: Optional filter by status ('sent', 'failed', 'pending'). template_type: Optional filter by template type. start_date: Optional filter by start date (ISO format). end_date: Optional filter by end date (ISO format). page: Page number (default: 1). page_size: Number of logs per page (default: 25, max: 100). Returns: Paginated list of email logs. # List Email Templates Source: https://docs.snackbase.dev/api-reference/endpoints/admin/list-email-templates get /api/v1/admin/email/templates List all email templates with optional filters. Args: template_type: Optional filter by template type. locale: Optional filter by locale. account_id: Optional filter by account ID. enabled: Optional filter by enabled status. Returns: List of email templates matching the filters. # Render Email Template Source: https://docs.snackbase.dev/api-reference/endpoints/admin/render-email-template post /api/v1/admin/email/templates/render Render an email template without sending. Args: render_request: Template rendering request with variables. Returns: Rendered email content (subject, html_body, text_body). Raises: HTTPException: 404 if template not found, 422 if rendering fails. # Send Test Email Source: https://docs.snackbase.dev/api-reference/endpoints/admin/send-test-email post /api/v1/admin/email/templates/{template_id}/test Send a test email using the specified template. Args: template_id: Template ID to use for test email. test_request: Test email request with recipient and variables. request: FastAPI request object for accessing app state. Returns: Success message with email details. Raises: HTTPException: 404 if template not found, 400 if no email provider configured, 500 if sending fails. # Test Provider Connection Source: https://docs.snackbase.dev/api-reference/endpoints/admin/test-provider-connection post /api/v1/admin/configuration/test-connection Test connection for a provider configuration. # Update Configuration Status Source: https://docs.snackbase.dev/api-reference/endpoints/admin/update-configuration-status patch /api/v1/admin/configuration/{config_id} Update configuration status (enable/disable). Args: config_id: Configuration ID. enabled: New enabled status. # Update Configuration Values Source: https://docs.snackbase.dev/api-reference/endpoints/admin/update-configuration-values patch /api/v1/admin/configuration/{config_id}/values Update configuration values. # Update Email Template Source: https://docs.snackbase.dev/api-reference/endpoints/admin/update-email-template put /api/v1/admin/email/templates/{template_id} Update an email template. Args: template_id: Template ID to update. update_data: Fields to update. Returns: Updated email template. Raises: HTTPException: 404 if template not found. # Export Audit Logs Source: https://docs.snackbase.dev/api-reference/endpoints/audit-logs/export-audit-logs get /api/v1/audit-logs/export Export audit logs in CSV or JSON format. Applies current filters to the exported data. Only superadmins can export audit logs. PII is masked unless the user belongs to the 'pii_access' group. # Get Audit Log Source: https://docs.snackbase.dev/api-reference/endpoints/audit-logs/get-audit-log get /api/v1/audit-logs/{log_id} Get a single audit log entry by ID. Includes full details and integrity chain information. Only superadmins can access audit logs. PII is masked unless the user belongs to the 'pii_access' group. # List Audit Logs Source: https://docs.snackbase.dev/api-reference/endpoints/audit-logs/list-audit-logs get /api/v1/audit-logs/ List audit log entries with advanced filtering and pagination. Only superadmins can access audit logs. PII is masked unless the user belongs to the 'pii_access' group. # Acs Source: https://docs.snackbase.dev/api-reference/endpoints/auth/acs post /api/v1/auth/saml/acs SAML Assertion Consumer Service (ACS) endpoint. Identity Provider POSTs the SAML assertion here after successful authentication. # Authorize Source: https://docs.snackbase.dev/api-reference/endpoints/auth/authorize post /api/v1/auth/oauth/{provider_name}/authorize Initiate OAuth authorization flow. Generates an authorization URL for the specified provider and stores the flow state in the database for CSRF protection. Flow: 1. Resolve provider configuration (account override -> system fallback) 2. Generate state token (if not provided) 3. Store state in oauth_states table 4. Generate authorization URL via provider handler 5. Return URL and state # Callback Source: https://docs.snackbase.dev/api-reference/endpoints/auth/callback post /api/v1/auth/oauth/{provider_name}/callback Complete OAuth authorization flow. Validates the state token, exchanges the authorization code for tokens, and creates or updates the user record. Returns JWT tokens. Flow: 1. Validate state token and check expiration 2. Resolve provider configuration 3. Exchange code for tokens via provider handler 4. Fetch user info via provider handler 5. Link to existing user or create new user/account 6. Generate JWT tokens and return response # Forgot Password Source: https://docs.snackbase.dev/api-reference/endpoints/auth/forgot-password post /api/v1/auth/forgot-password Initiate password reset flow. Generates a reset token and sends an email with reset instructions. Always returns 200 regardless of whether the email exists (security - don't reveal user existence). Args: request: FastAPI request object to get client IP. forgot_request: Email and account identifier. session: Database session. reset_service: Password reset service dependency. Returns: Generic success message. # Get Current User Info Source: https://docs.snackbase.dev/api-reference/endpoints/auth/get-current-user-info get /api/v1/auth/me Get the current authenticated user's information. This is a protected endpoint that requires a valid access token. Returns the user information extracted from the JWT token claims. # Login Source: https://docs.snackbase.dev/api-reference/endpoints/auth/login post /api/v1/auth/login Authenticate a user and return JWT tokens. Validates the user's credentials against the specified account and returns JWT tokens for authenticated access. Flow: 1. Resolve account by slug or ID 2. Look up user by email in account 3. Check authentication provider (OAuth/SAML users must use their respective flows) 4. Verify password using timing-safe comparison 5. Check if user is active 6. Update last_login timestamp 7. Generate JWT tokens 8. Return response Security: - All authentication failures return the same generic 401 message - Password verification is always performed (even with dummy hash) to prevent timing attacks # Metadata Source: https://docs.snackbase.dev/api-reference/endpoints/auth/metadata get /api/v1/auth/saml/metadata Download SAML Service Provider Metadata XML. Used to configure the Identity Provider to recognize this SP. Returns: XML content with Content-Disposition attachment. # Refresh Tokens Source: https://docs.snackbase.dev/api-reference/endpoints/auth/refresh-tokens post /api/v1/auth/refresh Refresh access and refresh tokens. Validates the provided refresh token, issues new tokens, and invalidates the old refresh token (token rotation). Flow: 1. Validate refresh token (signature, expiration, type) 2. Check if token is revoked in database 3. Get user information from token claims 4. Revoke old refresh token 5. Generate new access token and refresh token 6. Store new refresh token 7. Return new tokens # Register Source: https://docs.snackbase.dev/api-reference/endpoints/auth/register post /api/v1/auth/register Register a new account and user. In multi-tenant mode (default): - Creates a new account with the provided details - Creates the first user as an admin In single-tenant mode: - Joins the pre-configured account - Assigns 'user' role (not admin) # Resend Verification Source: https://docs.snackbase.dev/api-reference/endpoints/auth/resend-verification post /api/v1/auth/resend-verification Resend verification email to the user. # Reset Password Source: https://docs.snackbase.dev/api-reference/endpoints/auth/reset-password post /api/v1/auth/reset-password Reset password using a valid reset token. Validates the token, updates the password, and invalidates all refresh tokens. Args: request: Reset token and new password. reset_service: Password reset service dependency. Returns: Success message. Raises: HTTPException: 400 if token is invalid, expired, or already used. # Send Verification Email Source: https://docs.snackbase.dev/api-reference/endpoints/auth/send-verification-email post /api/v1/auth/send-verification Send a verification email to the current user. Args: request: Optional email address. current_user: The authenticated user. verification_service: Verification service dependency. # Sso Source: https://docs.snackbase.dev/api-reference/endpoints/auth/sso get /api/v1/auth/saml/sso Initiate SAML Single Sign-On flow. Redirects the user to the Identity Provider's SSO URL with a SAML AuthnRequest. Args: request: FastAPI request object. account: Account identifier (slug or ID). provider: Optional provider name to use (e.g., 'okta', 'azure_ad'). If not specified, uses the configured provider for the account. relay_state: Optional state to return after successful authentication. session: Database session. Returns: RedirectResponse to the IdP. # Verify Email Source: https://docs.snackbase.dev/api-reference/endpoints/auth/verify-email post /api/v1/auth/verify-email Verify a user's email address using a token. Args: request: Verification token. verification_service: Verification service dependency. # Verify Reset Token Source: https://docs.snackbase.dev/api-reference/endpoints/auth/verify-reset-token get /api/v1/auth/verify-reset-token/{token} Verify if a password reset token is valid without using it. Used by frontend to pre-validate the token before showing the reset form. Args: token: The reset token to verify. reset_service: Password reset service dependency. Returns: Token validity status and expiration time. # Create Collection Source: https://docs.snackbase.dev/api-reference/endpoints/collections/create-collection post /api/v1/collections Create a new collection with custom schema. Creates an Alembic migration and applies it to create the physical table. Only superadmins can create collections. # Delete Collection Source: https://docs.snackbase.dev/api-reference/endpoints/collections/delete-collection delete /api/v1/collections/{collection_id} Delete collection and drop its physical table. Uses a two-phase approach to avoid transaction deadlocks on PostgreSQL: 1. Prepare deletion and generate migration (read-only) 2. Close session and apply migration (no locks held) 3. Delete collection record in new session Only superadmins (users in the system account) can delete collections. # Get Collection Source: https://docs.snackbase.dev/api-reference/endpoints/collections/get-collection get /api/v1/collections/{collection_id} Get collection details by ID. Only superadmins (users in the system account) can view collections. # Get Collection Names Source: https://docs.snackbase.dev/api-reference/endpoints/collections/get-collection-names get /api/v1/collections/names Get simple list of all collection names. Returns a simple list of collection names without pagination, suitable for populating dropdowns and permission matrices. Only superadmins can access this endpoint. Args: current_user: Authenticated superadmin user. session: Database session. Returns: List of collection names. # Get collection rules Source: https://docs.snackbase.dev/api-reference/endpoints/collections/get-collection-rules get /api/v1/collections/{name}/rules # List Collections Source: https://docs.snackbase.dev/api-reference/endpoints/collections/list-collections get /api/v1/collections List all collections with pagination and search. Only superadmins (users in the system account) can list collections. # Update Collection Source: https://docs.snackbase.dev/api-reference/endpoints/collections/update-collection put /api/v1/collections/{collection_id} Update collection schema. Allows adding new fields and modifying field properties (except type changes). Only superadmins (users in the system account) can update collections. # Update collection rules Source: https://docs.snackbase.dev/api-reference/endpoints/collections/update-collection-rules put /api/v1/collections/{name}/rules # Create Endpoint Source: https://docs.snackbase.dev/api-reference/endpoints/custom-endpoints/create-endpoint post /api/v1/endpoints Create a new custom endpoint for the current account. Returns 409 if the account has reached the endpoint limit, or if a conflicting (account_id, path, method) combination already exists. Returns 422 if the path is invalid or conflicts with a built-in route. # Delete Endpoint Source: https://docs.snackbase.dev/api-reference/endpoints/custom-endpoints/delete-endpoint delete /api/v1/endpoints/{endpoint_id} Delete a custom endpoint. # Get Endpoint Source: https://docs.snackbase.dev/api-reference/endpoints/custom-endpoints/get-endpoint get /api/v1/endpoints/{endpoint_id} Retrieve a single custom endpoint by ID. # List Endpoint Executions Source: https://docs.snackbase.dev/api-reference/endpoints/custom-endpoints/list-endpoint-executions get /api/v1/endpoints/{endpoint_id}/executions List execution history for a custom endpoint, newest first. # List Endpoints Source: https://docs.snackbase.dev/api-reference/endpoints/custom-endpoints/list-endpoints get /api/v1/endpoints List custom endpoints for the current account. # Toggle Endpoint Source: https://docs.snackbase.dev/api-reference/endpoints/custom-endpoints/toggle-endpoint patch /api/v1/endpoints/{endpoint_id}/toggle Toggle a custom endpoint's enabled/disabled state. # Update Endpoint Source: https://docs.snackbase.dev/api-reference/endpoints/custom-endpoints/update-endpoint put /api/v1/endpoints/{endpoint_id} Update a custom endpoint. # Get Dashboard Stats Source: https://docs.snackbase.dev/api-reference/endpoints/dashboard/get-dashboard-stats get /api/v1/dashboard/stats Get dashboard statistics. Returns comprehensive dashboard metrics including: - Total counts (accounts, users, collections, records) - Growth metrics (new accounts/users in last 7 days) - Recent registrations (last 10 users) - System health (database status, storage usage) - Active sessions count - Recent audit logs (PII masked based on user group membership) Only superadmins (users in the system account with nil UUID) can access this endpoint. PII is masked unless the user belongs to the 'pii_access' group. # Download a file Source: https://docs.snackbase.dev/api-reference/endpoints/files/download-a-file get /api/v1/files/{file_path} Download a file from storage. Requires authentication and proper permissions. # Upload a file Source: https://docs.snackbase.dev/api-reference/endpoints/files/upload-a-file post /api/v1/files/upload Upload a file to storage. Returns file metadata including path for use in records. # Add user to group Source: https://docs.snackbase.dev/api-reference/endpoints/groups/add-user-to-group post /api/v1/groups/{group_id}/users Add a user to a group. # Create a new group Source: https://docs.snackbase.dev/api-reference/endpoints/groups/create-a-new-group post /api/v1/groups Create a new group in the user's account. Requires authenticated user (usually admin, but enforced via permissions logic if needed). For now, any authenticated user can create groups in their account (or restrict to admin). # Delete a group Source: https://docs.snackbase.dev/api-reference/endpoints/groups/delete-a-group delete /api/v1/groups/{group_id} Delete a group. # Get a group Source: https://docs.snackbase.dev/api-reference/endpoints/groups/get-a-group get /api/v1/groups/{group_id} Get a specific group by ID. # List groups Source: https://docs.snackbase.dev/api-reference/endpoints/groups/list-groups get /api/v1/groups List all groups in the user's account (or all groups if superadmin). # Remove user from group Source: https://docs.snackbase.dev/api-reference/endpoints/groups/remove-user-from-group delete /api/v1/groups/{group_id}/users/{user_id} Remove a user from a group. # Update a group Source: https://docs.snackbase.dev/api-reference/endpoints/groups/update-a-group patch /api/v1/groups/{group_id} Update a group. # Health Check Source: https://docs.snackbase.dev/api-reference/endpoints/health/health-check get /health Basic health check endpoint. Returns 200 if the service is running. Does not check database connectivity or other dependencies. # Liveness Check Source: https://docs.snackbase.dev/api-reference/endpoints/health/liveness-check get /live Liveness check endpoint. Returns 200 if the service is alive. This is a simple check that the service is running and responding to requests. # Readiness Check Source: https://docs.snackbase.dev/api-reference/endpoints/health/readiness-check get /ready Readiness check endpoint. Returns 200 if the service is ready to accept requests, including database connectivity check. # Create Hook Source: https://docs.snackbase.dev/api-reference/endpoints/hooks/create-hook post /api/v1/hooks Create a new hook for the current account. Supports schedule, event, and manual trigger types. Returns 422 for invalid cron expressions or unrecognised event names. Returns 409 when the account has reached the maximum hook limit. # Delete Hook Source: https://docs.snackbase.dev/api-reference/endpoints/hooks/delete-hook delete /api/v1/hooks/{hook_id} Delete a hook. # Get Hook Source: https://docs.snackbase.dev/api-reference/endpoints/hooks/get-hook get /api/v1/hooks/{hook_id} Retrieve a single hook by ID. # List Hook Executions Source: https://docs.snackbase.dev/api-reference/endpoints/hooks/list-hook-executions get /api/v1/hooks/{hook_id}/executions List execution history for a hook, newest first. # List Hooks Source: https://docs.snackbase.dev/api-reference/endpoints/hooks/list-hooks get /api/v1/hooks List hooks for the current account with optional filters. # Toggle Hook Source: https://docs.snackbase.dev/api-reference/endpoints/hooks/toggle-hook patch /api/v1/hooks/{hook_id}/toggle Toggle a hook's enabled/disabled state. For schedule-type hooks, ``next_run_at`` is recalculated when re-enabling. # Trigger Hook Source: https://docs.snackbase.dev/api-reference/endpoints/hooks/trigger-hook post /api/v1/hooks/{hook_id}/trigger Manually trigger a hook. For schedule-type hooks a job is enqueued (existing behaviour). For event and manual hooks the actions are executed inline and an execution record is written. The hook does not need to be enabled. # Update Hook Source: https://docs.snackbase.dev/api-reference/endpoints/hooks/update-hook patch /api/v1/hooks/{hook_id} Update a hook. # Accept Invitation Source: https://docs.snackbase.dev/api-reference/endpoints/invitations/accept-invitation post /api/v1/invitations/{token}/accept Accept an invitation and create a user account. Validates the invitation token, creates a user account with the provided password, and returns authentication tokens. Flow: 1. Validate token exists 2. Validate token not expired 3. Validate token not already accepted 4. Validate password strength 5. Create user account 6. Mark invitation as accepted 7. Generate JWT tokens 8. Return auth response # Cancel Invitation Source: https://docs.snackbase.dev/api-reference/endpoints/invitations/cancel-invitation delete /api/v1/invitations/{invitation_id} Cancel an invitation. Deletes an invitation from the database. Only invitations belonging to the current user's account can be cancelled. Args: invitation_id: ID of the invitation to cancel. current_user: Authenticated user context. session: Database session. Raises: HTTPException: 404 if invitation not found or doesn't belong to account. # Create Invitation Source: https://docs.snackbase.dev/api-reference/endpoints/invitations/create-invitation post /api/v1/invitations Create a new invitation. Creates an invitation for a user to join the current user's account. Sends an invitation email with a secure token. Flow: 1. Validate email format (handled by Pydantic) 2. Check if user already exists in account 3. Check if pending invitation exists 4. Generate secure token 5. Create invitation record 6. Send invitation email 7. Return invitation details (excluding token) # Get Invitation Source: https://docs.snackbase.dev/api-reference/endpoints/invitations/get-invitation get /api/v1/invitations/{token} Get public invitation details. Validates the invitation token and returns non-sensitive details for the acceptance page. Flow: 1. Validate token exists 2. Validate token not expired 3. Validate token not already accepted 4. Return public details # List Invitations Source: https://docs.snackbase.dev/api-reference/endpoints/invitations/list-invitations get /api/v1/invitations List invitations. For superadmins: lists all invitations or filters by account_id. For regular users: lists invitations for their own account only. Args: current_user: Authenticated user context. status_filter: Optional status filter (pending, accepted, expired). account_id: Optional account ID filter (superadmin only). session: Database session. Returns: List of invitations. # Resend Invitation Source: https://docs.snackbase.dev/api-reference/endpoints/invitations/resend-invitation post /api/v1/invitations/{invitation_id}/resend Resend an invitation email. Resends the invitation email for a pending invitation. # Cancel Job Source: https://docs.snackbase.dev/api-reference/endpoints/jobs/cancel-job delete /api/v1/admin/jobs/{job_id} Cancel and delete a pending job. Only jobs in 'pending' status can be cancelled. Running, completed, failed, retrying, and dead jobs cannot be cancelled. Args: job_id: ID of the job to cancel. Raises: 404: Job not found. 400: Job is not in pending status. # Get Job Statistics Source: https://docs.snackbase.dev/api-reference/endpoints/jobs/get-job-stats get /api/v1/admin/jobs/stats Get aggregate job counts by status. Returns counts for all statuses (pending, running, completed, failed, retrying, dead) and computed metrics (avg_duration_seconds, failure_rate). Counts are live snapshots from the database. # List Jobs Source: https://docs.snackbase.dev/api-reference/endpoints/jobs/list-jobs get /api/v1/admin/jobs List background jobs with optional filters and pagination. Args: status_filter: Filter by job status (pending, running, completed, etc.). queue: Filter by queue name. handler: Filter by handler identifier. limit: Maximum records to return (1-200, default 50). offset: Records to skip for pagination (default 0). Returns: Paginated list of jobs with total count. # Retry Job Source: https://docs.snackbase.dev/api-reference/endpoints/jobs/retry-job post /api/v1/admin/jobs/{job_id}/retry Manually retry a dead, failed, or retrying job. Resets the job's status to pending, clears the error message, and resets the attempt counter. The job will be picked up by the worker on the next poll cycle. Args: job_id: ID of the job to retry. Raises: 404: Job not found. 400: Job is not in dead, failed, or retrying status. # Create Macro Source: https://docs.snackbase.dev/api-reference/endpoints/macros/create-macro post /api/v1/macros Create a new SQL macro. Requires superadmin privileges. # Delete Macro Source: https://docs.snackbase.dev/api-reference/endpoints/macros/delete-macro delete /api/v1/macros/{macro_id} Delete a SQL macro. Requires superadmin privileges. Fails if macro is used in any active permission rules. # Get Macro Source: https://docs.snackbase.dev/api-reference/endpoints/macros/get-macro get /api/v1/macros/{macro_id} Get a SQL macro by ID. Accessible by all authenticated users. # List Macros Source: https://docs.snackbase.dev/api-reference/endpoints/macros/list-macros get /api/v1/macros List all SQL macros. Accessible by all authenticated users. # Test Macro Source: https://docs.snackbase.dev/api-reference/endpoints/macros/test-macro post /api/v1/macros/{macro_id}/test Test a SQL macro execution. Executes the macro in a transaction that is rolled back after execution. Requires superadmin privileges. # Update Macro Source: https://docs.snackbase.dev/api-reference/endpoints/macros/update-macro put /api/v1/macros/{macro_id} Update a SQL macro. Requires superadmin privileges. # Get Current Migration Source: https://docs.snackbase.dev/api-reference/endpoints/migrations/get-current-migration get /api/v1/migrations/current Get current database revision. Returns the currently applied migration revision. Only superadmins can access this endpoint. # Get Migration History Source: https://docs.snackbase.dev/api-reference/endpoints/migrations/get-migration-history get /api/v1/migrations/history Get full migration history. Returns all applied migrations in chronological order from oldest to newest. Only superadmins can access this endpoint. # List Migrations Source: https://docs.snackbase.dev/api-reference/endpoints/migrations/list-migrations get /api/v1/migrations List all Alembic revisions. Returns all migration revisions from both core and dynamic directories with their application status. Only superadmins can access this endpoint. # Sse Endpoint Source: https://docs.snackbase.dev/api-reference/endpoints/realtime/sse-endpoint get /api/v1/realtime/subscribe SSE endpoint for real-time subscriptions. # SSE Endpoint Connect to this endpoint for real-time updates via Server-Sent Events (SSE). **Connection URL:** ``` http://localhost:8000/api/v1/realtime/subscribe?token={jwt_token}&collection=posts ``` ## Query Parameters | Parameter | Type | Required | Description | | ---------- | ------ | -------- | ------------------------------------------------------------ | | token | string | Yes | JWT access token | | collection | string | No | Collection to subscribe to (can be specified multiple times) | ## Server Events ### Heartbeat Event Sent every 30 seconds to keep the connection alive. ``` event: heartbeat data: {"timestamp": "2026-01-17T12:34:56.789Z"} ``` ### Message Event Sent when a subscribed collection has data changes. ``` event: message data: {"type": "posts.create", "timestamp": "2026-01-17T12:34:56.789Z", "data": {...}} ``` ## Event Format All data events follow this structure: ```json theme={null} { "type": "posts.create", "timestamp": "2026-01-17T12:34:56.789Z", "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "title": "New Post", "created_at": "2026-01-17T12:34:56.789Z" } } ``` ## Connection Behavior * Automatic reconnection handled by the browser * Token expiration will close the connection * Heartbeat sent every 30 seconds ## Example ```javascript theme={null} const token = "your_jwt_token"; const eventSource = new EventSource( `http://localhost:8000/api/v1/realtime/subscribe?token=${token}&collection=posts` ); eventSource.addEventListener("message", (event) => { const message = JSON.parse(event.data); if (message.event === "heartbeat") { console.log("Heartbeat received"); return; } console.log("Event received:", message); // Update your UI }); eventSource.onerror = (error) => { console.error("SSE error:", error); }; // Close the connection when done // eventSource.close(); ``` ## Multiple Collections Subscribe to multiple collections by specifying the `collection` parameter multiple times: ``` http://localhost:8000/api/v1/realtime/subscribe?token={token}&collection=posts&collection=comments ``` # Websocket endpoint Source: https://docs.snackbase.dev/api-reference/endpoints/realtime/websocket-endpoint ws /api/v1/realtime/ws # WebSocket Endpoint Connect to this endpoint for real-time updates via WebSocket. **Connection URL:** ``` ws://localhost:8000/api/v1/realtime/ws?token={jwt_token} ``` ## Query Parameters | Parameter | Type | Required | Description | | --------- | ------ | -------- | ---------------- | | token | string | Yes | JWT access token | ## WebSocket Messages ### Subscribe Subscribe to a collection to receive updates. ```json theme={null} { "action": "subscribe", "collection": "posts", "operations": ["create", "update", "delete"] } ``` ### Unsubscribe Stop receiving updates for a collection. ```json theme={null} { "action": "unsubscribe", "collection": "posts" } ``` ### Ping Send a ping to keep the connection alive. ```json theme={null} { "action": "ping" } ``` ## Server Responses ### Subscribe Confirmation ```json theme={null} { "status": "subscribed", "collection": "posts" } ``` ### Unsubscribe Confirmation ```json theme={null} { "status": "unsubscribed", "collection": "posts" } ``` ### Pong Response ```json theme={null} { "type": "pong" } ``` ### Heartbeat Sent every 30 seconds. ```json theme={null} { "type": "heartbeat", "timestamp": "2026-01-17T12:34:56.789Z" } ``` ### Data Event ```json theme={null} { "type": "posts.create", "timestamp": "2026-01-17T12:34:56.789Z", "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "title": "New Post", "created_at": "2026-01-17T12:34:56.789Z" } } ``` ## Connection Limits * Maximum 100 subscriptions per connection * Connections closed after 1 hour (token expiration) * Heartbeat sent every 30 seconds ## Example ```javascript theme={null} const token = "your_jwt_token"; const ws = new WebSocket(`ws://localhost:8000/api/v1/realtime/ws?token=${token}`); ws.onopen = () => { // Subscribe to posts collection ws.send(JSON.stringify({ action: "subscribe", collection: "posts", operations: ["create", "update", "delete"] })); }; ws.onmessage = (event) => { const message = JSON.parse(event.data); if (message.type === "posts.create") { console.log("New post created:", message.data); } if (message.type === "heartbeat") { console.log("Heartbeat received"); } }; ws.onerror = (error) => { console.error("WebSocket error:", error); }; ws.onclose = () => { console.log("WebSocket connection closed"); }; ``` # Create Record Source: https://docs.snackbase.dev/api-reference/endpoints/records/create-record post /api/v1/records/{collection} Create a new record in a collection. Validates the request data against the collection schema, auto-generates a record ID, and sets system fields (account_id, created_at, created_by). Args: collection: The collection name (from URL path). data: The record data (request body). May include 'account_id' for superadmins. current_user: The authenticated user, or None for anonymous. auth_context: Authorization context for permission checking. session: Database session. Returns: The created record with all fields including system fields. # Delete Record Source: https://docs.snackbase.dev/api-reference/endpoints/records/delete-record delete /api/v1/records/{collection}/{record_id} Delete a record by ID. # Get Record Source: https://docs.snackbase.dev/api-reference/endpoints/records/get-record get /api/v1/records/{collection}/{record_id} Get a single record by ID. # List Records Source: https://docs.snackbase.dev/api-reference/endpoints/records/list-records get /api/v1/records/{collection} List records in a collection. Supports pagination, sorting, and filtering. # Update Record Full Source: https://docs.snackbase.dev/api-reference/endpoints/records/update-record-full put /api/v1/records/{collection}/{record_id} Update a record (full replacement). Replaces the entire record with the provided data (except system fields). # Update Record Partial Source: https://docs.snackbase.dev/api-reference/endpoints/records/update-record-partial patch /api/v1/records/{collection}/{record_id} Update a record (partial update). Updates only the provided fields. # Bulk update permissions Source: https://docs.snackbase.dev/api-reference/endpoints/roles/bulk-update-permissions put /api/v1/roles/{role_id}/permissions/bulk # Create Role Source: https://docs.snackbase.dev/api-reference/endpoints/roles/create-role post /api/v1/roles Create a new role. Creates a role with the specified name and description. Only superadmins can create roles. Args: role_request: Role creation request. current_user: Authenticated superadmin user. session: Database session. Returns: Created role. # Delete Role Source: https://docs.snackbase.dev/api-reference/endpoints/roles/delete-role delete /api/v1/roles/{role_id} Delete a role. Deletes the role. Default roles (admin, user) cannot be deleted. Only superadmins can delete roles. Args: role_id: Role ID. current_user: Authenticated superadmin user. session: Database session. # Get permissions matrix Source: https://docs.snackbase.dev/api-reference/endpoints/roles/get-permissions-matrix get /api/v1/roles/{role_id}/permissions/matrix # Get Role Source: https://docs.snackbase.dev/api-reference/endpoints/roles/get-role get /api/v1/roles/{role_id} Get a role by ID. Only superadmins can view role details. Args: role_id: Role ID. current_user: Authenticated superadmin user. session: Database session. Returns: Role details. # Get role permissions Source: https://docs.snackbase.dev/api-reference/endpoints/roles/get-role-permissions get /api/v1/roles/{role_id}/permissions # List Roles Source: https://docs.snackbase.dev/api-reference/endpoints/roles/list-roles get /api/v1/roles List all roles. Only superadmins can access this endpoint. Args: current_user: Authenticated superadmin user. session: Database session. Returns: List of all roles. # Test rule Source: https://docs.snackbase.dev/api-reference/endpoints/roles/test-rule post /api/v1/roles/test-rule # Update Role Source: https://docs.snackbase.dev/api-reference/endpoints/roles/update-role put /api/v1/roles/{role_id} Update a role. Updates the role name and description. Only superadmins can update roles. Args: role_id: Role ID. role_request: Role update request. current_user: Authenticated superadmin user. session: Database session. Returns: Updated role. # Validate rule Source: https://docs.snackbase.dev/api-reference/endpoints/roles/validate-rule post /api/v1/roles/validate-rule # Create a new user Source: https://docs.snackbase.dev/api-reference/endpoints/users/create-a-new-user post /api/v1/users Create a new user in any account (superadmin only). Creates a user with the specified email, password, account, and role. For password-based users, the password must meet security requirements. For OAuth/SAML users, a random unknowable password is auto-generated. # Deactivate a user Source: https://docs.snackbase.dev/api-reference/endpoints/users/deactivate-a-user delete /api/v1/users/{user_id} Deactivate a user (soft delete via is_active flag) (superadmin only). The user will not be able to log in, but their data is preserved. You cannot deactivate yourself. # Get a user Source: https://docs.snackbase.dev/api-reference/endpoints/users/get-a-user get /api/v1/users/{user_id} Get a specific user by ID (superadmin only). # List users Source: https://docs.snackbase.dev/api-reference/endpoints/users/list-users get /api/v1/users List users with optional filters (superadmin only). Returns a paginated list of users across all accounts. Supports filtering by account, role, status, and email search. # Manually verify user email Source: https://docs.snackbase.dev/api-reference/endpoints/users/manually-verify-user-email post /api/v1/users/{user_id}/verify Manually mark a user's email as verified (superadmin only). This bypasses the email token flow and directly updates the user's status. # Resend verification email Source: https://docs.snackbase.dev/api-reference/endpoints/users/resend-verification-email post /api/v1/users/{user_id}/resend-verification Resend verification email to a user (superadmin only). # Reset user password Source: https://docs.snackbase.dev/api-reference/endpoints/users/reset-user-password put /api/v1/users/{user_id}/password Reset a user's password (superadmin only). Can either send a reset link email or set a new password directly. Invalidates all of the user's refresh tokens, forcing them to log in again. # Update a user Source: https://docs.snackbase.dev/api-reference/endpoints/users/update-a-user patch /api/v1/users/{user_id} Update a user's email, role, or active status (superadmin only). Cannot modify password through this endpoint - use the password reset endpoint. Cannot modify your own role or deactivate yourself. # Create Webhook Source: https://docs.snackbase.dev/api-reference/endpoints/webhooks/create-webhook post /api/v1/webhooks Create a new outbound webhook for the current account. The `secret` is returned only in this response — store it securely. # Delete Webhook Source: https://docs.snackbase.dev/api-reference/endpoints/webhooks/delete-webhook delete /api/v1/webhooks/{webhook_id} Delete a webhook and all its delivery history. # Get Webhook Source: https://docs.snackbase.dev/api-reference/endpoints/webhooks/get-webhook get /api/v1/webhooks/{webhook_id} Get details for a specific webhook. # List Webhook Deliveries Source: https://docs.snackbase.dev/api-reference/endpoints/webhooks/list-deliveries get /api/v1/webhooks/{webhook_id}/deliveries List delivery history for a webhook (paginated). # List Webhooks Source: https://docs.snackbase.dev/api-reference/endpoints/webhooks/list-webhooks get /api/v1/webhooks List all webhooks configured for the current account. # Test Webhook Source: https://docs.snackbase.dev/api-reference/endpoints/webhooks/test-webhook post /api/v1/webhooks/{webhook_id}/test Send a test payload to the webhook URL synchronously and return the result. # Update Webhook Source: https://docs.snackbase.dev/api-reference/endpoints/webhooks/update-webhook put /api/v1/webhooks/{webhook_id} Update a webhook configuration. # Cancel Workflow Instance Source: https://docs.snackbase.dev/api-reference/endpoints/workflows/cancel-workflow-instance post /api/v1/workflow-instances/{instance_id}/cancel Cancel a running or waiting workflow instance. # Create Workflow Source: https://docs.snackbase.dev/api-reference/endpoints/workflows/create-workflow post /api/v1/workflows Create a new workflow for the current account. # Delete Workflow Source: https://docs.snackbase.dev/api-reference/endpoints/workflows/delete-workflow delete /api/v1/workflows/{workflow_id} Delete a workflow and all its instances. # Get Workflow Source: https://docs.snackbase.dev/api-reference/endpoints/workflows/get-workflow get /api/v1/workflows/{workflow_id} Get a single workflow by ID. # Get Workflow Instance Source: https://docs.snackbase.dev/api-reference/endpoints/workflows/get-workflow-instance get /api/v1/workflow-instances/{instance_id} Get a workflow instance with its step logs. # List Workflow Instances Source: https://docs.snackbase.dev/api-reference/endpoints/workflows/list-workflow-instances get /api/v1/workflows/{workflow_id}/instances List instances for a workflow, newest first. # List Workflows Source: https://docs.snackbase.dev/api-reference/endpoints/workflows/list-workflows get /api/v1/workflows List workflows for the current account. # Resume Workflow Instance Source: https://docs.snackbase.dev/api-reference/endpoints/workflows/resume-workflow-instance post /api/v1/workflow-instances/{instance_id}/resume Resume a failed workflow instance from its last step. # Toggle Workflow Source: https://docs.snackbase.dev/api-reference/endpoints/workflows/toggle-workflow patch /api/v1/workflows/{workflow_id}/toggle Toggle a workflow's enabled/disabled state. # Trigger Workflow Source: https://docs.snackbase.dev/api-reference/endpoints/workflows/trigger-workflow post /api/v1/workflows/{workflow_id}/trigger Manually trigger a workflow instance. Works for any trigger type. Accepts an optional JSON body that becomes the trigger context data available via ``{{trigger.*}}`` in step configs. # Update Workflow Source: https://docs.snackbase.dev/api-reference/endpoints/workflows/update-workflow put /api/v1/workflows/{workflow_id} Update a workflow definition. # Trigger Workflow via Webhook Source: https://docs.snackbase.dev/api-reference/endpoints/workflows/workflow-webhook-trigger post /api/v1/workflow-webhooks/{token} Trigger a webhook-type workflow via its secret token. The request body (JSON, if any) is passed as the trigger context. Returns 404 if no matching enabled workflow is found for the token. # API Examples Source: https://docs.snackbase.dev/api-reference/examples Complete guide to using the SnackBase REST API with practical examples Complete guide to using the SnackBase REST API with practical examples. ## Getting Started ### Base URL ``` Development: http://localhost:8000 Production: https://api.yourdomain.com ``` ### API Version All endpoints are prefixed with `/api/v1`: ``` http://localhost:8000/api/v1/auth/register http://localhost:8000/api/v1/collections http://localhost:8000/api/v1/records/posts ``` All record operations use `/api/v1/records/{collection}`, NOT `/api/v1/{collection}`. The `records_router` must be registered LAST in FastAPI to avoid capturing specific routes. ### Interactive Documentation * **Swagger UI**: [http://localhost:8000/docs](http://localhost:8000/docs) * **ReDoc**: [http://localhost:8000/redoc](http://localhost:8000/redoc) * **OpenAPI JSON**: [http://localhost:8000/openapi.json](http://localhost:8000/openapi.json) ### Common Headers ```bash theme={null} Content-Type: application/json Authorization: Bearer X-Correlation-ID: ``` ## Authentication ### 1. Register New Account Create a new account with the first admin user. **Endpoint**: `POST /api/v1/auth/register` **Authentication**: None (public endpoint) **Request**: ```bash theme={null} curl -X POST http://localhost:8000/api/v1/auth/register \ -H "Content-Type: application/json" \ -d '{ "account_name": "Acme Corporation", "account_slug": "acme", "email": "admin@acme.com", "password": "SecurePass123!" }' ``` **Request (Single-Tenant Mode)**: ```bash theme={null} curl -X POST http://localhost:8000/api/v1/auth/register \ -H "Content-Type: application/json" \ -d '{ "email": "admin@acme.com", "password": "SecurePass123!" }' ``` **Response** (201 Created): ```json theme={null} { "message": "Registration successful. Please check your email to verify your account.", "account": { "id": "AB1234", "slug": "acme", "name": "Acme Corporation", "created_at": "2025-12-24T22:00:00Z" }, "user": { "id": "usr_abc123", "email": "admin@acme.com", "role": "admin", "is_active": true, "email_verified": false, "created_at": "2025-12-24T22:00:00Z" } } ``` Registration no longer returns tokens immediately. Email verification is REQUIRED before login. **Password Strength Requirements**: * Minimum 12 characters * At least one uppercase letter (A-Z) * At least one lowercase letter (a-z) * At least one digit (0-9) * At least one special character: `!@#$%^&*()_+\-=\[\]{};':\"\\|,.<>\/?~` ### 2. Login Authenticate with email, password, and account identifier. **Endpoint**: `POST /api/v1/auth/login` **Authentication**: None (public endpoint) **Request**: ```bash theme={null} curl -X POST http://localhost:8000/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{ "account": "acme", "email": "admin@acme.com", "password": "SecurePass123!" }' ``` **Response** (200 OK): ```json theme={null} { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "expires_in": 3600, "account": { "id": "AB1234", "slug": "acme", "name": "Acme Corporation" }, "user": { "id": "usr_abc123", "email": "admin@acme.com", "role": "admin" } } ``` **Account Identifier Options**: * Account slug: `"acme"` * Account ID: `"AB1234"` **Single-Tenant Mode**: The `account` field is optional. If omitted, the default singleton account is used. ### 3. Refresh Token Get a new access token using a refresh token. **Endpoint**: `POST /api/v1/auth/refresh` **Request**: ```bash theme={null} curl -X POST http://localhost:8000/api/v1/auth/refresh \ -H "Content-Type: application/json" \ -d '{ "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }' ``` ### 4. Get Current User Get information about the authenticated user. **Endpoint**: `GET /api/v1/auth/me` **Authentication**: Required ```bash theme={null} curl -X GET http://localhost:8000/api/v1/auth/me \ -H "Authorization: Bearer " ``` ## Records (CRUD) **IMPORTANT**: All record operations use `/api/v1/records/{collection}`. ### Create Record **Endpoint**: \`POST /api/v1/records/\{collection}\`\` ```bash theme={null} curl -X POST http://localhost:8000/api/v1/records/posts \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "title": "Getting Started with SnackBase", "content": "SnackBase is an open-source Backend-as-a-Service...", "published": true }' ``` ### List Records **Endpoint**: `GET /api/v1/records/{collection}` | Parameter | Type | Default | Constraints | | ---------- | ---- | -------------- | -------------------------- | | `skip` | int | 0 | >= 0 | | `limit` | int | 30 | >= 1, \<= 100 | | `sort` | str | "-created\_at" | +/- prefix for asc/desc | | `fields` | str | null | Comma-separated field list | | Field name | any | - | Filter by field value | ```bash theme={null} curl -X GET "http://localhost:8000/api/v1/records/posts?skip=0&limit=10" \ -H "Authorization: Bearer " ``` **Response**: ```json theme={null} { "items": [ { "id": "rec_abc123", "title": "Getting Started with SnackBase", "created_at": "2025-12-24T22:00:00Z" } ], "total": 1, "skip": 0, "limit": 10 } ``` ### Get Single Record **Endpoint**: `GET /api/v1/records/{collection}/{id}` ```bash theme={null} curl -X GET http://localhost:8000/api/v1/records/posts/rec_abc123 \ -H "Authorization: Bearer " ``` ### Update Record (Full) **Endpoint**: `PUT /api/v1/records/{collection}/{id}` ```bash theme={null} curl -X PUT http://localhost:8000/api/v1/records/posts/rec_abc123 \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "title": "Updated Title", "content": "Updated content..." }' ``` ### Update Record (Partial) **Endpoint**: `PATCH /api/v1/records/{collection}/{id}` ```bash theme={null} curl -X PATCH http://localhost:8000/api/v1/records/posts/rec_abc123 \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "views": 150 }' ``` ### Delete Record **Endpoint**: `DELETE /api/v1/records/{collection}/{id}` ```bash theme={null} curl -X DELETE http://localhost:8000/api/v1/records/posts/rec_abc123 \ -H "Authorization: Bearer " ``` ## Collections All collection endpoints require **Superadmin** access. ### Create Collection **Endpoint**: `POST /api/v1/collections/` ```bash theme={null} curl -X POST http://localhost:8000/api/v1/collections/ \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "name": "posts", "schema": [ { "name": "title", "type": "text", "required": true }, { "name": "content", "type": "text", "required": true }, { "name": "published", "type": "boolean", "default": false } ] }' ``` **Field Types**: | Type | Description | | ----------- | ------------------------------------------------------- | | `text` | String values | | `number` | Numeric values (int or float, not bool) | | `boolean` | True/false | | `datetime` | ISO 8601 datetime strings | | `email` | Email addresses (validated) | | `url` | URLs (validated, must start with http\:// or https\://) | | `json` | JSON objects | | `reference` | Foreign key to another collection | ## Roles & Permissions ### Create Role **Endpoint**: `POST /api/v1/roles` ```bash theme={null} curl -X POST http://localhost:8000/api/v1/roles \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "name": "editor", "description": "Can edit but not delete content" }' ``` ### Update Collection Rules **Endpoint**: `PUT /api/v1/collections/{collection_name}/rules` ```bash theme={null} curl -X PUT http://localhost:8000/api/v1/collections/posts/rules \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "list_rule": "true", "view_rule": "true", "create_rule": "@request.auth.id != \"\"", "update_rule": "created_by = @request.auth.id", "delete_rule": "@request.auth.role = \"admin\"", "list_fields": ["id", "title", "status"] }' ``` ### Permission Rule Syntax ```python theme={null} # Always allow "true" # Role checks "@request.auth.role = \"admin\"" # Record ownership "@owns_record()" # Field comparisons "status = \"published\" || status = \"draft\"" # Complex expressions "@request.auth.role = \"admin\" || @owns_record()" ``` **Permission Structure**: ```json theme={null} { "create": {"rule": "true", "fields": ["title", "content"]}, "read": {"rule": "true", "fields": "*"}, "update": {"rule": "@owns_record()", "fields": ["title"]}, "delete": {"rule": "@has_role(\"admin\")", "fields": "*"} } ``` ## Users All users endpoints require **Superadmin** access. ### Create User **Endpoint**: `POST /api/v1/users` ```bash theme={null} curl -X POST http://localhost:8000/api/v1/users \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "email": "user@example.com", "password": "SecurePass123!", "account_id": "AB1234", "role_id": "2" }' ``` ### List Users **Endpoint**: `GET /api/v1/users` | Parameter | Type | Default | Description | | ------------ | ---- | ------- | -------------------------- | | `skip` | int | 0 | Pagination offset | | `limit` | int | 25 | Results per page (max 100) | | `account_id` | str | null | Filter by account | | `role_id` | int | null | Filter by role | | `is_active` | bool | null | Filter by active status | | `search` | str | null | Search in email | ## OAuth Authentication SnackBase supports OAuth 2.0 authentication for popular providers. ### Supported Providers * `google` - Google OAuth 2.0 * `github` - GitHub OAuth App * `microsoft` - Microsoft Azure AD * `apple` - Sign in with Apple ### Initiate OAuth Flow **Endpoint**: `POST /api/v1/auth/oauth/{provider_name}/authorize` ```bash theme={null} curl -X POST http://localhost:8000/api/v1/auth/oauth/google/authorize \ -H "Content-Type: application/json" \ -d '{ "account": "acme", "redirect_uri": "http://localhost:3000/auth/callback", "state": "random_state_string" }' ``` **Response**: ```json theme={null} { "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=...", "state": "random_state_string", "provider": "google" } ``` ## SAML Authentication SnackBase supports SAML 2.0 for enterprise single sign-on (SSO). ### Supported Providers * `azure` - Microsoft Azure AD * `okta` - Okta Identity Cloud * `generic` - Any SAML 2.0 compliant IdP ### Initiate SAML SSO **Endpoint**: `GET /api/v1/auth/saml/sso` ```bash theme={null} curl -X GET "http://localhost:8000/api/v1/auth/saml/sso?account=acme&provider=azure" \ -L ``` **Response**: Redirects to the Identity Provider's login page ### Download SAML Metadata **Endpoint**: `GET /api/v1/auth/saml/metadata` ```bash theme={null} curl -X GET "http://localhost:8000/api/v1/auth/saml/metadata?account=acme&provider=azure" \ -o saml-metadata.xml ``` ## Error Handling ### HTTP Status Codes | Code | Meaning | Example | | ---- | --------------------- | -------------------------- | | 200 | OK | Successful GET, PUT, PATCH | | 201 | Created | Successful POST | | 204 | No Content | Successful DELETE | | 400 | Bad Request | Validation error | | 401 | Unauthorized | Missing or invalid token | | 403 | Forbidden | Insufficient permissions | | 404 | Not Found | Resource doesn't exist | | 409 | Conflict | Duplicate resource | | 422 | Unprocessable Entity | Invalid data format | | 500 | Internal Server Error | Server error | ### Error Response Format ```json theme={null} { "error": "Error type", "message": "Error message describing what went wrong" } ``` ### Validation Error Response ```json theme={null} { "error": "Validation error", "details": [ { "field": "password", "message": "Password must be at least 12 characters...", "code": "password_too_weak" } ] } ``` ## Best Practices ### 1. Always Use HTTPS in Production ```bash theme={null} # Bad (production) http://api.yourdomain.com/api/v1/records/posts # Good (production) https://api.yourdomain.com/api/v1/records/posts ``` ### 2. Store Tokens Securely ```javascript theme={null} // Bad - localStorage is vulnerable to XSS localStorage.setItem("token", token); // Good - httpOnly cookie (server-side) ``` ### 3. Handle Token Expiration ```javascript theme={null} async function makeRequest(url) { let token = getAccessToken(); if (isTokenExpiringSoon(token)) { token = await refreshAccessToken(); } return fetch(url, { headers: { Authorization: `Bearer ${token}` }, }); } ``` ### 4. Use Pagination for Large Datasets ```bash theme={null} # Bad - fetching too many records curl -X GET http://localhost:8000/api/v1/records/posts?limit=1000 # Good - paginate results curl -X GET "http://localhost:8000/api/v1/records/posts?skip=0&limit=30" ``` ### 5. Use Field Limiting ```bash theme={null} # Good - limit to needed fields curl -X GET "http://localhost:8000/api/v1/records/posts?fields=id,title,created_at" ``` ### 6. Remember Route Registration Order The `records_router` MUST be registered LAST in your FastAPI app to prevent capturing specific routes. ```python theme={null} # Correct order in app.py app.include_router(invitations_router, prefix="/api/v1/invitations") app.include_router(collections_router, prefix="/api/v1/collections") app.include_router(accounts_router, prefix="/api/v1/accounts") # ... all other specific routers ... app.include_router(records_router, prefix="/api/v1/records") # MUST BE LAST ``` ## Related Guides * [Writing Rules](./writing-rules) * [Security Model](/concepts/security) * [OAuth Overview](./oauth-overview) # API Reference Source: https://docs.snackbase.dev/api-reference/introduction Interactive API documentation for SnackBase ## SnackBase API Complete interactive reference for all SnackBase API endpoints. Explore endpoints, test requests, and view responses directly from your browser. Use the API playground on the right to test requests. You can switch between development and production environments using the selector above. ### Base URL | Environment | URL | | ----------- | --------------------------- | | Development | `http://localhost:8000` | | Production | `https://api.snackbase.dev` | ### Authentication Most endpoints require authentication using a Bearer token: ```bash theme={null} Authorization: Bearer ``` ### Response Format All responses return JSON: ```json theme={null} { "data": {}, "message": "Success" } ``` ### Error Responses Errors follow this format: ```json theme={null} { "error": "Error type", "message": "Detailed error message" } ``` ### Quick Links * [Authentication](/api-reference/auth) - Login, register, OAuth, SAML * [Accounts & Users](/api-reference/accounts) - Manage accounts and users * [Collections](/api-reference/collections) - Create and manage collections * [Records](/api-reference/records) - CRUD operations for records * [Roles](/api-reference/roles) - Role-based access control * [Permissions](/api-reference/permissions) - Fine-grained permissions # Architecture Source: https://docs.snackbase.dev/architecture Complete architecture overview of SnackBase's clean architecture design ## Architecture Overview SnackBase follows **Clean Architecture** principles with clear separation between business logic and infrastructure concerns. ### Layer Structure | Layer | Purpose | Dependencies | | ------------------------ | -------------------------- | ---------------------------- | | **Frontend** | React admin UI | API Layer (HTTP) | | **API Layer** | FastAPI routes, middleware | Domain, Core, Infrastructure | | **Core Layer** | Cross-cutting concerns | Zero framework deps | | **Domain Layer** | Business logic, entities | Core only | | **Application Layer** | Use cases (placeholder) | Domain | | **Infrastructure Layer** | External concerns | Domain, Core | ### Key Architectural Patterns 1. **Repository Pattern**: 17 repositories abstract data access 2. **Service Layer Pattern**: 17 domain services contain business logic 3. **Hook System**: 33+ events across 8 categories for extensibility (stable API v1.0) 4. **Rule Engine**: Custom DSL for permission expressions 5. **Multi-Tenancy**: Row-level isolation via `account_id` 6. **JWT Authentication**: Access token (1h) + refresh token (7d) 7. **Configuration System**: Hierarchical provider configuration with encryption at rest 8. **Email System**: Multi-provider email with template rendering ### Component Statistics * **19 API Routers**: auth, oauth, saml, accounts, collections, roles, permissions, users, groups, invitations, macros, dashboard, files, audit-logs, migrations, admin, email\_templates, records, health * **17 ORM Models**: Account, User, Role, Permission, Collection, Macro, Group, Invitation, RefreshToken, UsersGroups, AuditLog, Configuration, OAuthState, EmailVerification, EmailTemplate, EmailLog * **17 Repositories** matching each model * **17 Domain Entities** + **17 Domain Services** * **10 React Pages** + **40+ Components** * **14 ShadCN UI Components** ### Technology Stack | Category | Technology | | ---------- | --------------------------------------------- | | Backend | Python 3.12+, FastAPI, SQLAlchemy 2.0 (async) | | Database | SQLite (dev), PostgreSQL (prod) with JSONB | | Frontend | React 19, TypeScript, Vite 7, React Router v7 | | UI | TailwindCSS 4, Radix UI, ShadCN, Lucide Icons | | State | Zustand, TanStack Query | | Auth | JWT (HS256), Argon2id password hashing | | Logging | structlog (JSON in production) | | Validation | Pydantic, Zod | | Templates | Jinja2 for email templates | | Crypto | cryptography (Fernet) for config encryption | | OAuth | Authlib for OAuth 2.0 flow | | SAML | python3-saml for SAML SSO | ## Major Systems ### 1. Configuration/Provider System The configuration system provides hierarchical provider configuration for external services (authentication, email, storage). **Architecture:** * **System-level configs**: Use account\_id `00000000-0000-0000-0000-000000000000` for defaults * **Account-level configs**: Per-account overrides that take precedence * **Encryption at rest**: All sensitive values encrypted using Fernet symmetric encryption * **5-minute TTL cache**: ConfigRegistry caches resolved configurations **Built-in Providers (12):** | Category | Providers | | ------------------- | -------------------------------- | | **Auth Providers** | Email/Password | | **Email Providers** | SMTP, AWS SES, Resend | | **OAuth Providers** | Google, GitHub, Microsoft, Apple | | **SAML Providers** | Okta, Azure AD, Generic SAML | | **System** | System Configuration | ### 2. Email Verification System Handles email address verification with secure token-based workflow. **Components:** * `EmailVerificationTokenModel` - Stores SHA-256 hashed tokens * `EmailVerificationRepository` - Database operations * `EmailVerificationService` - Business logic for verification workflow * Token expiration: 24 hours * Single-use tokens (marked as used after verification) **Flow:** 1. User registers → `send_verification_email()` generates token 2. Token stored as SHA-256 hash 3. Email sent with verification URL 4. User clicks link → `verify_email()` validates token 5. User record updated: `email_verified=True`, `email_verified_at=now()` ### 3. Email Template System Multi-language email template system with Jinja2 variable support. **Components:** * `EmailTemplateModel` - ORM model with locale support * `EmailTemplateRepository` - Template CRUD operations * `TemplateRenderer` - Jinja2-based rendering * `EmailService` - Orchestrates sending with provider selection **Template Types:** * `email_verification` - Email verification emails * `password_reset` - Password reset emails * `invitation` - User invitation emails **Features:** * Account-level templates override system defaults * Multi-language support via `locale` field * System variables injected: `app_name`, `app_url`, `support_email` * Comprehensive logging via `EmailLogModel` ### 4. Hook System (Stable API v1.0) **33+ Hook Events across 8 Categories:** | Category | Events | | ----------------------------- | -------------------------------------------------------------- | | **App Lifecycle** (3) | `on_bootstrap`, `on_serve`, `on_terminate` | | **Model Operations** (6) | `on_model_before/after_create/update/delete` | | **Record Operations** (8) | `on_record_before/after_create/update/delete/query` | | **Collection Operations** (6) | `on_collection_before/after_create/update/delete` | | **Auth Operations** (8) | `on_auth_before/after_login/logout/register/password_reset` | | **Request Processing** (2) | `on_before_request`, `on_after_request` | | **Realtime** (4) | `on_realtime_connect/disconnect/message/subscribe/unsubscribe` | | **Mailer** (2) | `on_mailer_before/after_send` | **Built-in Hooks:** * `timestamp_hook` (priority: -100) - Sets `created_at`/`updated_at` * `account_isolation_hook` (priority: -200) - Enforces `account_id` on records * `created_by_hook` (priority: -150) - Sets `created_by`/`updated_by` * `audit_capture_hook` (priority: 100) - Captures audit trails for records * **SQLAlchemy Event Listeners** - Systemic audit logging for models ### 5. Audit Logging System GxP-compliant audit logging with blockchain-style integrity chain. **Features:** * **Configurable**: Toggle via `SNACKBASE_AUDIT_LOGGING_ENABLED` (default: `true`) * **Column-level granularity**: Each row represents a single column change * **Immutable**: Database triggers prevent UPDATE/DELETE operations * **Blockchain integrity**: `checksum` and `previous_hash` chain * **Electronic signature support**: CFR Part 11 compliant (`es_username`, `es_reason`, `es_timestamp`) * **Systemic capture**: SQLAlchemy event listeners automatically log all model changes * **Record capture**: Hooks automatically log all dynamic collection record changes **Audit Flow:** 1. SQLAlchemy event listener detects model change OR hook detects record change 2. `AuditLogService` creates audit entries for each changed column 3. `AuditChecksum` computes SHA-256 hash linking to previous entry 4. Entries written atomically with the operation 5. Database triggers enforce immutability ## Data Flow Examples ### Authentication Flow ``` LoginPage → auth.service.login() → POST /api/v1/auth/login → JWT Service creates tokens → UserRepository updates last_login → Return AuthResponse → Zustand Store stores tokens ``` ### Permission Check Flow ``` GET /api/v1/records/posts → Authorization Middleware → PermissionResolver.resolve_permission() → Rule Engine: parse_rule() → Lexer → Parser → AST → Evaluator.evaluate() with MacroExecutionEngine → PermissionCache (5-min TTL) → If allowed: RecordRepository.find_all() → PIIMaskingService masks sensitive fields → Return filtered response ``` ### Record Creation Flow ``` POST /api/v1/records/posts → Validate request fields → Permission check → RecordValidator.validate_and_apply_defaults() → Trigger ON_RECORD_BEFORE_CREATE hooks → account_isolation_hook (priority: -200) → created_by_hook (priority: -150) → timestamp_hook (priority: -100) → User hooks (priority: >=0) → RecordRepository.insert_record() → Trigger ON_RECORD_AFTER_CREATE hooks → audit_capture_hook (priority: 100) → Apply field filter + PII masking → Return RecordResponse ``` ### Email Sending Flow ``` EmailService.send_template_email() → EmailTemplateRepository.get_template() → Check account-level template → Fallback to system-level template → TemplateRenderer.render() with Jinja2 → Merge system variables + user variables → ConfigurationRepository.list_configs() → Check account-level email provider → Fallback to system-level provider → Decrypt config with EncryptionService → Provider.send_email() (SMTP/SES/Resend) → EmailLogRepository.create() log entry → Commit transaction atomically ``` ## Key Files | File | Purpose | | ------------------------------------------------------------- | ---------------------------------------- | | `src/snackbase/infrastructure/api/app.py` | FastAPI app factory | | `src/snackbase/core/config.py` | Pydantic Settings | | `src/snackbase/core/hooks/hook_registry.py` | Hook system core | | `src/snackbase/core/configuration/config_registry.py` | Configuration registry | | `src/snackbase/core/rules/` | Rule engine (lexer→parser→AST→evaluator) | | `src/snackbase/domain/services/permission_resolver.py` | Permission resolution | | `src/snackbase/domain/services/email_verification_service.py` | Email verification logic | | `src/snackbase/domain/services/audit_log_service.py` | Audit logging service | | `src/snackbase/infrastructure/persistence/database.py` | SQLAlchemy engine | | `src/snackbase/infrastructure/persistence/table_builder.py` | Dynamic table creation | | `src/snackbase/infrastructure/services/email_service.py` | Email sending with templates | | `src/snackbase/infrastructure/hooks/builtin_hooks.py` | Built-in hook implementations | | `ui/src/main.tsx` | React app entry | | `ui/src/App.tsx` | Route configuration | | `ui/src/lib/api.ts` | Axios client with token refresh | ## Related Guides * [Hook System Reference](../hooks) * [Creating Custom Hooks](./creating-custom-hooks) * [Security Model](/concepts/security) * [Authentication Model](/concepts/authentication) # API-Defined Hooks Source: https://docs.snackbase.dev/concepts/api-hooks Event-driven automation configured via the REST API -- no code required **API-Defined Hooks** let you automate actions in response to events, on a schedule, or on demand -- all configured through the REST API or Admin UI without writing backend code. This page covers **API-Defined Hooks** -- hooks managed via the REST API with event, schedule, and manual triggers. For the **code-level Python hook system** used to extend SnackBase internals, see the [Hook System Reference](/hooks). ## Overview An API-defined hook consists of three parts: 1. **Trigger** -- When the hook fires (event, schedule, or manual) 2. **Condition** -- An optional rule expression that gates execution 3. **Actions** -- An ordered list of operations to perform ### Key Features * **Three Trigger Types**: Event-driven, cron-scheduled, or manual * **Conditional Execution**: Gate hooks with rule expressions * **Action Pipelines**: Chain multiple actions in sequence * **Execution Logging**: Full history with status, duration, and errors * **Enable/Disable Toggle**: Pause hooks without deleting them ## Trigger Types ### Event Triggers Fire when a specific event occurs in your application: ```json theme={null} { "type": "event", "event": "records.create", "collection": "orders" } ``` **Supported events:** | Event | Fires When | | ---------------- | -------------------- | | `records.create` | A record is created | | `records.update` | A record is updated | | `records.delete` | A record is deleted | | `auth.login` | A user logs in | | `auth.register` | A new user registers | The `collection` field is optional -- omit it to match events across all collections. ### Schedule Triggers Fire on a cron schedule: ```json theme={null} { "type": "schedule", "cron": "0 9 * * MON" } ``` Standard 5-field cron syntax is supported: ``` ┌───────────── minute (0-59) │ ┌───────────── hour (0-23) │ │ ┌───────────── day of month (1-31) │ │ │ ┌───────────── month (1-12) │ │ │ │ ┌───────────── day of week (0-6, 0=Sunday) │ │ │ │ │ * * * * * ``` When a scheduled hook is enabled, `next_run_at` is automatically calculated. Disabling clears it; re-enabling recalculates it. ### Manual Triggers Fire only when explicitly triggered via the API: ```json theme={null} { "type": "manual" } ``` Trigger manually: ```bash theme={null} curl -X POST https://api.snackbase.dev/api/v1/hooks/{hook_id}/trigger \ -H "Authorization: Bearer {token}" ``` ## Condition Expressions Add an optional `condition` to gate hook execution. The hook only fires if the expression evaluates to `true`: ```json theme={null} { "trigger": { "type": "event", "event": "records.create", "collection": "orders" }, "condition": "total >= 100" } ``` Conditions use the same rule expression syntax as [collection rules](/permissions) and [webhook filters](/concepts/webhooks). If a condition expression fails to evaluate, the hook fires anyway (fail-open design). ## Actions Actions are executed sequentially. If an action fails, execution stops and the hook is logged with `partial` status. ### Supported Action Types | Action Type | Description | | --------------- | --------------------------------------------- | | `send_webhook` | Send an HTTP request to an external URL | | `send_email` | Send an email (via configured email provider) | | `create_record` | Create a record in a collection | | `update_record` | Update an existing record | | `delete_record` | Delete a record | | `enqueue_job` | Enqueue a background job | ### Template Variables Action configurations support template variables that resolve at execution time: | Variable | Description | | ------------------ | -------------------------------------- | | `{{record.field}}` | Field value from the triggering record | | `{{auth.user_id}}` | User ID from the execution context | | `{{auth.email}}` | User email from the execution context | | `{{now}}` | Current UTC timestamp (ISO 8601) | **Example** -- send a webhook when a high-value order is created: ```json theme={null} { "name": "Notify on large orders", "trigger": { "type": "event", "event": "records.create", "collection": "orders" }, "condition": "total >= 500", "actions": [ { "type": "send_webhook", "config": { "url": "https://slack.example.com/webhook", "body_template": { "text": "New order #{{record.id}} for ${{record.total}} from {{record.customer_name}}" } } } ] } ``` ## Execution Logging Every hook execution is logged with: | Field | Description | | ------------------- | ------------------------------------------------------------------- | | `trigger_type` | `event`, `schedule`, or `manual` | | `status` | `success`, `failed`, or `partial` | | `actions_executed` | Number of actions completed | | `error_message` | Error details (if failed/partial) | | `duration_ms` | Execution time in milliseconds | | `execution_context` | Snapshot of triggering context (event, account, record, collection) | | `executed_at` | Timestamp | ### Execution Statuses | Status | Meaning | | --------- | ---------------------------------------- | | `success` | All actions executed without error | | `failed` | Condition not met or first action failed | | `partial` | Some actions completed before a failure | ## Limits | Limit | Default | | -------------------------- | ---------------------------- | | Max hooks per account | 50 (configurable) | | Max action execution depth | 5 (prevents recursive loops) | ## Comparison: Hooks vs Webhooks vs Workflows | Feature | API-Defined Hooks | Outbound Webhooks | Workflows | | ---------------------- | ----------------------- | ---------------------- | ---------------------------------- | | **Trigger types** | Event, schedule, manual | Record events only | Event, schedule, manual, webhook | | **Actions** | Multiple, sequenced | Single HTTP POST | Multi-step with branching | | **Conditions** | Rule expressions | Filter expressions | Per-step conditions | | **Delays/Waits** | No | No | Yes (wait\_delay, wait\_condition) | | **Branching** | No | No | Yes (condition steps) | | **Parallel execution** | No | No | Yes (parallel steps) | | **Best for** | Simple automations | External notifications | Complex multi-step processes | # Authentication Model Source: https://docs.snackbase.dev/concepts/authentication Multi-tenant authentication flows, token management, API keys, OAuth/SAML integration SnackBase provides a comprehensive authentication system designed for multi-tenant applications. This guide explains authentication flows, token management, API keys, multi-account users, email verification, OAuth/SAML integration, and security considerations. ## Overview SnackBase authentication is built for **enterprise multi-account scenarios**: | Feature | Description | | -------------------------- | ----------------------------------------------------- | | **Account-Scoped Users** | Users belong to specific accounts | | **Multi-Account Support** | Same email can exist in multiple accounts | | **Per-Account Passwords** | Different passwords per (email, account) tuple | | **JWT Tokens** | Access tokens (1 hour) + Refresh tokens (7 days) | | **Token Rotation** | Refresh token rotation on each use with revocation | | **API Keys** | Service authentication with hashed keys | | **Email Verification** | Required for login, tokens expire in 1 hour | | **Multi-Provider** | Support for Password, OAuth, and SAML providers | | **Identity Linking** | Link local accounts with external provider identities | | **Timing-Safe Comparison** | Password verification resistant to timing attacks | | **Hierarchical Config** | System-level and account-level provider settings | ## User Identity Model In SnackBase, a user's identity is defined by a **tuple**: ``` (email, account_id) = unique user identity ``` This means: * `alice@acme.com` in account `AB1001` = User Identity #1 * `alice@acme.com` in account `XY2048` = User Identity #2 * These are **different users** with different passwords ## Account Registration Account registration creates a new tenant/workspace in SnackBase. ### Account ID Format Accounts use **two identifiers**: ```python theme={null} # Example account { "id": "550e8400-e29b-41d4-a716-446655440000", # UUID (primary key) "account_code": "AB1001", # Human-readable code "slug": "acme-corp", # URL-friendly identifier "name": "Acme Corp" # Display name } ``` **Properties:** * **id (UUID)**: Primary key, immutable, globally unique * **account\_code (XX####)**: Human-readable format for display * Format: 2 letters + 4 digits (e.g., AB1001, XY2048) * Sequential generation for easy reference * Used in UI and exports * **slug**: URL-friendly identifier for login * **name**: Display name (not unique) ## User Registration User registration creates a new user within a specific account. ### Registration Flow ``` ┌──────────────┐ │ User fills │ │ registration │ │ form │ └──────┬───────┘ │ ▼ ┌─────────────────────────────────┐ │ POST /api/v1/auth/register │ │ { │ │ "account": "acme-corp", │ │ "email": "alice@acme.com", │ │ "password": "SecurePass123!", │ │ "name": "Alice Johnson" │ │ } │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ 1. Resolve account by slug │ │ "acme-corp" → account_id │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ 2. Validate email uniqueness │ │ (within account) │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ 3. Validate password strength │ │ - Min 8 chars │ │ - Uppercase, lowercase │ │ - Number, special char │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ 4. Hash password (Argon2id) │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ 5. Create user record │ │ - id: user_abc123 │ │ - account_id: │ │ - email: alice@acme.com │ │ - password_hash: │ │ - email_verified: false │ │ - auth_provider: "password" │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ 6. Generate verification token │ │ - SHA-256 hash │ │ - 1 hour expiration │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ 7. Send verification email │ │ To: alice@acme.com │ └─────────────────────────────────┘ ``` ### Email Uniqueness Email uniqueness is **scoped to account**: ``` ✅ ALLOWED: Account AB1001: alice@acme.com Account XY2048: alice@acme.com (Same email, different account) ❌ NOT ALLOWED: Account AB1001: alice@acme.com Account AB1001: alice@acme.com (Duplicate within account) ``` ## Email Verification Email verification is **required** before users can log in to their accounts. ### Verification Flow ``` ┌─────────────────────────────────┐ │ User completes registration │ │ Account created │ │ email_verified: false │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ System generates verification │ │ token (random 32-byte string) │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ Token hashed with SHA-256 │ │ Stored in email_verifications │ │ - token_hash: │ │ - expires_at: now() + 1 hour │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ Verification email sent │ │ Subject: Verify your email │ │ Contains verification link │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ User clicks link │ │ GET /auth/verify-email?token=...│ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ 1. Hash provided token │ │ SHA-256(token) │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ 2. Lookup token_hash in DB │ │ Check not expired │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ 3. Update user record │ │ - email_verified: true │ │ - email_verified_at: now() │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ 4. Delete verification token │ │ (single-use only) │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ 5. Return success response │ │ User can now login │ └─────────────────────────────────┘ ``` ### Verification Token Model ```python theme={null} # Email Verification Token { "id": "ev_abc123", "user_id": "user_xyz789", "token_hash": "a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e", # SHA-256 "expires_at": "2025-01-01T01:00:00Z", # 1 hour from creation "created_at": "2025-01-01T00:00:00Z" } ``` **Security Properties:** * Tokens are **hashed** with SHA-256 before storage (never stored in plaintext) * Tokens **expire** after 1 hour * Tokens are **single-use** (deleted after verification) * Token hash uses **constant-time comparison** to prevent timing attacks ### Login Requirement Users **cannot login** until their email is verified: ```python theme={null} # Login check if not user.email_verified: raise HTTPException( status_code=401, detail="Email not verified. Please check your inbox." ) ``` ## Login Flow Login authenticates a user and issues JWT tokens. ### Login Process ``` ┌──────────────┐ │ User enters │ │ credentials: │ │ - account │ │ - email │ │ - password │ └──────┬───────┘ │ ▼ ┌─────────────────────────────────┐ │ POST /api/v1/auth/login │ │ { │ │ "account": "acme-corp", │ │ "email": "alice@acme.com", │ │ "password": "SecurePass123!" │ │ } │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ 1. Resolve account by slug │ │ "acme-corp" → account_id │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ 2. Find user by (email, account)│ │ WHERE email = ? │ │ AND account_id = ? │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ 3. Check email verification │ │ if not verified: 401 Error │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ 4. Timing-safe password verify │ │ argon2.verify(password_hash, │ │ provided_password)│ │ (uses dummy hash if no user) │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ 5. Generate tokens │ │ - Access token (1 hour) │ │ - Refresh token (7 days) │ │ - Store refresh token hash │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ 6. Return tokens │ │ { │ │ "access_token": "...", │ │ "refresh_token": "...", │ │ "token_type": "bearer", │ │ "user": { ... } │ │ } │ └─────────────────────────────────┘ ``` ### Timing-Safe Password Comparison SnackBase uses **timing-safe comparison** to prevent timing attacks: ```python theme={null} # ❌ VULNERABLE: Regular comparison (timing leak) if user.password_hash == provided_password: # Attacker can measure time to guess password # ✅ SECURE: Timing-safe comparison # Also uses dummy hash for non-existent users if argon2.verify(user.password_hash, provided_password): # Constant time regardless of match ``` ## Token Management SnackBase uses **JWT (JSON Web Tokens)** with access and refresh tokens, with true token rotation for enhanced security. ### Token Types | Token Type | Lifetime | Purpose | Storage | Database | | ----------------- | -------- | -------------------- | ------------------------------- | -------- | | **Access Token** | 1 hour | API requests | localStorage/memory | No | | **Refresh Token** | 7 days | Get new access token | HttpOnly cookie or localStorage | Yes | ### Access Token Structure ```json theme={null} { "sub": "user_abc123", // Subject (user ID) "account_id": "550e8400-...", // Account context (UUID) "email": "alice@acme.com", // User email "role": "admin", // User role "exp": 1704067200, // Expiration timestamp "iat": 1704063600 // Issued at timestamp } ``` ### Refresh Token Structure ```json theme={null} { "sub": "user_abc123", // Subject (user ID) "account_id": "550e8400-...", // Account context (UUID) "jti": "token_xyz789", // JWT ID (unique token identifier) "exp": 1704668400, // Expiration timestamp (7 days) "iat": 1704063600 // Issued at timestamp } ``` **The `jti` (JWT ID) claim** uniquely identifies each refresh token and is used to track revocation. ### Token Refresh with Rotation ```bash theme={null} # Refresh access token POST /api/v1/auth/refresh Content-Type: application/json { "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } # Response { "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", // NEW! "token_type": "bearer" } ``` **True Token Rotation:** 1. Old refresh token is marked as **revoked** in database 2. New refresh token is generated and stored (hash) 3. Old token cannot be used again (returns 401 if attempted) 4. Each refresh creates a new token in the chain ## OAuth 2.0 Authentication SnackBase supports OAuth 2.0 / OpenID Connect authentication for popular social and enterprise identity providers. ### Supported OAuth Providers | Provider | Description | | ------------- | -------------------------- | | **Google** | Google Account login | | **GitHub** | GitHub account login | | **Microsoft** | Microsoft / Azure AD login | | **Apple** | Sign in with Apple | ### OAuth Flow ``` ┌──────────────┐ │ User clicks │ │ "Login with │ │ Google" │ └──────┬───────┘ │ ▼ ┌─────────────────────────────────┐ │ GET /oauth/google/authorize │ │ ?account=acme-corp │ │ &client_state=abc123 │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ 1. Generate state token │ │ 2. Encode RelayState │ │ 3. Redirect to Google │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ User authenticates │ │ with Google │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ Google redirects back │ │ GET /oauth/google/callback? │ │ code=...& │ │ state=...& │ │ relay_state=... │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ 1. Verify state token │ │ 2. Decode RelayState │ │ 3. Exchange code for tokens │ │ 4. Get user info │ │ 5. Find or create user │ │ 6. Update user record │ │ 7. Generate JWT tokens │ │ 8. Redirect to client app │ └─────────────────────────────────┘ ``` ## SAML 2.0 Authentication SnackBase supports SAML 2.0 for enterprise single sign-on (SSO) with identity providers like Okta, Azure AD, and other SAML-compliant systems. ### Supported SAML Providers | Provider | Description | | ------------ | -------------------------------- | | **Okta** | Okta Identity Cloud SSO | | **Azure AD** | Microsoft Azure Active Directory | | **Generic** | Any SAML 2.0 compliant IdP | ### SAML Flow ``` ┌──────────────┐ │ User clicks │ │ "Login with │ │ SSO" │ └──────┬───────┘ │ ▼ ┌─────────────────────────────────┐ │ GET /saml/{provider}/sso │ │ ?account=acme-corp │ │ &client_state=abc123 │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ 1. Resolve SAML config │ │ 2. Generate SAML request │ │ 3. Encode RelayState │ │ 4. Redirect to IdP │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ User authenticates │ │ with IdP (e.g., Okta) │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ IdP posts SAML response │ │ POST /saml/{provider}/acs │ │ - SAMLResponse= │ │ - RelayState= │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ 1. Decode RelayState │ │ 2. Verify SAML response │ │ 3. Extract user attributes │ │ 4. Find or create user │ │ 5. Update user record │ │ 6. Generate JWT tokens │ │ 7. Redirect to client app │ └─────────────────────────────────┘ ``` ## Multi-Account Users SnackBase supports **enterprise multi-account scenarios** where users can belong to multiple accounts. ### User Identity Matrix ``` ┌────────────────────┬──────────────┬──────────────┬──────────────┐ │ email │ account_id │ password │ role │ ├────────────────────┼──────────────┼──────────────┼──────────────┤ │ alice@acme.com │ 550e8400-... │ Password1! │ admin │ │ alice@acme.com │ 660e8400-... │ Password2! │ viewer │ │ bob@acme.com │ 550e8400-... │ Password3! │ editor │ │ jane@globex.com │ 660e8400-... │ Password4! │ admin │ └────────────────────┴──────────────┴──────────────┴──────────────┘ ``` **Key Points:** * Same email can exist in multiple accounts * Each `(email, account_id)` tuple has a unique password * Users must specify account when logging in ### Login with Account Selection When logging in, users must specify which account they're accessing: **Option 1: Account in URL (subdomain)** ``` POST https://acme-corp.snackbase.dev/api/v1/auth/login { "email": "alice@acme.com", "password": "Password1!" } ``` **Option 2: Account in Request Body** ``` POST https://snackbase.dev/api/v1/auth/login { "account": "acme-corp", // Account slug "email": "alice@acme.com", "password": "Password1!" } ``` ## API Key Authentication API keys provide an alternative authentication method designed for service-to-service communication, CLI tools, and integrations where JWT token management is impractical. ### When to Use API Keys | Use Case | Recommended Method | Reason | | ------------------------ | --------------------------- | ----------------------------------------------- | | Browser applications | JWT (access/refresh tokens) | Token rotation, user session management | | Mobile apps | JWT (access/refresh tokens) | Built-in token refresh, user experience | | Service-to-service calls | **API Keys** | No token refresh needed, long-lived credentials | | CLI tools | **API Keys** | Easy configuration, no session management | | Webhooks | **API Keys** | Static credentials for incoming requests | | Third-party integrations | **API Keys** | Simple credential sharing | | IoT devices | **API Keys** | Limited token handling capabilities | ### API Key Format API keys follow a structured format: ``` sb_sk__ ``` **Example**: `sb_sk_AB1234_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6` **Components:** * `sb_sk` - SnackBase Secret Key prefix (identifies the key type) * `AB1234` - Account code (human-readable account identifier) * `a1b2c3...o5p6` - 32-character cryptographically secure random string ### API Key Authentication Flow ``` ┌──────────────┐ │ Admin creates │ │ API key via │ │ UI or API │ └──────┬───────┘ │ ▼ ┌─────────────────────────────────┐ │ POST /api/v1/api-keys/ │ │ { │ │ "name": "Production Service", │ │ "description": "Backend API" │ │ } │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ System generates API key: │ │ - Format: sb_sk__ │ │ - Store SHA-256 hash in DB │ │ - Return full key (ONCE ONLY) │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ Admin stores key securely: │ │ - Environment variable │ │ - Secret manager (Vault, AWS) │ │ - Configuration file (secured) │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ Service uses API key: │ │ Authorization: Bearer sb_sk_... │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ Server validates: │ │ 1. Extract key from header │ │ 2. Hash with SHA-256 │ │ 3. Lookup hash in database │ │ 4. Check not revoked │ │ 5. Get associated user/account │ └──────┬──────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ Request processed with user │ │ context from API key │ └─────────────────────────────────┘ ``` ### API Key Data Model ```python theme={null} # API Key in Database { "id": "ak_abc123xyz", # Key ID (public) "key_hash": "", # Hashed API key (never plaintext) "account_id": "550e8400-...", # Associated account "account_code": "AB1234", # Human-readable account code "created_by": "usr_abc123", # User who created the key "name": "Production Service", # Human-readable name "description": "Backend API for prod", # Optional description "last_used_at": "2026-01-17T15:45:00Z", # Last authentication timestamp "is_revoked": false, # Revocation status "created_at": "2026-01-01T00:00:00Z", "revoked_at": null, # Set when revoked "revoked_by": null # User who revoked it } ``` **Security Properties:** * **SHA-256 Hashing**: Keys are hashed before storage (plaintext never persisted) * **Single-User Association**: Each key is linked to one user in one account * **Immediate Revocation**: Keys can be revoked instantly * **Audit Trail**: Tracks creation, last used, and revocation * **Account Scoping**: Keys are automatically scoped to their account ### Authentication Comparison | Feature | API Keys | JWT Tokens | | -------------------- | --------------------------------- | --------------------------------- | | **Use Case** | Service-to-service, CLI, webhooks | Browser, mobile apps | | **Lifetime** | Indefinite (until revoked) | Access: 1 hour, Refresh: 7 days | | **Storage** | Server-side (hash) | Client-side (localStorage/cookie) | | **Visibility** | Full key shown only at creation | Tokens visible in responses | | **Rotation** | Manual (create new, revoke old) | Automatic (on refresh) | | **Revocation** | Immediate | On refresh or logout | | **User Context** | Single user per key | Can include user, role, account | | **Security** | SHA-256 hashed at rest | Signed, verifiable signature | | **Token Management** | Not applicable | Required (refresh mechanism) | | **Session Support** | None (stateless) | Yes (with refresh tokens) | ### Creating API Keys **Via API**: ```bash theme={null} # Create API key curl -X POST http://localhost:8000/api/v1/api-keys/ \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "name": "Production Service", "description": "Backend API for production environment" }' # Response (only time full key is shown) { "id": "ak_abc123xyz", "name": "Production Service", "description": "Backend API for production environment", "key": "sb_sk_AB1234_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", # SAVE THIS! "account_id": "AB1234", "created_at": "2026-01-17T10:30:00Z" } ``` **Via Admin UI**: 1. Navigate to Settings → API Keys 2. Click "Create API Key" 3. Enter name and description 4. Copy the displayed key (shown only once) 5. Store securely in your application ### Using API Keys API keys use the standard `Authorization: Bearer` header: ```bash theme={null} # Using API key for authentication curl -X GET http://localhost:8000/api/v1/posts \ -H "Authorization: Bearer sb_sk_AB1234_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" # Creating a record curl -X POST http://localhost:8000/api/v1/posts \ -H "Authorization: Bearer sb_sk_AB1234_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" \ -H "Content-Type: application/json" \ -d '{ "title": "Hello World", "content": "Created with API key" }' ``` **In Code**: ```python theme={null} import requests # Configure API key API_KEY = "sb_sk_AB1234_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" headers = {"Authorization": f"Bearer {API_KEY}"} # Make requests response = requests.get( "http://localhost:8000/api/v1/posts", headers=headers ) ``` ```javascript theme={null} // Using fetch const API_KEY = "sb_sk_AB1234_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"; const response = await fetch("http://localhost:8000/api/v1/posts", { headers: { "Authorization": `Bearer ${API_KEY}` } }); ``` ### Managing API Keys **List API Keys**: ```bash theme={null} curl -X GET http://localhost:8000/api/v1/api-keys/ \ -H "Authorization: Bearer " ``` **Response** (metadata only, no full keys): ```json theme={null} { "items": [ { "id": "ak_abc123xyz", "name": "Production Service", "description": "Backend API", "created_at": "2026-01-17T10:30:00Z", "last_used_at": "2026-01-17T15:45:00Z", "is_revoked": false } ] } ``` **Revoke API Key**: ```bash theme={null} curl -X POST http://localhost:8000/api/v1/api-keys/ak_abc123xyz/revoke \ -H "Authorization: Bearer " ``` **Get Key Details**: ```bash theme={null} curl -X GET http://localhost:8000/api/v1/api-keys/ak_abc123xyz \ -H "Authorization: Bearer " ``` ### Security Best Practices **1. Secure Storage**: ```python theme={null} # ✅ Good: Environment variable import os API_KEY = os.environ.get("SNACKBASE_API_KEY") # ✅ Good: Secret manager import boto3 client = boto3.client('secretsmanager') response = client.get_secret_value(SecretId='snackbase/api_key') API_KEY = response['SecretString'] # ❌ Bad: Hardcoded in source API_KEY = "sb_sk_AB1234_..." # NEVER do this # ❌ Bad: Committed to version control # .env files should be in .gitignore ``` **2. Key Rotation**: ```bash theme={null} # 1. Create new key new_key_response=$(curl -s -X POST http://localhost:8000/api/v1/api-keys/ \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"name": "Rotated Key"}') # 2. Extract key new_key=$(echo "$new_key_response" | jq -r '.key') # 3. Update application configuration export SNACKBASE_API_KEY="$new_key" # 4. Verify new key works curl -H "Authorization: Bearer $new_key" http://localhost:8000/api/v1/auth/me # 5. Revoke old key curl -X POST http://localhost:8000/api/v1/api-keys/ak_old_key/revoke \ -H "Authorization: Bearer " ``` **3. Scoping and Naming**: ```json theme={null} { "name": "Production - Payment Service", "description": "Used by payment processing service in production environment" } ``` Use descriptive names to identify: * Environment (Production, Staging, Development) * Service (Payment Service, Webhook Handler, CLI) * Purpose (Backup Job, Monitoring Integration) **4. Monitoring and Auditing**: ```bash theme={null} # Check for unused keys curl -X GET http://localhost:8000/api/v1/api-keys/ \ -H "Authorization: Bearer " | \ jq '.items[] | select(.last_used_at == null)' # Check for old keys (not used in 90 days) # Implement automated monitoring and alerting ``` **5. Revocation on Compromise**: If an API key is accidentally exposed (committed to repo, logged, etc.): 1. Immediately revoke the compromised key 2. Create a replacement key 3. Update all services using the old key 4. Investigate potential unauthorized access 5. Review audit logs for suspicious activity ### API Key vs User Permissions API keys inherit the permissions of the user who created them: ```python theme={null} # Admin user creates API key # → API key has admin permissions # Viewer user creates API key # → API key has viewer permissions # Key permissions are tied to creating user's role ``` This means: * Create dedicated service users with minimal required permissions * Don't use personal admin accounts to create production API keys * Regularly audit which users have created API keys ### Common Patterns **Service Authentication**: ```python theme={null} # Backend service calling SnackBase import os import requests API_KEY = os.environ["SNACKBASE_API_KEY"] headers = {"Authorization": f"Bearer {API_KEY}"} def create_post(title, content): response = requests.post( "http://localhost:8000/api/v1/posts", headers=headers, json={"title": title, "content": content} ) return response.json() ``` **CLI Tool**: ```bash theme={null} # Configure CLI with API key snackbase config set api-key sb_sk_AB1234_... # CLI uses stored key for all commands snackbase posts list snackbase posts create --title "Hello" ``` **Webhook Handler**: ```python theme={null} # Webhook endpoint validates API key from fastapi import Header, HTTPException async def webhook_handler( authorization: str = Header(...) ): if not authorization.startswith("Bearer sb_sk_"): raise HTTPException(401, "Invalid API key") # Process webhook ``` ## Security Features ### Password Hashing (Argon2id) SnackBase uses **Argon2id**, the OWASP-recommended password hashing algorithm: ```python theme={null} import argon2 # Password hasher configuration hasher = argon2.PasswordHasher( time_cost=3, # Number of iterations memory_cost=65536, # Memory in KiB (64 MB) parallelism=4, # Number of threads hash_len=32, # Hash length salt_len=16 # Salt length ) # Hash password password_hash = hasher.hash("SecurePass123!") # $argon2id$v=19$m=65536,t=3,p=4$... # Verify password (timing-safe) is_valid = hasher.verify(password_hash, "SecurePass123!") ``` ### Password Requirements Default password requirements (configurable): | Requirement | Minimum | | ----------------- | ------------ | | Length | 8 characters | | Uppercase | 1 character | | Lowercase | 1 character | | Number | 1 digit | | Special character | 1 character | ### Token Expiration | Token Type | Default Lifetime | Configurable Via | | ------------- | ---------------- | --------------------------------------- | | Access Token | 1 hour | `SNACKBASE_ACCESS_TOKEN_EXPIRE_MINUTES` | | Refresh Token | 7 days | `SNACKBASE_REFRESH_TOKEN_EXPIRE_DAYS` | ## Best Practices ### 1. Token Storage **For Web Applications:** ```javascript theme={null} // ✅ Recommended: HttpOnly cookies for refresh tokens // Set-Cookie: refresh_token=; HttpOnly; Secure; SameSite=Strict // ⚠️ Acceptable: localStorage for access token only localStorage.setItem("access_token", token); // ❌ Avoid: localStorage for refresh tokens localStorage.setItem("refresh_token", token); // Vulnerable to XSS ``` ### 2. Token Refresh Implement proactive token refresh: ```javascript theme={null} // Refresh token 5 minutes before expiration const token = parseJwt(access_token); const expiresAt = token.exp * 1000; const now = Date.now(); const refreshBefore = 5 * 60 * 1000; // 5 minutes if (expiresAt - now < refreshBefore) { await refreshToken(); } ``` ### 3. Handle Token Expiration ```javascript theme={null} // Axios interceptor for automatic token refresh axios.interceptors.response.use( (response) => response, async (error) => { if (error.response?.status === 401) { // Access token expired try { const newToken = await refreshToken(); // Retry original request return axios.request(error.config); } catch { // Refresh token expired - redirect to login window.location.href = "/login"; } } return Promise.reject(error); } ); ``` ### 4. Logout Properly ```javascript theme={null} async function logout() { // Clear tokens from storage localStorage.removeItem("access_token"); document.cookie = "refresh_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; // Call backend logout to revoke refresh token await axios.post("/api/v1/auth/logout"); // Redirect to login window.location.href = "/login"; } ``` ### 5. Use HTTPS in Production Never send tokens over unencrypted connections: ```bash theme={null} # ❌ Development only http://localhost:8000 # ✅ Production https://yourdomain.com ``` ## Summary | Concept | Key Takeaway | | ------------------------ | ------------------------------------------------------------------------------ | | **User Identity** | Defined by `(email, account_id)` tuple | | **Account Registration** | Creates new tenant with UUID primary key and `XX####` display code | | **User Registration** | Creates user within specific account, email unique per account | | **Email Verification** | Required for login, tokens expire in 1 hour, single-use | | **Login Flow** | Resolve account → Find user → Check verification → Verify password → Issue JWT | | **Token Management** | Access token (1 hour) + Refresh token (7 days) with true rotation | | **OAuth Authentication** | Redirect → Authorize → Callback → Exchange tokens → Create/update user | | **SAML Authentication** | SSO request → IdP → ACS response → Verify → Create/update user | | **Multi-Account Users** | Same email can exist in multiple accounts with different passwords | | **Security** | Argon2id hashing, timing-safe comparison, token rotation, HTTPS required | | **Configuration** | Hierarchical: system-level defaults → account-level overrides | # Background Jobs Source: https://docs.snackbase.dev/concepts/background-jobs Asynchronous job execution engine for reliable background processing SnackBase includes a **background job engine** that powers reliable asynchronous execution for webhooks, scheduled hooks, workflow delays, and email delivery. Jobs are managed by superadmins through the Admin API. ## Overview The job system is the execution substrate that other automation features build on: * **Webhook deliveries** are dispatched as jobs with automatic retries * **Scheduled hooks** are executed via jobs on their cron schedule * **Workflow delays** (`wait_delay` steps) enqueue resume jobs * **Email sending** is handled by background jobs ### Key Features * **Priority Queue**: Jobs execute in priority order (lower number = higher priority) * **Automatic Retries**: Failed jobs retry with exponential backoff * **Stale Job Recovery**: Jobs stuck in `running` state are automatically recovered * **Job Statistics**: Aggregate counts by status with failure rate * **Retention Cleanup**: Completed jobs are automatically purged after a configurable period ## Job Lifecycle ``` pending ──> running ──> completed ├──> failed ──> retrying ──> running (retry) │ └──> dead (retries exhausted) └──> (cancelled by admin, only from pending) ``` ### Job Statuses | Status | Meaning | | ----------- | ------------------------------ | | `pending` | Queued, waiting to execute | | `running` | Currently executing | | `completed` | Finished successfully | | `failed` | Execution failed (may retry) | | `retrying` | Waiting to retry after failure | | `dead` | All retry attempts exhausted | ## Retry Logic Failed jobs retry automatically with exponential backoff: ``` delay = retry_delay_seconds * 2^attempt_number ``` | Attempt | Delay (default base: 60s) | | --------- | ------------------------- | | 1st retry | 60 seconds | | 2nd retry | 120 seconds | | 3rd retry | 240 seconds | Default `max_retries` is 3. Once exhausted, the job transitions to `dead`. ## Built-in Job Handlers | Handler | Purpose | | ------------------ | ----------------------------------------------- | | `webhook_delivery` | Deliver outbound webhook payloads | | `send_email` | Send emails via the configured provider | | `scheduled_task` | Execute scheduled tasks | | `workflow_resume` | Resume a workflow instance after a `wait_delay` | | `scheduled_hook` | Execute a scheduled API-defined hook's actions | ## Admin API The Jobs API is restricted to **superadmins only**. It provides a system-wide view across all accounts. ### Job Statistics Get aggregate counts and failure rate: ```bash theme={null} curl https://api.snackbase.dev/api/v1/admin/jobs/stats \ -H "Authorization: Bearer {superadmin_token}" ``` Response: ```json theme={null} { "pending": 12, "running": 3, "completed": 1547, "failed": 8, "retrying": 2, "dead": 1, "avg_duration_seconds": null, "failure_rate": 0.0058 } ``` The `failure_rate` is calculated as `(failed + dead) / (completed + failed + dead)`. ### List Jobs Filter by status, queue, or handler: ```bash theme={null} curl "https://api.snackbase.dev/api/v1/admin/jobs?status=failed&handler=webhook_delivery" \ -H "Authorization: Bearer {superadmin_token}" ``` ### Retry a Job Manually retry a `dead`, `failed`, or `retrying` job: ```bash theme={null} curl -X POST https://api.snackbase.dev/api/v1/admin/jobs/{job_id}/retry \ -H "Authorization: Bearer {superadmin_token}" ``` This resets the job to `pending` with `attempt_number` reset to 0. ### Cancel a Job Cancel a `pending` job (only pending jobs can be cancelled): ```bash theme={null} curl -X DELETE https://api.snackbase.dev/api/v1/admin/jobs/{job_id} \ -H "Authorization: Bearer {superadmin_token}" ``` ## Job Fields | Field | Type | Description | | --------------------- | -------- | ----------------------------------------------- | | `id` | string | UUID | | `queue` | string | Queue name (default: `"default"`) | | `handler` | string | Registered handler identifier | | `payload` | object | JSON payload passed to handler | | `status` | string | Current job status | | `priority` | integer | Execution priority (lower = higher, default: 0) | | `run_at` | datetime | Earliest execution time (null = immediately) | | `started_at` | datetime | When execution began | | `completed_at` | datetime | When completed successfully | | `failed_at` | datetime | When last failed | | `error_message` | string | Most recent error (truncated to \~5000 chars) | | `attempt_number` | integer | Attempts made so far | | `max_retries` | integer | Max retry attempts (default: 3) | | `retry_delay_seconds` | integer | Base retry delay (default: 60) | | `account_id` | string | Account context (null for system jobs) | | `created_by` | string | User who enqueued (null for system jobs) | ## Worker Configuration The job worker is configured via application settings: | Setting | Description | | -------------------------- | -------------------------------------------------- | | `job_worker_poll_interval` | How often the worker checks for new jobs (seconds) | | `job_execution_timeout` | Maximum execution time per job (seconds) | | `job_retention_days` | How long to keep completed jobs before cleanup | ### Graceful Shutdown When the worker shuts down, any currently running job is reset to `pending` so it can be picked up again. ### Stale Job Recovery Jobs stuck in `running` state longer than the execution timeout are automatically recovered and reset to `pending`. # Collections Model Source: https://docs.snackbase.dev/concepts/collections Dynamic schemas, table generation, and auto-generated APIs SnackBase's **Collections** are the core abstraction for defining data schemas and generating APIs. This guide explains how collections work, the dynamic table system, and implications for developers. ## Overview In traditional databases, you define tables with schemas. In SnackBase, you define **Collections**, which: 1. Store schema metadata in the `collections` table 2. Create/update physical tables dynamically 3. Auto-generate REST API endpoints 4. Provide validation and type safety 5. Support relationships between collections 6. Protect sensitive data with PII masking ## What is a Collection? A **Collection** is a named data schema with fields, types, and configuration options. ### Collection Structure ```json theme={null} { "id": "col_abc123", "name": "posts", "description": "Blog posts and articles", "account_id": "AB1001", "migration_revision": "20250101_create_posts", "schema": { "fields": [ { "name": "title", "type": "text", "required": true }, { "name": "content", "type": "text", "required": false }, { "name": "status", "type": "text", "default": "draft" }, { "name": "views", "type": "number", "default": 0 }, { "name": "published_at", "type": "datetime" }, { "name": "author_email", "type": "email" }, { "name": "cover_image", "type": "url" }, { "name": "metadata", "type": "json" } ] }, "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T00:00:00Z" } ``` ### Components of a Collection | Component | Purpose | Example | | ----------------------- | ------------------------------------------------------ | --------------------------- | | **name** | Unique identifier, becomes table name | `posts` → `col_posts` table | | **description** | Human-readable description | "Blog posts and articles" | | **schema** | Field definitions with types and validation | See field types below | | **account\_id** | Owner account (for collection management) | `AB1001` | | **migration\_revision** | Tracks which migration created/modified the collection | `20250101_create_posts` | ## Collection vs Table Understanding the distinction is critical: ### The Confusion ``` ❌ Common Misconception: "Creating a collection creates a separate table for each account" ✓ Reality: "Creating a collection creates ONE shared table for ALL accounts" ``` ### How It Actually Works When you create a collection named `posts`: 1. **Schema Definition**: Stored in `collections` table (metadata) 2. **Table Creation**: Physical `col_posts` table created (if doesn't exist) 3. **API Generation**: `/api/v1/records/posts` endpoints registered 4. **Usage**: All accounts use the same physical table ``` collections table (metadata): ┌─────────────┬──────────────┬─────────────┬────────────────────────┐ │ id │ name │ account_id │ migration_revision │ ├─────────────┼──────────────┼─────────────┼────────────────────────┤ │ col_abc123 │ posts │ AB1001 │ 20250101_create_posts │ ← Who created it │ col_def456 │ products │ XY2048 │ 20250102_products │ └─────────────┴──────────────┴─────────────┴────────────────────────┘ col_posts table (actual data - ONE table for ALL accounts): ┌─────────────┬─────────────┬───────────────┬─────────────┬───────────────┬─────────────┐ │ id │ title │ content │ account_id │ created_at │ updated_by │ ├─────────────┼─────────────┼───────────────┼─────────────┼───────────────┼─────────────┤ │ post_001 │ Hello │ Welcome... │ AB1001 │ 2025-01-01... │ user_123 │ ← AB1001's data │ post_002 │ Acme News │ Latest... │ AB1001 │ 2025-01-02... │ user_456 │ │ post_003 │ Globex Post │ Update... │ XY2048 │ 2025-01-03... │ user_789 │ ← XY2048's data └─────────────┴─────────────┴───────────────┴─────────────┴───────────────┴─────────────┘ ``` ### Table Naming Convention **Critical**: Collection tables are prefixed with `col_` to avoid conflicts with system tables. | Collection Name | Physical Table Name | | --------------- | ------------------- | | `posts` | `col_posts` | | `products` | `col_products` | | `users` | `col_users` | ### Why This Design? | Approach | Pros | Cons | SnackBase | | ------------------------ | --------------------------- | --------------------------------------- | ------------ | | **Table per Account** | Complete isolation | Thousands of tables, complex migrations | ❌ | | **Database per Account** | Maximum isolation | Complex operations, resource intensive | ❌ | | **Shared Table** | Simple, scalable, efficient | Requires account filtering | ✅ **Chosen** | ## Field Types Collections support multiple field types with built-in validation. ### Available Field Types | Type | Description | Database Type | Example | | ------------- | ------------------------------- | -------------- | ------------------------------------------- | | **text** | Single-line text | `VARCHAR` | Name, title | | **number** | Numeric value | `NUMERIC` | Price, quantity | | **boolean** | True/false | `BOOLEAN` | is\_published | | **datetime** | Date/time with validation | `TIMESTAMP` | published\_at, created\_at | | **email** | Email with validation | `VARCHAR` | [user@example.com](mailto:user@example.com) | | **url** | URL with validation | `VARCHAR` | [https://example.com](https://example.com) | | **json** | JSON data | `JSONB` | metadata | | **reference** | Reference to another collection | `VARCHAR` (FK) | user\_id | | **file** | File upload reference | `VARCHAR` | avatar\_url | ### Field Configuration Each field type has specific configuration options: #### Text Field ```json theme={null} { "name": "title", "type": "text", "required": true, "default": null, "unique": false } ``` #### Number Field ```json theme={null} { "name": "price", "type": "number", "required": true, "default": 0, "min": 0, "max": 1000000 } ``` #### DateTime Field ```json theme={null} { "name": "published_at", "type": "datetime", "required": false, "default": null } ``` #### Email Field ```json theme={null} { "name": "author_email", "type": "email", "required": true, "unique": true } ``` #### URL Field ```json theme={null} { "name": "website", "type": "url", "required": false } ``` #### JSON Field ```json theme={null} { "name": "metadata", "type": "json", "required": false, "default": "{}" } ``` #### Reference Field ```json theme={null} { "name": "author_id", "type": "reference", "target_collection": "users", "on_delete": "set_null", "required": false } ``` #### File Field ```json theme={null} { "name": "attachment", "type": "file", "required": false, "max_size": 10485760 } ``` ## Dynamic Table Generation SnackBase dynamically creates and modifies database tables based on collection schemas. ### Table Creation Flow ``` 1. User creates collection via UI or API POST /api/v1/collections { "name": "posts", "schema": { "fields": [...] } } 2. System validates collection name - Must be alphanumeric with underscores - Cannot conflict with system tables - Cannot conflict with existing collections 3. System generates SQL CREATE TABLE IF NOT EXISTS col_posts ( id VARCHAR(50) PRIMARY KEY, account_id VARCHAR(10) NOT NULL, title VARCHAR NOT NULL, content TEXT, status VARCHAR, views NUMERIC DEFAULT 0, published_at TIMESTAMP, author_email VARCHAR, cover_image VARCHAR, metadata JSONB, created_at TIMESTAMP DEFAULT NOW(), created_by VARCHAR(50), updated_at TIMESTAMP DEFAULT NOW(), updated_by VARCHAR(50), FOREIGN KEY (account_id) REFERENCES accounts(id), FOREIGN KEY (created_by) REFERENCES users(id), FOREIGN KEY (updated_by) REFERENCES users(id) ); 4. System executes SQL via SQLAlchemy 5. System registers API routes GET /api/v1/records/posts POST /api/v1/records/posts GET /api/v1/records/posts/:id PUT /api/v1/records/posts/:id DELETE /api/v1/records/posts/:id ``` ### Built-in Fields Every collection table includes **automatic fields** you don't need to define: | Field | Type | Description | Auto-Managed | | ------------ | ----------- | --------------------- | ---------------- | | `id` | VARCHAR | Unique record ID | ✅ Auto-generated | | `account_id` | VARCHAR(10) | Account isolation | ✅ Automatic | | `created_at` | TIMESTAMP | Creation timestamp | ✅ Auto-set | | `created_by` | VARCHAR(50) | Creator user ID | ✅ Auto-set | | `updated_at` | TIMESTAMP | Last update timestamp | ✅ Auto-updated | | `updated_by` | VARCHAR(50) | Last updater user ID | ✅ Auto-updated | ### Indexes and Constraints SnackBase automatically creates: ```sql theme={null} -- Primary key PRIMARY KEY (id) -- Account isolation index INDEX idx_col_posts_account_id ON col_posts(account_id); -- Timestamp indexes INDEX idx_col_posts_created_at ON col_posts(created_at); INDEX idx_col_posts_updated_at ON col_posts(updated_at); -- Unique constraints (if specified) UNIQUE (title) -- if field.unique = true -- Foreign keys FOREIGN KEY (account_id) REFERENCES accounts(id) FOREIGN KEY (created_by) REFERENCES users(id) FOREIGN KEY (updated_by) REFERENCES users(id) ``` ## Auto-Generated APIs Each collection automatically gets a complete REST API. ### Generated Endpoints For a collection named `posts`: | Method | Endpoint | Description | Permission | | ------ | ---------------------------- | --------------------------------- | -------------------------------- | | GET | `/api/v1/records/posts` | List all records (with filtering) | `posts:read` | | POST | `/api/v1/records/posts` | Create a new record | `posts:create` | | GET | `/api/v1/records/posts/:id` | Get single record | `posts:read` | | PATCH | `/api/v1/records/posts/:id` | Partial update record | `posts:update` | | PUT | `/api/v1/records/posts/:id` | Full update record | `posts:update` | | DELETE | `/api/v1/records/posts/:id` | Delete record | `posts:delete` | | POST | `/api/v1/records/posts/bulk` | Bulk operations | `posts:create`/`update`/`delete` | ### API Usage Examples ```bash theme={null} # Create a record POST /api/v1/records/posts Authorization: Bearer Content-Type: application/json { "title": "My First Post", "content": "This is the content", "status": "published", "views": 0, "author_email": "author@example.com", "cover_image": "https://example.com/image.jpg", "metadata": { "seo_title": "SEO Title", "tags": ["tag1", "tag2"] } } # Response { "id": "post_abc123", "title": "My First Post", "content": "This is the content", "status": "published", "views": 0, "author_email": "author@example.com", "cover_image": "https://example.com/image.jpg", "metadata": { "seo_title": "SEO Title", "tags": ["tag1", "tag2"] }, "account_id": "AB1001", "created_at": "2025-01-01T00:00:00Z", "created_by": "user_xyz789", "updated_at": "2025-01-01T00:00:00Z", "updated_by": "user_xyz789" } ``` ### Query Filtering List endpoints support powerful filtering: ```bash theme={null} # Basic filtering GET /api/v1/records/posts?status=published # Multiple filters GET /api/v1/records/posts?status=published&views.gt=100 # Sorting GET /api/v1/records/posts?sort=-created_at # Pagination GET /api/v1/records/posts?page=1&limit=20 # Full text search (if configured) GET /api/v1/records/posts?q=hello # Date range filtering GET /api/v1/records/posts?created_at.gte=2025-01-01T00:00:00Z ``` ## PII Masking SnackBase provides **automatic PII (Personally Identifiable Information) masking** to protect sensitive user data. ### How PII Masking Works PII fields are automatically masked for users who don't have the `pii_access` group membership: ```json theme={null} // User without pii_access sees: { "id": "user_123", "email": "j***@example.com", // Masked "phone": "***-***-1234", // Masked "ssn": "***-**-****", // Masked "name": "John D." // Masked } // User with pii_access sees: { "id": "user_123", "email": "john.doe@example.com", // Full value "phone": "555-123-4567", // Full value "ssn": "123-45-6789", // Full value "name": "John Doe" // Full value } ``` ### PII Mask Types | Mask Type | Description | Example | | ---------- | ----------------------------------------- | ------------------ | | **email** | Shows first character and domain | `j***@example.com` | | **ssn** | Shows only format | `***-**-****` | | **phone** | Shows last 4 digits | `***-***-1234` | | **name** | Shows first name initial and last initial | `John D.` | | **full** | Completely hides value | `******` | | **custom** | Custom mask pattern | Configurable | ### Configuring PII Fields To enable PII masking on a field, use the `pii_mask` configuration: ```json theme={null} { "fields": [ { "name": "email", "type": "email", "pii_mask": { "enabled": true, "mask_type": "email" } }, { "name": "ssn", "type": "text", "pii_mask": { "enabled": true, "mask_type": "ssn" } }, { "name": "phone", "type": "text", "pii_mask": { "enabled": true, "mask_type": "phone" } } ] } ``` ## Reference Fields Reference fields allow you to create relationships between collections using foreign keys. ### Reference Field Configuration ```json theme={null} { "name": "author_id", "type": "reference", "target_collection": "users", "on_delete": "set_null", "required": false } ``` ### on\_delete Actions | Action | Description | Use Case | | ------------- | ----------------------------------------------- | ------------------------------------- | | **cascade** | Delete referenced record when target is deleted | Dependent data (order items → orders) | | **set\_null** | Set field to NULL when target is deleted | Optional relationships | | **restrict** | Prevent deletion if referenced | Critical references (users → orders) | ### Reference Field Example ```json theme={null} // Posts collection with author reference { "name": "posts", "schema": { "fields": [ { "name": "title", "type": "text" }, { "name": "author_id", "type": "reference", "target_collection": "users", "on_delete": "set_null", "required": false } ] } } ``` This creates a foreign key constraint: ```sql theme={null} ALTER TABLE col_posts ADD CONSTRAINT fk_posts_author FOREIGN KEY (author_id) REFERENCES col_users(id) ON DELETE SET NULL; ``` ### Reference Validation When creating or updating records with reference fields: ```bash theme={null} # Valid reference POST /api/v1/records/posts { "title": "My Post", "author_id": "user_abc123" # ✅ Exists in users collection } # Invalid reference POST /api/v1/records/posts { "title": "My Post", "author_id": "user_xyz999" # ❌ Does not exist - 400 Bad Request } ``` ## Schema Evolution Collections can evolve over time with schema updates. ### Supported Changes | Change | Supported | Notes | | -------------------- | --------- | ----------------------------- | | Add new field | ✅ Yes | New field added to table | | Remove field | ❌ **No** | Field deletion is NOT allowed | | Rename field | ❌ **No** | Must create new field instead | | Change type | ❌ **No** | Type changes are NOT allowed | | Modify field options | ✅ Yes | Adding options is supported | ### Schema Update Flow ``` 1. User updates collection schema PUT /api/v1/collections/:id { "schema": { "fields": [...] } } 2. System validates changes - Field names are unique - Types are valid - No field deletions - No type changes 3. System generates ALTER TABLE statements ALTER TABLE col_posts ADD COLUMN category TEXT; 4. System executes SQL 5. Updated schema applies immediately - New API validation - Updated forms in UI ``` ### Schema Evolution Rules **Field Addition** (Only supported operation): ```json theme={null} // Before: Collection with 3 fields { "name": "posts", "schema": { "fields": [ { "name": "title", "type": "text" }, { "name": "content", "type": "text" }, { "name": "status", "type": "text" } ] } } // After: Add new field "category" { "name": "posts", "schema": { "fields": [ { "name": "title", "type": "text" }, { "name": "content", "type": "text" }, { "name": "status", "type": "text" }, { "name": "category", "type": "text" } // ✅ New field added ] } } ``` **Field Deletion** (NOT allowed): ```json theme={null} // Attempting to remove "status" field { "name": "posts", "schema": { "fields": [ { "name": "title", "type": "text" }, { "name": "content", "type": "text" } // "status" removed - ❌ NOT ALLOWED ] } } // System response: 400 Bad Request { "error": "Cannot remove fields from schema", "detail": "Field removal is not supported. Fields can only be added." } ``` **Type Changes** (NOT allowed): ```json theme={null} // Attempting to change field type { "fields": [ { "name": "views", "type": "text" } // ❌ Was "number", now "text" ] } // System response: 400 Bad Request { "error": "Cannot change field types", "detail": "Type changes are not supported. Create a new field instead." } ``` ## Best Practices ### 1. Naming Conventions Use **lowercase, plural** names for collections: | Good | Bad | | ------------- | ------------- | | `posts` | `Posts` | | `users` | `user` | | `blog_posts` | `BlogPosts` | | `order_items` | `order-items` | ### 2. Field Naming Use **snake\_case** for field names: ```json theme={null} { "fields": [ { "name": "first_name", "type": "text" }, // ✅ Good { "name": "lastName", "type": "text" }, // ❌ Bad { "name": "Email-Address", "type": "email" } // ❌ Bad ] } ``` ### 3. Use Appropriate Field Types Choose the most specific type for your data: ```json theme={null} { "fields": [ { "name": "email", "type": "email" }, // ✅ Specific type { "name": "published_at", "type": "datetime" }, // ✅ With validation { "name": "website", "type": "url" }, // ✅ URL validation { "name": "metadata", "type": "json" } // ✅ Flexible data ] } ``` ### 4. Use JSON for Flexible Data For metadata or varying structures: ```json theme={null} { "name": "metadata", "type": "json" } ``` Store arbitrary data: ```json theme={null} { "metadata": { "seo_title": "SEO Title", "seo_description": "Description", "tags": ["tag1", "tag2"], "custom_field": "any value" } } ``` ### 5. Plan Schema Evolution Design schemas with evolution in mind: * Only add new fields (deletion and type changes are not allowed) * Use `required: false` for fields that might be optional later * Document schema changes in migration revisions * Test schema updates in development first ### 6. Use PII Masking for Sensitive Data Protect user privacy with automatic PII masking: ```json theme={null} { "name": "email", "type": "email", "pii_mask": { "enabled": true, "mask_type": "email" } } ``` This ensures compliance with privacy regulations by default. ### 7. Choose Appropriate on\_delete Actions Select the right action for reference fields: | Scenario | Action | | ---------------------------------------- | ---------- | | Optional relationships (posts → authors) | `set_null` | | Dependent data (order items → orders) | `cascade` | | Critical references (prevent deletion) | `restrict` | ### 8. Avoid Too Many Collections Each collection creates a table. Consider: * Can related data be in the same collection? * Would JSON fields work better for varying schemas? * Do you really need separate tables? **Example**: Instead of `blog_posts` and `news_posts`, use `posts` with a `category` field. ## Aggregation Queries Collections support server-side aggregation via the `/{collection}/aggregate` endpoint: | Function | Description | | -------- | -------------------------- | | `count` | Count matching records | | `sum` | Sum a numeric field | | `avg` | Average of a numeric field | | `min` | Minimum value | | `max` | Maximum value | Aggregations support `filter`, `group_by`, and `having` clauses for powerful analytics without fetching raw records. See the [API Reference](/api-reference/introduction) for full details. ## Public Collections Collections can be configured for **anonymous read access**, allowing unauthenticated users to query records. This is useful for public-facing data like product listings, blog posts, or FAQ content. ## Summary | Concept | Key Takeaway | | ------------------------- | -------------------------------------------------------------------------------------- | | **Collection Definition** | Named schema with fields, stored in `collections` table | | **Collection vs Table** | Collections are metadata; ONE shared table per collection for ALL accounts | | **Table Naming** | Tables prefixed with `col_` (e.g., `col_posts`) | | **Field Types** | 9 types available (text, number, boolean, datetime, email, url, json, reference, file) | | **Dynamic Tables** | Tables created/modified automatically based on schema | | **Auto-Generated APIs** | REST endpoints at `/api/v1/records/{collection}` | | **PII Masking** | Automatic masking for sensitive data (email, ssn, phone, name) | | **Reference Fields** | Foreign key relationships with on\_delete actions | | **Schema Evolution** | Only adding fields is allowed; deletion and type changes are NOT supported | | **Migration Tracking** | `migration_revision` field tracks schema changes | | **Best Practices** | Use lowercase plurals, snake\_case fields, plan for evolution | # Custom Endpoints Source: https://docs.snackbase.dev/concepts/custom-endpoints Define serverless-like HTTP endpoints without writing backend code **Custom Endpoints** let you define serverless-like HTTP endpoints that execute action pipelines -- all configured through the REST API. Define a path, choose an HTTP method, configure actions, and your endpoint is live. ## Overview Custom endpoints are dispatched through a dedicated URL namespace: ``` POST /api/v1/x/{your-slug}/{optional-path} ``` When a request arrives, SnackBase matches it to an endpoint definition, evaluates authorization conditions, executes the action pipeline, and returns the configured response. ### Key Features * **No Code Required**: Define endpoints via API or Admin UI * **Path Parameters**: Support for `:param` segments (e.g., `/users/:user_id/orders`) * **Authorization Conditions**: Gate access with rule expressions * **Action Pipelines**: Chain multiple actions with access to results from previous steps * **Response Templates**: Customize response status, body, and headers * **Execution History**: Track every invocation with timing and status ## How It Works ``` ┌───────────────┐ ┌──────────────────────────────┐ ┌──────────────┐ │ HTTP Request │ │ Custom Endpoint Dispatcher │ │ Response │ │ │────>│ │────>│ │ │ POST /api/v1 │ │ 1. Match path + method │ │ Status code │ │ /x/feedback │ │ 2. Extract path params │ │ Body │ │ │ │ 3. Check auth (if required) │ │ Headers │ │ │ │ 4. Evaluate condition │ │ │ │ │ │ 5. Execute actions │ │ │ │ │ │ 6. Apply response template │ │ │ └───────────────┘ └──────────────────────────────┘ └──────────────┘ ``` ## Endpoint Definition | Field | Type | Required | Description | | ------------------- | ------- | -------- | ----------------------------------------------------------------------- | | `name` | string | Yes | Human-readable name (max 200 chars) | | `path` | string | Yes | URL path (max 500 chars), e.g., `/submit-feedback` or `/users/:user_id` | | `method` | string | Yes | HTTP method: `GET`, `POST`, `PUT`, `PATCH`, `DELETE` | | `auth_required` | boolean | No | Require authentication (default: `true`) | | `condition` | string | No | Rule expression -- returns 403 if false | | `actions` | array | No | Ordered list of action configs (default: `[]`) | | `response_template` | object | No | Custom response format | | `enabled` | boolean | No | Active status (default: `true`) | ### Path Parameters Use `:param` syntax to capture path segments: ``` /users/:user_id/orders/:order_id ``` These values are available as `{{request.params.user_id}}` and `{{request.params.order_id}}` in action templates. ### Uniqueness Each endpoint is uniquely identified by the combination of `(account_id, path, method)`. You cannot create two endpoints with the same path and method within one account. ## Actions Actions execute sequentially. Each action's result is available to subsequent actions. ### Supported Action Types | Action Type | Description | | ------------------- | ---------------------------------------------- | | `send_webhook` | Send an HTTP request to an external URL | | `send_email` | Send an email | | `create_record` | Create a record in a collection | | `update_record` | Update an existing record | | `delete_record` | Delete a record | | `enqueue_job` | Enqueue a background job | | `query_records` | Query records from a collection | | `aggregate_records` | Run an aggregation (count, sum, avg, min, max) | | `transform` | Transform data using a template | ### Query Records ```json theme={null} { "type": "query_records", "config": { "collection": "orders", "filter": "customer_id = '{{request.params.customer_id}}'", "sort": "-created_at", "limit": 10, "offset": 0 } } ``` ### Aggregate Records ```json theme={null} { "type": "aggregate_records", "config": { "collection": "orders", "function": "sum", "field": "total", "filter": "status = 'completed'", "group_by": "category" } } ``` Supported functions: `count`, `sum`, `avg`, `min`, `max`. ### Transform Reshape data from previous action results: ```json theme={null} { "type": "transform", "config": { "output": { "total_orders": "{{actions[0].result}}", "customer": "{{request.params.customer_id}}" } } } ``` ## Template Variables All string values in action configs and response templates support these variables: | Variable | Description | | -------------------------- | --------------------------------------- | | `{{request.body.field}}` | Value from the request body | | `{{request.query.field}}` | URL query parameter | | `{{request.params.field}}` | Path parameter (from `:param` segments) | | `{{auth.user_id}}` | Authenticated user's ID | | `{{auth.email}}` | Authenticated user's email | | `{{auth.account_id}}` | Account ID | | `{{actions[N].result}}` | Result of the Nth action (0-indexed) | | `{{now}}` | Current UTC timestamp (ISO 8601) | ## Response Templates Customize what your endpoint returns: ```json theme={null} { "response_template": { "status": 200, "body": { "message": "Feedback received", "order_count": "{{actions[0].result}}" }, "headers": { "X-Custom-Header": "value" } } } ``` If no response template is configured, the endpoint returns HTTP 200 with the last action's result as the body. ## Authorization ### Authentication Set `auth_required: true` (the default) to require a valid authentication token. Set to `false` for public endpoints. ### Condition Expressions Add a `condition` to control who can access the endpoint: ```json theme={null} { "condition": "@has_role('admin') or @owns_record()" } ``` If the condition evaluates to `false`, the endpoint returns 403 Forbidden. ## Example: Customer Order Summary Endpoint ```json theme={null} { "name": "Customer Order Summary", "path": "/customers/:customer_id/summary", "method": "GET", "auth_required": true, "actions": [ { "type": "query_records", "config": { "collection": "orders", "filter": "customer_id = '{{request.params.customer_id}}'", "sort": "-created_at", "limit": 5 } }, { "type": "aggregate_records", "config": { "collection": "orders", "function": "sum", "field": "total", "filter": "customer_id = '{{request.params.customer_id}}'" } }, { "type": "transform", "config": { "output": { "recent_orders": "{{actions[0].result}}", "lifetime_total": "{{actions[1].result}}" } } } ], "response_template": { "status": 200, "body": "{{actions[2].result}}" } } ``` Call it: ```bash theme={null} curl https://api.snackbase.dev/api/v1/x/customers/cust-123/summary \ -H "Authorization: Bearer {token}" ``` ## Limits | Limit | Default | | -------------------------- | ----------------- | | Max endpoints per account | 20 (configurable) | | Execution timeout | 30 seconds | | Max action execution depth | 5 | | Path max length | 500 characters | # Multi-Tenancy Model Source: https://docs.snackbase.dev/concepts/multi-tenancy Account isolation, two-tier architecture, and tenant data management SnackBase uses a **shared database, row-level isolation** multi-tenancy model. This guide explains how accounts work, how data is isolated, and what you need to know when building multi-tenant applications. ## Overview SnackBase enables **Software-as-a-Service (SaaS)** applications by allowing multiple independent tenants (accounts) to coexist in a single database while maintaining complete data isolation. ### Key Characteristics | Characteristic | Description | | ------------------------ | ------------------------------------------------------------- | | **Isolation Type** | Row-level isolation via `account_id` column | | **Database Model** | Shared database, shared tables | | **Account Scope** | All data (users, collections, records) scoped to `account_id` | | **Cross-Account Access** | Not possible by design (enforced at database and API levels) | ## Account Model ### What is an Account? An **Account** (also called a "tenant" or "organization") represents an isolated workspace containing: * Users who belong to the account * Collections (data schemas) defined for the account * Records (actual data) created by the account's users * Roles and permissions specific to the account * Groups for organizing users * Configuration overrides for providers (auth, email, storage) ### Account Hierarchy ``` SnackBase Instance │ ├── System Account (SY0000) │ ├── Superadmin users │ └── Manages all accounts │ ├── Account AB1001 (Acme Corp) │ ├── Users: alice@acme.com, bob@acme.com │ ├── Collections: posts, products, orders │ ├── Roles: admin, editor, viewer │ └── Records: (all scoped to account_id = "550e8400-...") │ ├── Account XY2048 (Globex Inc) │ ├── Users: jane@globex.com │ ├── Collections: customers, tickets │ ├── Roles: support, manager │ └── Records: (all scoped to account_id = "aabbccdd-...") │ └── Account ZZ9999 (StartUp Co) └── ... (completely isolated) ``` ## Data Isolation ### How Isolation Works Most tables in SnackBase include an `account_id` column that references the `accounts` table: ```sql theme={null} -- Example: users table ┌─────────────┬──────────────────┬─────────────────────┐ │ id │ email │ account_id │ ├─────────────┼──────────────────┼─────────────────────┤ │ user_abc123 │ alice@acme.com │ 550e8400-e29b-... │ │ user_def456 │ bob@acme.com │ 550e8400-e29b-... │ │ user_ghi789 │ jane@globex.com │ aabbccdd-1234-... │ └─────────────┴──────────────────┴─────────────────────┘ -- Example: Dynamic collection table (col_posts) ┌─────────────┬─────────────────────┬─────────────┬─────────────────────┐ │ id │ title │ content │ account_id │ ├─────────────┼─────────────────────┼─────────────┼─────────────────────┤ │ post_001 │ Hello World │ Welcome... │ 550e8400-e29b-... │ │ post_002 │ Acme News │ Latest... │ 550e8400-e29b-... │ │ post_003 │ Globex Update │ News... │ aabbccdd-1234-... │ └─────────────┴─────────────────────┴─────────────┴─────────────────────┘ ``` ### Tables WITHOUT account\_id (Global Tables) The following tables **do not have** an `account_id` column because they define global structures shared by all accounts: | Table | Why No account\_id? | | ------------- | ------------------------------------------------------------ | | `accounts` | Defines accounts themselves (cannot be scoped to an account) | | `roles` | Roles are global definitions shared by all accounts | | `permissions` | Permissions are global rules shared by all accounts | | `collections` | Collection schemas are global definitions (data is isolated) | | `macros` | Macros are global SQL snippets shared by all accounts | | `migrations` | Migrations are global and affect all accounts | ### Automatic Filtering SnackBase **automatically filters** all queries by `account_id`. Users never see data from other accounts. **Example API Request:** ```bash theme={null} # User from AB1001 requests all posts GET /api/v1/posts # SQL executed (simplified): SELECT * FROM col_posts WHERE account_id = '550e8400-e29b-41d4-a716-446655440000' ``` The user doesn't need to specify `account_id`—it's automatically added based on their authentication context. ### Enforcement Layers Isolation is enforced at **multiple layers** for defense-in-depth: | Layer | Mechanism | Details | | --------------------- | -------------------------------------------------- | ------------------------------------------ | | **Database** | `account_id` column with foreign key to accounts | Row-level filtering at SQL level | | **Hook** | `account_isolation_hook` (priority -200) | Automatically injects `account_id` filters | | **Repository** | All repositories enforce `account_id` in queries | Cannot bypass without explicit override | | **API Middleware** | Authorization middleware validates account context | Checks permissions before execution | | **Superadmin Bypass** | Superadmin can pass `account_id=None` | Allows cross-account visibility for admins | ## Two-Tier Architecture SnackBase uses a **two-tier table architecture** that's critical to understand: ### Tier 1: Core System Tables These tables define the platform structure and are shared across all accounts: | Table | Purpose | Has account\_id? | Schema Changes | | ------------- | ----------------------------- | --------------------- | -------------- | | `accounts` | Account/tenant definitions | No (defines accounts) | Releases only | | `users` | User identities (per-account) | Yes | Releases only | | `roles` | Role definitions | No (global) | Releases only | | `permissions` | Permission rules | No (global) | Releases only | | `collections` | Collection schema definitions | No (global) | Releases only | | `macros` | SQL macro definitions | No (global) | Releases only | | `migrations` | Database migration history | No (global) | Automatic | **Important**: Schema changes to these tables only happen via SnackBase releases. ### Tier 2: User-Created Collections User collections are **single physical tables** shared by ALL accounts: | Physical Table | Collection Name | Contains | | -------------- | --------------- | -------------------------- | | `col_posts` | "posts" | All accounts' post data | | `col_products` | "products" | All accounts' product data | | `col_orders` | "orders" | All accounts' order data | **Critical Concept**: When you create a collection named "posts", you're creating: 1. A **schema definition** in the `collections` table (metadata) 2. A **physical table** named `col_posts` (if it doesn't exist) 3. All accounts' post data goes into this **single shared table** ### Physical Table Naming Convention Collection tables are **prefixed with `col_`** to avoid conflicts with system tables: | Collection Name | Physical Table Name | Example Query | | --------------- | ------------------- | ------------------------------------------------------ | | `posts` | `col_posts` | `SELECT * FROM col_posts WHERE account_id = ?` | | `products` | `col_products` | `SELECT * FROM col_products WHERE account_id = ?` | | `user_profiles` | `col_user_profiles` | `SELECT * FROM col_user_profiles WHERE account_id = ?` | This prefix: * Prevents naming conflicts with system tables * Makes it clear which tables are user-created collections * Allows easy identification of collection tables in database dumps ### Why This Architecture? | Approach | Description | SnackBase Choice | | ---------------------- | ------------------------------------------------------------------------- | ------------------------------------------- | | **Separate Tables** | Each account gets their own `col_posts_AB1001`, `col_posts_XY2048` tables | ❌ Not scalable (thousands of tables) | | **Separate Databases** | Each account gets their own database | ❌ Complex operations and migrations | | **Shared Tables** | All accounts share one `col_posts` table with `account_id` | ✅ **Chosen for scalability and simplicity** | ## Account Identifiers Accounts have **three distinct identifiers** that serve different purposes: ### Identifier Comparison | Field | Format | Purpose | Example | Uniqueness | | -------------- | ---------------- | ----------------------------------- | -------------------------------------- | --------------- | | `id` | UUID (36 chars) | Primary key, foreign key references | `550e8400-e29b-41d4-a716-446655440000` | Globally unique | | `account_code` | XX#### (6 chars) | Human-readable identifier | `AB1234` | Globally unique | | `slug` | URL-friendly | Login and URL routing | `acme-corp` | Globally unique | | `name` | Free text | Display name only | `Acme Corporation` | Not unique | ### Account ID (UUID) The **internal primary key** for accounts is a standard UUID: ``` Format: 8-4-4-4-12 hexadecimal characters Example: 550e8400-e29b-41d4-a716-446655440000 ``` * **Purpose**: Primary key, used in foreign key references * **Format**: Standard UUID v4 (36 characters) * **Used by**: `account_id` columns in all tenant-scoped tables * **Human-readable**: No (designed for systems, not humans) ### Account Code (XX####) The **human-readable identifier** for accounts: ``` XX#### = 2 letters + 4 digits Examples: ├── SY0000 (System account - reserved) ├── AB1001 (Acme Corp) ├── XY2048 (Globex Inc) └── ZZ9999 (StartUp Co) ``` * **Letters (XX)**: Random uppercase letters A-Z * **Digits (####)**: Sequential number starting from 0001 * **Total Capacity**: 6,760,000 unique codes (26×26×10,000) * **Reserved Range**: SY#### (skipped during generation) ### Account Code Generation Account codes are generated **sequentially** from the highest existing code: ```python theme={null} # Generation logic 1. Find highest existing account code (e.g., AB2345) 2. Increment numeric portion (AB2346) 3. Skip SY#### range (reserved for system) 4. Assign to new account ``` **Important Notes**: * Codes are never reused * Sequential generation ensures predictability * SY#### range is permanently reserved * System account uses SY0000 ### Identifier Usage | Identifier | Used In... | Example | | ----------------- | ---------------------------------- | ------------------------------------------------------ | | **id** (UUID) | Foreign keys, `account_id` columns | `WHERE account_id = '550e8400-...'` | | **account\_code** | Admin UI, support, logs | "Account AB1234" | | **slug** | Login URLs, subdomain routing | `ab1234.snackbase.dev` or `/api/v1/accounts/acme-corp` | | **name** | UI display, emails | "Welcome to Acme Corporation" | ## System Account vs User Accounts ### System Account (SY0000) The **system account** is a special reserved account for superadmin operations: | Attribute | Value | | ---------------- | ---------------------------------------------------------- | | **ID** | `00000000-0000-0000-0000-000000000000` (nil UUID) | | **Account Code** | `SY0000` (fixed) | | **Name** | "System" | | **Purpose** | Superadmin operations, system-level configuration | | **Access** | Superadmin users can operate across ALL accounts | | **Data** | Contains minimal data (mostly metadata and system configs) | **Superadmin users** are linked to the system account and have: * Access to ALL accounts * Ability to create/manage accounts * Ability to manage global collections * System-wide visibility (can pass `account_id=None` to see all data) ### User Accounts **User accounts** are regular tenant accounts created by superadmins: | Attribute | Value | | ---------------- | ------------------------------------------------------------------ | | **ID** | Auto-generated UUID (e.g., `550e8400-e29b-41d4-a716-446655440000`) | | **Account Code** | Auto-generated (e.g., `AB1001`) | | **Name** | User-defined (e.g., "Acme Corporation") | | **Purpose** | Regular tenant operations | | **Access** | Users can only access THEIR account | | **Data** | Contains all tenant data (users, collections, records) | **Regular users** (even with "admin" role) are linked to a specific account and have: * Access ONLY to their account * No cross-account visibility * Full CRUD within their account (based on permissions) ## Multi-Account Users ### Enterprise Multi-Account Model SnackBase supports **enterprise multi-account scenarios** where a single user can belong to multiple accounts with different roles and permissions. ### User Identity A user's identity is defined by the **(email, account\_id) tuple**: ``` ┌────────────────────┬─────────────────────┬──────────────┐ │ email │ account_id │ role │ ├────────────────────┼─────────────────────┼──────────────┤ │ alice@acme.com │ 550e8400-e29b-... │ admin │ │ alice@acme.com │ aabbccdd-1234-... │ viewer │ │ bob@acme.com │ 550e8400-e29b-... │ editor │ │ jane@globex.com │ aabbccdd-1234-... │ admin │ └────────────────────┴─────────────────────┴──────────────┘ ``` **Key Point**: The same email (`alice@acme.com`) can exist in multiple accounts with different roles. ### Password Scope **Passwords are per-account**, not per-email. This means: * `alice@acme.com` in account `AB1001` has password `Password1!` * `alice@acme.com` in account `XY2048` has password `Password2!` * These are **different credentials** even though the email is the same ### Login Flow When logging in, users must specify their account: **Option 1: Account in URL** ``` POST /api/v1/auth/login Host: ab1001.snackbase.dev # Account in subdomain { "email": "alice@acme.com", "password": "Password1!" } ``` **Option 2: Account in Request Body** ``` POST /api/v1/auth/login { "account": "acme-corp", # Account slug "email": "alice@acme.com", "password": "Password1!" } ``` ## Configuration Hierarchy SnackBase uses a **hierarchical configuration model** for provider settings (authentication, email, storage, etc.): ### Two-Level Hierarchy ``` System-Level Configuration ├── account_id: 00000000-0000-0000-0000-000000000000 (nil UUID) ├── Purpose: Default configs for all accounts └── Applied when: No account-level override exists Account-Level Configuration ├── account_id: ├── Purpose: Per-account custom settings └── Priority: Always overrides system defaults ``` ### Configuration Resolution When resolving a provider configuration: 1. **Check account-level** config for the specific account 2. **If not found**, use system-level default 3. **Merge** with fallback values for any missing keys ```python theme={null} # Example: Email provider resolution config = config_registry.get_config( account_id="550e8400-e29b-41d4-a716-446655440000", provider_name="email" ) # Returns: # - Account-specific config if exists # - System-level config if no account override # - Cached for 5 minutes ``` ### Use Cases | Configuration Type | System-Level | Account-Level | | -------------------- | ------------------- | ----------------------- | | **SMTP Settings** | Default SMTP server | Custom SMTP per account | | **OAuth Providers** | Available to all | Custom app credentials | | **Storage Backends** | Default S3 bucket | Per-account buckets | | **Auth Providers** | Default providers | Custom provider config | ### Key Points * System-level configs use the **nil UUID** (`00000000-0000-0000-0000-000000000000`) * Account-level configs use the **account's UUID** as `account_id` * Resolution is cached for **5 minutes** for performance * Built-in providers are marked with `is_builtin` flag (cannot be deleted) ## Implications for Developers ### When Building Applications Understanding multi-tenancy is critical when building on SnackBase: ### 1. Never Store Account ID Manually ```python theme={null} # ❌ DON'T: Manual account_id def create_post(title: str, account_id: str): post = Post(title=title, account_id=account_id) # Error-prone, security risk # ✅ DO: Let the framework handle it def create_post(title: str, context: Context): post = Post(title=title, account_id=context.account_id) # Automatic, secure ``` ### 2. Account Isolation is Automatic You don't need to write WHERE clauses for account filtering: ```python theme={null} # ❌ DON'T: Manual filtering def get_posts(account_id: str): return db.query(Post).filter(Post.account_id == account_id).all() # ✅ DO: Use the repository def get_posts(context: Context): return posts_repo.find_all(context) # Automatically filters by account_id ``` ### 3. Cross-Account Queries Are Impossible By design, you cannot query across accounts: ```python theme={null} # ❌ This will NEVER return results def get_all_posts_from_all_accounts(): return db.query(Post).all() # Only returns current account's posts ``` **Superadmin Exception**: Superadmins can explicitly pass `account_id=None` to bypass filtering: ```python theme={null} # ✅ Superadmin-only cross-account query def get_all_posts_as_superadmin(): return posts_repo.find_all(context, account_id=None) # Returns ALL posts ``` ### 4. Collections Are Global When creating a collection, remember: * The collection schema is shared across ALL accounts * The physical table (`col_`) is shared across ALL accounts * Each account only sees their own data (via `account_id` filtering) ```python theme={null} # Creating "posts" collection creates ONE global table collections_service.create("posts", fields=[...]) # Result: col_posts table created (if not exists) # All accounts can use "posts", but see only their data ``` ### 5. Migrations Affect All Accounts Database migrations affect ALL accounts simultaneously: ```python theme={null} # ⚠️ CAUTION: This affects ALL accounts alembic revision --autogenerate -m "Add index to col_posts" # Result: ALL accounts' posts data is affected ``` Always test migrations thoroughly before deploying! ### 6. Use Account Code for Display When displaying account identifiers in UI or logs: ```python theme={null} # ✅ DO: Use account_code for display account_code = account.account_code # "AB1234" print(f"Processing account {account_code}") # ❌ DON'T: Use UUID for display account_id = account.id # "550e8400-e29b-41d4-a716-446655440000" print(f"Processing account {account_id}") # Hard to read! ``` ## Summary | Concept | Key Takeaway | | --------------------------- | ---------------------------------------------------------------------------------------- | | **Account Model** | Accounts are isolated tenants with their own users, collections, and data | | **Account Identifiers** | UUID (id) for system, account\_code (XX####) for humans, slug for URLs, name for display | | **Data Isolation** | Row-level isolation via `account_id` column, enforced at multiple layers | | **Global Tables** | accounts, roles, permissions, collections, macros, migrations have no account\_id | | **Two-Tier Architecture** | Core system tables (release-only schema) + user collections (shared col\_\* tables) | | **System Account** | Uses nil UUID for ID, SY0000 for account\_code, reserved for superadmin operations | | **Multi-Account Users** | Same email can exist in multiple accounts with different passwords | | **Configuration Hierarchy** | System-level (nil UUID) defaults + account-level overrides | | **Developer Implications** | Never handle `account_id` manually; isolation is automatic; collections are global | # Realtime System Source: https://docs.snackbase.dev/concepts/realtime WebSocket and Server-Sent Events for live data updates SnackBase's **Realtime System** provides live data updates through WebSocket and Server-Sent Events (SSE), enabling your applications to react instantly to database changes without polling. ## Overview The realtime system broadcasts events when records are created, updated, or deleted in collections. Clients can subscribe to specific collections and receive push notifications as changes occur. ### Key Benefits * **Instant Updates**: No need to poll the server * **Reduced Bandwidth**: Only receive relevant data changes * **Account Isolation**: Events never cross account boundaries * **Flexible Subscriptions**: Subscribe to specific collections and operations * **Dual Protocol Support**: Choose WebSocket or SSE based on your needs ## How It Works ### Architecture ``` ┌─────────────────────────────────────────────────────────────┐ │ Client Layer │ │ ┌──────────────────┐ ┌──────────────────┐ │ │ │ WebSocket │ │ SSE │ │ │ │ Full-duplex │ │ One-way │ │ │ └────────┬─────────┘ └────────┬─────────┘ │ └───────────┼──────────────────────────┼─────────────────────┘ │ │ └──────────┬───────────────┘ │ ┌──────────────────────▼──────────────────────────────────────┐ │ Realtime Router │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ ConnectionManager │ │ │ │ - Active connections │ │ │ │ - Subscriptions per connection │ │ │ │ - Account isolation │ │ │ └──────────────────────────────────────────────────────┘ │ │ ┌──────────────────────────────────────────────────────┐ │ │ │ EventBroadcaster │ │ │ │ - Publish events to subscribers │ │ │ │ - Non-blocking async broadcast │ │ │ └──────────────────────────────────────────────────────┘ │ └──────────────────────┬──────────────────────────────────────┘ │ ┌──────────────────────▼──────────────────────────────────────┐ │ Record Router │ │ POST /api/v1/records/{collection} │ │ PATCH /api/v1/records/{collection}/{id} │ │ DELETE /api/v1/records/{collection}/{id} │ └──────────────────────┬──────────────────────────────────────┘ │ ┌──────────────────────▼──────────────────────────────────────┐ │ Database │ └─────────────────────────────────────────────────────────────┘ ``` ### Event Flow 1. A record is created, updated, or deleted via the REST API 2. The record operation completes successfully 3. `EventBroadcaster.publish_event()` is called with the event details 4. The event is broadcast to all active connections in the same account 5. Subscribers matching the collection and operation receive the event ## Event Format All realtime events follow this structure: ```json theme={null} { "type": "posts.create", "timestamp": "2026-01-17T12:34:56.789Z", "data": { "id": "550e8400-e29b-41d4-a716-446655440000", "title": "New Post", "status": "published", "created_at": "2026-01-17T12:34:56.789Z" } } ``` * **type**: `{collection}.{operation}` - The event type * **timestamp**: ISO 8601 timestamp of when the event occurred * **data**: The full record data after the operation ### Event Types | Type | Description | | --------------------- | ------------------------------ | | `{collection}.create` | A new record was created | | `{collection}.update` | An existing record was updated | | `{collection}.delete` | A record was deleted | ## WebSocket vs SSE Choose the protocol that fits your use case: ### WebSocket Full-duplex communication with bidirectional messaging. **Best for:** * Interactive applications (chat, collaboration) * Real-time games * Applications that need to send messages to the server **Advantages:** * Lower latency * Can send messages to server * More control over connection ```javascript theme={null} const ws = new WebSocket(`ws://localhost:8000/api/v1/realtime/ws?token=${token}`); ws.onopen = () => { ws.send(JSON.stringify({ action: "subscribe", collection: "posts" })); }; ws.onmessage = (event) => { const message = JSON.parse(event.data); console.log("Event:", message); }; ``` ### Server-Sent Events (SSE) One-way communication from server to client over HTTP. **Best for:** * Simple notifications * Live dashboards * Feed updates **Advantages:** * Simpler implementation * Automatic reconnection (handled by browser) * Native browser support ```javascript theme={null} const eventSource = new EventSource( `http://localhost:8000/api/v1/realtime/subscribe?token=${token}&collection=posts` ); eventSource.addEventListener("message", (event) => { const message = JSON.parse(event.data); console.log("Event:", message); }); ``` ## Subscriptions ### Subscribing to Collections **WebSocket:** ```javascript theme={null} ws.send(JSON.stringify({ action: "subscribe", collection: "posts", operations: ["create", "update", "delete"] // Optional })); ``` **SSE:** ``` http://localhost:8000/api/v1/realtime/subscribe?token={token}&collection=posts ``` ### Operation Filtering Subscribe to specific operations to reduce noise: ```javascript theme={null} ws.send(JSON.stringify({ action: "subscribe", collection: "posts", operations: ["create"] // Only receive create events })); ``` ### Multiple Collections Subscribe to multiple collections by creating multiple subscriptions: ```javascript theme={null} // WebSocket - send multiple subscribe messages ws.send(JSON.stringify({ action: "subscribe", collection: "posts" })); ws.send(JSON.stringify({ action: "subscribe", collection: "comments" })); // SSE - specify collection parameter multiple times const url = new URL("http://localhost:8000/api/v1/realtime/subscribe"); url.searchParams.set("token", token); url.searchParams.append("collection", "posts"); url.searchParams.append("collection", "comments"); const eventSource = new EventSource(url); ``` ## Authentication Realtime connections require authentication via JWT access token. ### Authentication Methods **Via Query Parameter** (recommended for WebSocket): ``` ws://localhost:8000/api/v1/realtime/ws?token=your_jwt_token ``` **Via Query Parameter** (SSE): ``` http://localhost:8000/api/v1/realtime/subscribe?token=your_jwt_token&collection=posts ``` **Via Authorization Header** (SSE only): ``` Authorization: Bearer your_jwt_token ``` ### Token Expiration When your access token expires (after 1 hour), the connection will be closed. Use your refresh token to obtain a new access token and reconnect. ## Connection Management ### Connection Limits * **Maximum 100 subscriptions** per WebSocket connection * **Heartbeat** sent every 30 seconds * **Connection closed** on authentication failure ### Reconnection Strategy Always implement reconnection logic: ```javascript theme={null} class ReconnectingRealtime { constructor(token) { this.token = token; this.reconnectDelay = 1000; this.maxReconnectDelay = 30000; } connect() { this.ws = new WebSocket(`ws://localhost:8000/api/v1/realtime/ws?token=${this.token}`); this.ws.onclose = () => { setTimeout(() => { this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay); this.connect(); }, this.reconnectDelay); }; this.ws.onopen = () => { this.reconnectDelay = 1000; // Reset delay // Resubscribe to collections }; } } ``` ### Heartbeat Handling Handle heartbeat messages to detect stale connections: ```javascript theme={null} let lastHeartbeat = Date.now(); ws.onmessage = (event) => { const message = JSON.parse(event.data); if (message.type === "heartbeat") { lastHeartbeat = Date.now(); return; } // Process data events }; // Check for stale connection setInterval(() => { if (Date.now() - lastHeartbeat > 60000) { console.warn("No heartbeat received, reconnecting"); ws.close(); this.connect(); } }, 60000); ``` ## Hook Integration The realtime system integrates with SnackBase's hook system. ### Realtime Hook Events | Event | Description | | ------------------------- | ------------------------------------------------- | | `on_realtime_connect` | Fired when a client connects | | `on_realtime_disconnect` | Fired when a client disconnects | | `on_realtime_subscribe` | Fired when a client subscribes to a collection | | `on_realtime_unsubscribe` | Fired when a client unsubscribes | | `on_realtime_message` | Fired when a message is received (WebSocket only) | ### Example: Logging Realtime Events ```python theme={null} @app.hook.on_realtime_connect() async def log_realtime_connection(connection_id, user_id, account_id): logger.info( "Realtime connection established", connection_id=connection_id, user_id=user_id, account_id=account_id ) @app.hook.on_realtime_subscribe() async def log_subscription(connection_id, user_id, collection): logger.info( "User subscribed to collection", connection_id=connection_id, user_id=user_id, collection=collection ) ``` ## Security Considerations 1. **Token Security**: Always use HTTPS in production to protect tokens 2. **Account Isolation**: Events never cross account boundaries 3. **Permission Validation**: While realtime broadcasts to all subscribers, your application should validate permissions on the client side 4. **Token Expiration**: Handle token expiration gracefully and reconnect with a new token ## Best Practices ### 1. Filter Events on the Server Use the `operations` parameter to filter events server-side: ```javascript theme={null} ws.send(JSON.stringify({ action: "subscribe", collection: "posts", operations: ["create", "update"] // Don't send delete events })); ``` ### 2. Use SSE for Simple Use Cases If you only need to receive events, SSE is simpler: * Automatic reconnection handled by browser * One-way communication (simpler API) * Built-in heartbeat support ### 3. Monitor Connection Health Handle heartbeat messages to detect stale connections ### 4. Limit Subscriptions Stay within the 100 subscription limit per connection ### 5. Implement Backoff Reconnection Use exponential backoff when reconnecting after failures ## API Endpoints ### WebSocket Endpoint ``` WS /api/v1/realtime/ws ``` ### SSE Endpoint ``` GET /api/v1/realtime/subscribe ``` See the [API Reference](/api-reference/endpoints/realtime/websocket-endpoint) for detailed documentation. # Security Model Source: https://docs.snackbase.dev/concepts/security Role-based access control, field-level permissions, and rule engine SnackBase provides a comprehensive security model with role-based access control, field-level permissions, and a powerful rule engine. This guide explains the security architecture, authorization flows, and best practices. ## Overview SnackBase security operates on **multiple layers** to ensure data protection: | Layer | Purpose | Mechanism | | ------------------------ | -------------------- | ------------------------------------- | | **Authentication** | Verify user identity | JWT tokens, Argon2id password hashing | | **Account Isolation** | Separate tenant data | Row-level filtering via `account_id` | | **Authorization** | Control user actions | RBAC + Permission system | | **Field-Level Security** | Hide sensitive data | Field-level access control | | **Audit Logging** | Track all actions | Immutable audit logs (coming soon) | ## Authentication vs Authorization Understanding the distinction is critical: | Aspect | Authentication | Authorization | | ------------------ | --------------------- | ------------------------- | | **Question** | Who are you? | What can you do? | | **Mechanism** | JWT tokens, passwords | Roles, permissions, rules | | **Timing** | Once per session | Every request | | **Failure Result** | 401 Unauthorized | 403 Forbidden | ### Example Scenario ``` Authentication (Who are you?): ├── User provides credentials ├── System verifies identity └── Result: "You are alice@acme.com" Authorization (What can you do?): ├── User requests DELETE /api/v1/posts/123 ├── System checks permissions ├── User has "editor" role ├── Editor role does NOT have "delete" permission └── Result: 403 Forbidden - "You cannot delete posts" ``` ## Role-Based Access Control (RBAC) SnackBase uses **RBAC** as the foundation of authorization. ### RBAC Hierarchy ``` Account (AB1001) │ ├── Users │ ├── alice@acme.com │ ├── bob@acme.com │ └── jane@acme.com │ ├── Roles │ ├── admin │ │ └── Permissions: All operations on all collections │ ├── editor │ │ └── Permissions: Create, Read, Update on posts only │ └── viewer │ └── Permissions: Read on posts only │ └── Role Assignments ├── alice@acme.com → admin ├── bob@acme.com → editor └── jane@acme.com → viewer ``` ### Default Roles | Role | Description | Typical Permissions | | ---------- | -------------------------- | -------------------------------------------- | | **admin** | Full administrative access | All operations on all collections | | **editor** | Content creator/manager | Create, Read, Update on specific collections | | **viewer** | Read-only access | Read on specific collections | ### Custom Roles You can create custom roles for any purpose: ```json theme={null} { "name": "moderator", "description": "Can moderate user-generated content", "permissions": [ { "collection": "comments", "create": false, "read": true, "update": true, "delete": true }, { "collection": "users", "create": false, "read": true, "update": false, "delete": false } ] } ``` ## Permission System Permissions define **what operations** a user can perform on **which collections**. ### Permission Matrix For a role with permissions: | Collection | Create | Read | Update | Delete | | ---------- | ------ | ---- | ------ | ------ | | posts | ✅ | ✅ | ✅ | ❌ | | comments | ✅ | ✅ | ✅ | ✅ | | users | ❌ | ✅ | ❌ | ❌ | ### Permission Structure ```json theme={null} { "id": "perm_abc123", "role_id": "role_editor", "collection": "posts", "create": true, "read": true, "update": true, "delete": false, "fields": ["title", "content", "status"], "rules": { "create": "@has_role('editor')", "update": "@owns_record() or @has_role('admin')" } } ``` ### Wildcard Collections Use `*` to grant permissions on **all collections**: ```json theme={null} { "role": "admin", "collection": "*", "create": true, "read": true, "update": true, "delete": true } ``` This grants admin full access to ALL collections, including future ones. ### Permission Caching Permissions are cached for **5 minutes** to improve performance: ``` ┌──────────────────┐ │ First Request │ │ Check permissions│ │ from database │ └────────┬─────────┘ │ ▼ ┌──────────────────┐ │ Cache for 5 min │ │ Subsequent │ │ requests use │ │ cached perms │ └────────┬─────────┘ │ ▼ ┌──────────────────┐ │ After 5 min or │ │ permission change│ │ Cache invalidated│ └──────────────────┘ ``` ## Rule Engine SnackBase includes a **powerful rule engine** for fine-grained access control. ### Rule Syntax Rules use a custom DSL (Domain Specific Language): ```python theme={null} # Simple comparisons user.id == "user_abc123" user.email == "admin@example.com" # Role checks @has_role("admin") @has_any_role(["admin", "moderator"]) # Record ownership @owns_record() # Field comparisons status in ["draft", "published"] priority >= 3 # Logical operators @has_role("admin") or @owns_record() @has_role("editor") and status == "draft" not status == "archived" # Complex expressions (@has_role("admin") or @owns_record()) and not status == "locked" ``` ### Built-in Functions | Function | Description | Example | | ------------------------ | --------------------------- | --------------------------------------- | | `@has_role(role)` | User has specific role | `@has_role("admin")` | | `@has_any_role([roles])` | User has any of these roles | `@has_any_role(["admin", "moderator"])` | | `@owns_record()` | User created this record | `@owns_record()` | | `@is_superadmin()` | User is superadmin | `@is_superadmin()` | ### Rule Evaluation Context Rules have access to: | Variable | Description | Example | | --------- | --------------------- | ------------------------------------ | | `user` | Current user object | `user.id`, `user.email` | | `record` | Record being accessed | `record.created_by`, `record.status` | | `context` | Request context | `context.account_id` | ### Permission Rules Example ```json theme={null} { "collection": "posts", "update": true, "rules": { "update": "(@owns_record() and status in ['draft', 'pending']) or @has_role('admin')" } } ``` **Translation**: Users can update posts if: * They created the post AND status is draft/pending, OR * They have admin role ## Field-Level Security SnackBase supports **field-level access control** to hide sensitive data. ### Field Visibility Restrict which fields a role can see: ```json theme={null} { "role": "viewer", "collection": "users", "read": true, "fields": ["name", "email"], "excluded_fields": ["phone", "ssn", "salary"] } ``` Users with this role will receive: ```json theme={null} // Response (excluded fields filtered out) { "id": "user_abc123", "name": "Alice Johnson", "email": "alice@example.com" // phone, ssn, salary NOT included } ``` ### Field-Level Rules Apply rules to specific fields: ```json theme={null} { "collection": "users", "field_rules": { "salary": { "read": "@has_role('admin') or @owns_record()", "write": "@has_role('hr') or @is_superadmin()" }, "email": { "read": "true", "write": "@has_role('admin')" } } } ``` ## Account Isolation Account isolation is the **foundation of SnackBase security**. ### Multi-Tenant Isolation All data is automatically isolated by `account_id`: ```sql theme={null} -- User from AB1001 requests posts SELECT * FROM posts WHERE account_id = 'AB1001'; -- User from XY2048 requests posts SELECT * FROM posts WHERE account_id = 'XY2048'; ``` Users cannot see or access data from other accounts. ### Enforcement Layers Account isolation is enforced at **multiple layers**: | Layer | Mechanism | Example | | ------------------ | ----------------------------------- | -------------------------- | | **Database** | `account_id` column in WHERE clause | `WHERE account_id = ?` | | **Repository** | Automatic filtering in queries | `posts.find_all(context)` | | **API Middleware** | Validates account in token | Token contains account\_id | | **Hooks** | Built-in account\_isolation\_hook | Cannot be disabled | ### Cross-Account Access Prevention Attempting to access another account's data: ```bash theme={null} # User from AB1001 tries to access XY2048 data GET /api/v1/posts?account_id=XY2048 # Result: 403 Forbidden # The account_id filter is overridden and reset to AB1001 ``` The system **ignores** malicious `account_id` parameters. ## Security Best Practices ### 1. Principle of Least Privilege Grant minimum required permissions: ```json theme={null} // ❌ Too permissive { "role": "viewer", "collection": "*", "delete": true // Viewers shouldn't delete! } // ✅ Correct { "role": "viewer", "collection": "posts", "read": true, "create": false, "update": false, "delete": false } ``` ### 2. Use Rules for Fine-Grained Control Leverage the rule engine for complex scenarios: ```json theme={null} { "rules": { "update": "@owns_record() or @has_role('admin')", "delete": "@has_role('admin') and not record.status == 'locked'" } } ``` ### 3. Implement Field-Level Security Hide sensitive fields by default: ```json theme={null} { "collection": "users", "excluded_fields": ["password_hash", "ssn", "salary"] } ``` ### 4. Regular Permission Audits Periodically review and update permissions: * Remove unused roles * Tighten overly permissive rules * Document permission rationale * Use audit logs (when available) to track access ### 5. Use Wildcards Carefully Wildcard permissions (`*`) are powerful but dangerous: ```json theme={null} // ⚠️ Use with caution { "collection": "*", "delete": true // Can delete from ALL collections! } // ✅ Prefer explicit collections { "collection": "posts", "delete": true } ``` ### 6. Test Permission Changes Always test permission changes in development: ```python theme={null} def test_editor_cannot_delete_posts(): editor_user = create_user(role="editor") client = login_as(editor_user) response = client.delete("/api/v1/posts/123") assert response.status_code == 403 ``` ### 7. Monitor and Alert Monitor for suspicious activity: * Repeated failed authorization attempts * Unusual access patterns * Permission escalation attempts * Cross-account access attempts ## Common Security Scenarios ### Scenario 1: User Can Only Edit Their Own Posts ```json theme={null} { "role": "author", "collection": "posts", "create": true, "read": true, "update": true, "delete": true, "rules": { "update": "@owns_record()", "delete": "@owns_record() and not status == 'published'" } } ``` ### Scenario 2: Moderators Can Edit All Comments ```json theme={null} { "role": "moderator", "collection": "comments", "create": false, "read": true, "update": true, "delete": true, "field_rules": { "author_ip": { "read": "@has_role('admin')" } } } ``` ### Scenario 3: Public Read, Private Write ```json theme={null} { "role": "anonymous", "collection": "posts", "read": true, "create": false, "update": false, "delete": false, "excluded_fields": ["draft_notes", "internal_status"] } ``` ## Security Headers SnackBase implements **defense-in-depth** security by automatically setting HTTP security headers on all responses. ### Automatic Headers All responses include the following security headers: | Header | Value | Purpose | | ----------------------------- | ------------------------------------- | -------------------------------------------------- | | **X-Content-Type-Options** | `nosniff` | Prevents MIME type sniffing attacks | | **X-Frame-Options** | `DENY` | Prevents clickjacking by blocking iframe embedding | | **X-XSS-Protection** | `1; mode=block` | Enables browser XSS protection (legacy browsers) | | **Strict-Transport-Security** | `max-age=31536000; includeSubDomains` | Enforces HTTPS (production only) | | **Content-Security-Policy** | Configurable | Prevents XSS and injection attacks | | **Permissions-Policy** | Configurable | Restricts browser features | | **Referrer-Policy** | `strict-origin-when-cross-origin` | Controls referrer information | ### Content Security Policy (CSP) The default CSP is designed for maximum security while supporting the Admin UI: ``` default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none' ``` **Key Directives**: * `default-src 'self'`: Only load resources from same origin * `script-src 'self'`: Block inline scripts and external scripts * `style-src 'self' 'unsafe-inline'`: Allow inline styles (for React) * `img-src 'self' data:`: Allow images from same origin and data URIs * `frame-ancestors 'none'`: Prevent iframe embedding ### Customization Customize security headers via environment variables: ```bash theme={null} # Customize CSP for external CDN SNACKBASE_CSP_POLICY="default-src 'self'; script-src 'self' https://cdn.example.com" # Adjust HSTS max-age SNACKBASE_HSTS_MAX_AGE=63072000 # 2 years # Customize Permissions Policy SNACKBASE_PERMISSIONS_POLICY="geolocation=(), camera=(), microphone=()" ``` ### Environment-Aware Behavior Security headers adapt to the environment: | Environment | HSTS Header | HTTPS Redirect | | --------------- | ------------------ | -------------------------- | | **Development** | ❌ Not set | ❌ Disabled | | **Production** | ✅ Set with max-age | ⚙️ Optional (configurable) | This prevents HSTS issues during local development while enforcing HTTPS in production. ## Summary | Concept | Key Takeaway | | ----------------------------------- | --------------------------------------------------------------------------------- | | **Security Layers** | Authentication → Account Isolation → Authorization → Field-Level Security → Audit | | **Authentication vs Authorization** | Authentication = Who are you? Authorization = What can you do? | | **RBAC** | Users → Roles → Permissions → Collections | | **Permission System** | CRUD permissions per collection, wildcard support, 5-minute cache | | **Rule Engine** | Custom DSL for fine-grained control with built-in functions | | **Field-Level Security** | Hide sensitive fields, field-specific rules | | **Account Isolation** | Automatic via account\_id, enforced at multiple layers | | **Best Practices** | Least privilege, use rules, hide sensitive data, audit permissions | # Outbound Webhooks Source: https://docs.snackbase.dev/concepts/webhooks Push notifications to external URLs when data changes in your collections SnackBase's **Outbound Webhooks** let you send HTTP notifications to external services whenever records are created, updated, or deleted. Instead of polling for changes, your external systems receive real-time push notifications. ## Overview Webhooks are configured per-collection and fire on specific events. When an event occurs, SnackBase sends a signed HTTP POST to your configured URL with the record data. ### Key Features * **Event-Driven**: Fire on `create`, `update`, or `delete` events * **HMAC-SHA256 Signing**: Every delivery is signed with a per-webhook secret * **Automatic Retries**: Failed deliveries are retried up to 5 times with exponential backoff * **Delivery Tracking**: Full history of every delivery attempt with status codes and response bodies * **Filter Expressions**: Conditionally fire webhooks based on record data * **Custom Headers**: Add custom HTTP headers to webhook deliveries ## How It Works ``` ┌──────────────┐ ┌────────────────────┐ ┌──────────────────┐ │ Record │ │ SnackBase │ │ Your Server │ │ Operation │────>│ Webhook Engine │────>│ (HTTPS endpoint)│ │ (create/ │ │ │ │ │ │ update/ │ │ 1. Match webhook │ │ Verify signature│ │ delete) │ │ 2. Evaluate filter│ │ Process payload │ │ │ │ 3. Sign payload │ │ │ └──────────────┘ └────────────────────┘ └──────────────────┘ ``` ## Webhook Configuration Each webhook is defined with: | Field | Type | Required | Description | | ------------ | --------- | -------- | ------------------------------------------------------------------- | | `url` | string | Yes | Destination URL (max 2048 chars). HTTPS required in production. | | `collection` | string | Yes | Collection to watch (max 100 chars) | | `events` | string\[] | Yes | Events to fire on: `"create"`, `"update"`, `"delete"` | | `secret` | string | No | HMAC signing secret. Auto-generated (64 hex chars) if not provided. | | `filter` | string | No | Rule expression to conditionally fire | | `headers` | object | No | Custom HTTP headers to include in deliveries | | `enabled` | boolean | No | Active status (default: `true`) | ### Event Types | Event | Fires When | | -------- | ----------------------------------------- | | `create` | A new record is created in the collection | | `update` | An existing record is updated | | `delete` | A record is deleted | ## Delivery Payload Every webhook delivery sends a JSON POST request with this structure: ```json theme={null} { "event": "records.create", "collection": "orders", "record": { "id": "abc-123", "status": "pending", "total": 99.99, "created_at": "2025-01-15T10:30:00Z" }, "previous": null, "timestamp": "2025-01-15T10:30:01Z", "webhook_id": "wh-uuid", "account_id": "AB1234" } ``` * **`record`**: The current state of the record (null for delete events) * **`previous`**: The previous state (only present for `update` and `delete` events) ## Security ### HMAC-SHA256 Signing Every delivery includes a signature header for verification: ``` X-SnackBase-Signature: sha256=a1b2c3d4e5f6... ``` The signature is computed as `HMAC-SHA256(secret, request_body)`. To verify on your server: ```python theme={null} import hmac import hashlib def verify_signature(payload: bytes, secret: str, signature_header: str) -> bool: expected = "sha256=" + hmac.new( secret.encode(), payload, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature_header) ``` The webhook secret is only returned once -- when the webhook is created. Store it securely. ### Additional Headers Every delivery also includes: | Header | Description | | ------------------------ | ----------------------------------- | | `Content-Type` | `application/json` | | `X-SnackBase-Signature` | HMAC-SHA256 signature | | `X-SnackBase-Event` | Event type (e.g., `records.create`) | | `X-SnackBase-Webhook-Id` | Webhook UUID | ### URL Validation In production, SnackBase enforces: * **HTTPS only** -- HTTP URLs are rejected * **No private IPs** -- Prevents SSRF attacks by blocking `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, and IPv6 equivalents ## Filter Expressions Use filter expressions to conditionally fire webhooks based on record data: ``` status = "published" rating >= 4.5 category IN ["electronics", "books"] title ~ "Product%" ``` If a filter expression fails to evaluate, the webhook fires anyway (fail-open design). This prevents silent data loss. ## Retry Logic Failed deliveries are automatically retried with exponential backoff: | Attempt | Delay | | --------- | ---------- | | 1st retry | 1 minute | | 2nd retry | 5 minutes | | 3rd retry | 30 minutes | | 4th retry | 2 hours | | 5th retry | 12 hours | A delivery is considered successful when it receives a 2xx HTTP response. Non-2xx responses or network errors trigger retries. ### Delivery Statuses | Status | Meaning | | ----------- | ------------------------------------ | | `pending` | Not yet delivered | | `delivered` | Successfully received (2xx response) | | `retrying` | Failed, scheduled for retry | | `failed` | All retry attempts exhausted | ## Testing Use the built-in test endpoint to verify your webhook setup: ```bash theme={null} curl -X POST https://api.snackbase.dev/api/v1/webhooks/{webhook_id}/test \ -H "Authorization: Bearer {token}" ``` This sends a test payload to your configured URL and returns the result immediately (synchronous). ## Limits | Limit | Default | | ------------------------- | ---------------------------- | | Max webhooks per account | 20 (configurable) | | URL max length | 2048 characters | | Response body storage | Truncated to 5000 characters | | HTTP timeout per delivery | 30 seconds | | Max retry attempts | 5 | ## When to Use Webhooks vs Other Features | Feature | Best For | | ---------------------------- | ----------------------------------------------- | | **Webhooks** | Push notifications to external services | | **API-Defined Hooks** | Internal automation (actions within SnackBase) | | **Workflows** | Multi-step processes with conditions and delays | | **Realtime (WebSocket/SSE)** | Client-side live updates | # Workflow Automation Source: https://docs.snackbase.dev/concepts/workflows Multi-step automation with branching, delays, and parallel execution SnackBase's **Workflow Engine** lets you build multi-step automation pipelines with conditional branching, timed delays, loops, and parallel execution -- all configured via the REST API. ## Overview A workflow consists of: 1. **Trigger** -- What starts the workflow (event, schedule, manual, or webhook) 2. **Steps** -- An ordered list of operations with branching and control flow 3. **Instances** -- Each execution creates a trackable instance with step-by-step logs ### Key Features * **Four Trigger Types**: Event, schedule, manual, and webhook * **Seven Step Types**: Action, condition, wait\_delay, wait\_condition, wait\_event, loop, parallel * **Instance Tracking**: Monitor running workflows with status, step logs, and error details * **Cancel and Resume**: Cancel running workflows or resume failed ones * **Template Variables**: Access trigger data and previous step outputs in any step ## Trigger Types ### Event Triggers Fire when a record or auth event occurs: ```json theme={null} { "trigger_type": "event", "trigger_config": { "type": "event", "event": "records.create", "collection": "orders", "condition": "total >= 100" } } ``` **Supported events:** `records.create`, `records.update`, `records.delete`, `auth.login`, `auth.register` ### Schedule Triggers Fire on a cron schedule: ```json theme={null} { "trigger_type": "schedule", "trigger_config": { "type": "schedule", "cron": "0 9 * * MON" } } ``` ### Manual Triggers Fire only via explicit API call: ```json theme={null} { "trigger_type": "manual", "trigger_config": { "type": "manual" } } ``` ```bash theme={null} curl -X POST https://api.snackbase.dev/api/v1/workflows/{id}/trigger \ -H "Authorization: Bearer {token}" \ -H "Content-Type: application/json" \ -d '{"customer_id": "cust-123"}' ``` ### Webhook Triggers Fire via an unauthenticated HTTP POST with a secret token: ```json theme={null} { "trigger_type": "webhook", "trigger_config": { "type": "webhook", "token": "auto-generated-32-char-secret" } } ``` The token is auto-generated on creation. Trigger with: ```bash theme={null} curl -X POST https://api.snackbase.dev/api/v1/workflow-webhooks/{token} \ -H "Content-Type: application/json" \ -d '{"source": "stripe", "event": "payment.completed"}' ``` Webhook trigger endpoints are unauthenticated. The security relies on the secret token. Treat it like a password. ## Step Types Steps are defined as an ordered list. Each step has a unique `name` and a `type`. ### Action Execute an operation (same action types as [API-Defined Hooks](/concepts/api-hooks)): ```json theme={null} { "name": "send_notification", "type": "action", "config": { "action_type": "send_webhook", "config": { "url": "https://slack.example.com/webhook", "body_template": { "text": "Order {{trigger.id}} created" } } } } ``` Supported actions: `send_webhook`, `send_email`, `create_record`, `update_record`, `delete_record`, `enqueue_job`. ### Condition Branch based on a rule expression: ```json theme={null} { "name": "check_value", "type": "condition", "config": { "expression": "trigger.total >= 500", "on_true": "high_value_flow", "on_false": "standard_flow" } } ``` Output: `{"result": true, "branch": "true"}` or `{"result": false, "branch": "false"}` ### Wait Delay Pause execution for a specified duration: ```json theme={null} { "name": "wait_24h", "type": "wait_delay", "config": { "duration": "24h", "next": "follow_up_step" } } ``` **Duration format:** `` where unit is `s` (seconds), `m` (minutes), `h` (hours), or `d` (days). The workflow instance transitions to `waiting` status. A background job resumes it after the delay. ### Loop Iterate over a list, executing a step for each item: ```json theme={null} { "name": "process_items", "type": "loop", "config": { "items": "{{trigger.line_items}}", "step": "process_single_item" } } ``` Output: `{"items_count": 3, "outputs": [...]}` ### Parallel Execute multiple branches concurrently: ```json theme={null} { "name": "parallel_notifications", "type": "parallel", "config": { "branches": [ ["send_email_step"], ["send_slack_step"], ["update_crm_step"] ] } } ``` Output: `{"branch_results": [[...], [...], [...]]}` If any branch fails, the entire parallel step fails. ### Wait Condition / Wait Event `wait_condition` and `wait_event` step types are defined in the schema but not yet implemented. They are skipped during execution. ## Template Variables All step configurations support template variables: | Variable | Description | | -------------------------------------- | ---------------------------------- | | `{{trigger.}}` | Data from the trigger context | | `{{steps..output.}}` | Output from a previous step | | `{{auth.user_id}}` | User ID (if authenticated trigger) | | `{{auth.email}}` | User email | | `{{now}}` | Current UTC timestamp (ISO 8601) | ## Workflow Instances Each execution creates a **workflow instance** that tracks progress: ### Instance Lifecycle ``` pending ──> running ──> completed ├──> failed ──> (resume) ──> running ├──> waiting ──> (resume after delay) ──> running └──> cancelled ``` ### Instance Statuses | Status | Meaning | | ----------- | ------------------------------------- | | `pending` | Created, not yet started | | `running` | Actively executing steps | | `waiting` | Paused on a `wait_delay` step | | `completed` | All steps finished successfully | | `failed` | A step failed (check `error_message`) | | `cancelled` | Manually cancelled by user | ### Instance Management **Cancel** a running or waiting instance: ```bash theme={null} curl -X POST https://api.snackbase.dev/api/v1/workflow-instances/{instance_id}/cancel \ -H "Authorization: Bearer {token}" ``` **Resume** a failed or waiting instance: ```bash theme={null} curl -X POST https://api.snackbase.dev/api/v1/workflow-instances/{instance_id}/resume \ -H "Authorization: Bearer {token}" ``` ## Step Logs Every step execution is logged with: | Field | Description | | --------------- | ------------------------------------------- | | `step_name` | Name of the step | | `step_type` | Type (action, condition, wait\_delay, etc.) | | `status` | `success`, `failed`, or `skipped` | | `input` | Snapshot of step inputs | | `output` | Step output data | | `error_message` | Error details (if failed) | | `started_at` | When the step began | | `completed_at` | When the step finished | ## Example: Order Processing Workflow ```json theme={null} { "name": "Order Processing", "trigger_type": "event", "trigger_config": { "type": "event", "event": "records.create", "collection": "orders" }, "steps": [ { "name": "check_value", "type": "condition", "config": { "expression": "trigger.total >= 500", "on_true": "high_value_alert", "on_false": "standard_confirm" } }, { "name": "high_value_alert", "type": "action", "config": { "action_type": "send_webhook", "config": { "url": "https://slack.example.com/webhook", "body_template": { "text": "High-value order #{{trigger.id}}: ${{trigger.total}}" } } } }, { "name": "standard_confirm", "type": "action", "config": { "action_type": "send_email", "config": { "to": "{{trigger.customer_email}}", "subject": "Order Confirmed", "template_name": "order_confirmation", "variables": { "order_id": "{{trigger.id}}" } } } }, { "name": "wait_for_processing", "type": "wait_delay", "config": { "duration": "2h", "next": "update_status" } }, { "name": "update_status", "type": "action", "config": { "action_type": "update_record", "config": { "collection": "orders", "record_id": "{{trigger.id}}", "data": { "status": "processing" } } } } ] } ``` ## Limits | Limit | Default | | ------------------------- | ------------------------ | | Max workflows per account | 50 (configurable) | | Max step execution depth | 5 | | Webhook token length | 32 characters (URL-safe) | # Deployment Guide Source: https://docs.snackbase.dev/deployment Deploy SnackBase in development and production environments This guide covers deploying SnackBase in development and production environments. ## Prerequisites ### Required Software * **Python**: 3.12 or higher * **uv**: Package manager ([installation guide](https://github.com/astral-sh/uv)) * **Database**: SQLite (development) or PostgreSQL (production recommended) ### Optional * **Docker**: For containerized deployment (coming soon) * **Nginx**: For reverse proxy in production * **systemd**: For service management on Linux ## Development Deployment ### Quick Start **1. Clone the repository**: ```bash theme={null} git clone cd SnackBase ``` **2. Install dependencies**: ```bash theme={null} uv sync ``` **3. Create environment file**: ```bash theme={null} cp .env.example .env # Edit .env with your settings ``` **4. Start the development server**: ```bash theme={null} uv run python -m snackbase serve --reload ``` **5. Access the application**: * API: [http://localhost:8000](http://localhost:8000) * Swagger UI: [http://localhost:8000/docs](http://localhost:8000/docs) * ReDoc: [http://localhost:8000/redoc](http://localhost:8000/redoc) ### Development Configuration Create a `.env` file in the project root: ```bash theme={null} # Application SNACKBASE_ENVIRONMENT=development SNACKBASE_DEBUG=true SNACKBASE_API_PREFIX=/api/v1 # Server SNACKBASE_HOST=0.0.0.0 SNACKBASE_PORT=8000 # Database (SQLite for development) SNACKBASE_DATABASE_URL=sqlite+aiosqlite:///./sb_data/snackbase.db # Security SNACKBASE_SECRET_KEY=dev-secret-key-change-in-production # CORS (allow localhost for development) SNACKBASE_CORS_ORIGINS=http://localhost:3000,http://localhost:8000 # Logging SNACKBASE_LOG_LEVEL=DEBUG SNACKBASE_LOG_FORMAT=console ``` ### Development Server Options ```bash theme={null} # Start with auto-reload (watches for file changes) uv run python -m snackbase serve --reload # Custom host and port uv run python -m snackbase serve --host 127.0.0.1 --port 3000 # Multiple workers (not recommended with --reload) uv run python -m snackbase serve --workers 4 # Using uvicorn directly uv run uvicorn snackbase.infrastructure.api.app:app --reload --port 8000 ``` ### Initialize Database The database is automatically initialized on first run. To manually initialize: ```bash theme={null} uv run python -m snackbase init-db ``` ### Development Tools ```bash theme={null} # Interactive Python shell with SnackBase context uv run python -m snackbase shell # View configuration uv run python -m snackbase info # Run tests uv run pytest # Run tests with coverage uv run pytest --cov=snackbase # Code formatting uv run ruff format . # Linting uv run ruff check . # Type checking uv run mypy src/ ``` ## Production Deployment ### Deployment Checklist * [ ] Set strong `SNACKBASE_SECRET_KEY` * [ ] Use PostgreSQL database * [ ] Set `SNACKBASE_ENVIRONMENT=production` * [ ] Set `SNACKBASE_DEBUG=false` * [ ] Configure proper CORS origins * [ ] Set up reverse proxy (Nginx) * [ ] Enable HTTPS/TLS * [ ] Configure log aggregation * [ ] Set up monitoring and alerts * [ ] Configure automated backups * [ ] Test health check endpoints ### Option 1: Direct Deployment (systemd) #### 1. Prepare the Server ```bash theme={null} # Update system sudo apt update && sudo apt upgrade -y # Install Python 3.12 sudo apt install python3.12 python3.12-venv -y # Install uv curl -LsSf https://astral.sh/uv/install.sh | sh # Create application user sudo useradd -m -s /bin/bash snackbase ``` #### 2. Deploy Application ```bash theme={null} # Switch to application user sudo su - snackbase # Clone repository git clone /home/snackbase/app cd /home/snackbase/app # Install dependencies uv sync --frozen # Create production environment file nano .env ``` #### 3. Production Environment Configuration ```bash theme={null} # Application SNACKBASE_ENVIRONMENT=production SNACKBASE_DEBUG=false SNACKBASE_API_PREFIX=/api/v1 # Server SNACKBASE_HOST=0.0.0.0 SNACKBASE_PORT=8000 # Database (PostgreSQL) SNACKBASE_DATABASE_URL=postgresql+asyncpg://snackbase:password@localhost/snackbase_prod # Security SNACKBASE_SECRET_KEY= SNACKBASE_ACCESS_TOKEN_EXPIRE_MINUTES=60 SNACKBASE_REFRESH_TOKEN_EXPIRE_DAYS=7 # CORS (restrict to your domains) SNACKBASE_CORS_ORIGINS=https://yourdomain.com,https://app.yourdomain.com # Logging SNACKBASE_LOG_LEVEL=INFO SNACKBASE_LOG_FORMAT=json # Storage SNACKBASE_STORAGE_PATH=/home/snackbase/app/sb_data/files ``` Generate a strong secret key: ```bash theme={null} python -c "import secrets; print(secrets.token_urlsafe(64))" ``` #### 4. Set Up PostgreSQL ```bash theme={null} # Install PostgreSQL sudo apt install postgresql postgresql-contrib -y # Create database and user sudo -u postgres psql << EOF CREATE DATABASE snackbase_prod; CREATE USER snackbase WITH PASSWORD 'your-secure-password'; GRANT ALL PRIVILEGES ON DATABASE snackbase_prod TO snackbase; \q EOF ``` #### 5. Create systemd Service Create `/etc/systemd/system/snackbase.service`: ```ini theme={null} [Unit] Description=SnackBase API Server After=network.target postgresql.service Requires=postgresql.service [Service] Type=notify User=snackbase Group=snackbase WorkingDirectory=/home/snackbase/app Environment="PATH=/home/snackbase/.local/bin:/usr/local/bin:/usr/bin:/bin" # Use uv to run the application ExecStart=/home/snackbase/.local/bin/uv run uvicorn snackbase.infrastructure.api.app:app \ --host 0.0.0.0 \ --port 8000 \ --workers 4 \ --log-config /home/snackbase/app/logging.json # Restart policy Restart=always RestartSec=10 # Security hardening NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=true ReadWritePaths=/home/snackbase/app/sb_data # Resource limits LimitNOFILE=65536 [Install] WantedBy=multi-user.target ``` #### 6. Start and Enable Service ```bash theme={null} # Reload systemd sudo systemctl daemon-reload # Start service sudo systemctl start snackbase # Enable on boot sudo systemctl enable snackbase # Check status sudo systemctl status snackbase # View logs sudo journalctl -u snackbase -f ``` ### Option 2: Nginx Reverse Proxy #### 1. Install Nginx ```bash theme={null} sudo apt install nginx -y ``` #### 2. Configure Nginx Create `/etc/nginx/sites-available/snackbase`: ```nginx theme={null} # Upstream to SnackBase upstream snackbase_backend { server 127.0.0.1:8000; } # Redirect HTTP to HTTPS server { listen 80; listen [::]:80; server_name api.yourdomain.com; return 301 https://$server_name$request_uri; } # HTTPS Server server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name api.yourdomain.com; # SSL Configuration ssl_certificate /etc/letsencrypt/live/api.yourdomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/api.yourdomain.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on; # Security Headers (Defense in Depth) # Note: SnackBase also sets these headers at the application level. # Nginx headers provide an additional layer of security. add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; # Logging access_log /var/log/nginx/snackbase_access.log; error_log /var/log/nginx/snackbase_error.log; # Client body size (for file uploads) client_max_body_size 100M; # Proxy to SnackBase location / { proxy_pass http://snackbase_backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # WebSocket support (for future real-time features) proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; # Timeouts proxy_connect_timeout 60s; proxy_send_timeout 60s; proxy_read_timeout 60s; } # Health check endpoint (bypass auth) location /health { proxy_pass http://snackbase_backend/health; access_log off; } } ``` #### 3. Enable Site and Restart Nginx ```bash theme={null} # Enable site sudo ln -s /etc/nginx/sites-available/snackbase /etc/nginx/sites-enabled/ # Test configuration sudo nginx -t # Restart Nginx sudo systemctl restart nginx ``` #### 4. Set Up SSL with Let's Encrypt ```bash theme={null} # Install Certbot sudo apt install certbot python3-certbot-nginx -y # Obtain certificate sudo certbot --nginx -d api.yourdomain.com # Auto-renewal is configured automatically # Test renewal sudo certbot renew --dry-run ``` ### Option 3: Docker Deployment You can deploy SnackBase using the included `Dockerfile` and `docker-compose.yml` (optional). #### 1. Build the Image ```bash theme={null} docker build -t snackbase . ``` #### 2. Run the Container ```bash theme={null} docker run -d \ -p 8000:8000 \ -v $(pwd)/sb_data:/app/sb_data \ --name snackbase \ snackbase ``` #### 3. Access Application The application will be available at [http://localhost:8000](http://localhost:8000). ## Database Configuration ### SQLite (Development Only) ```bash theme={null} SNACKBASE_DATABASE_URL=sqlite+aiosqlite:///./sb_data/snackbase.db ``` **Pros**: * Zero configuration * Perfect for development * File-based, easy to backup **Cons**: * Not suitable for production * Limited concurrency * No network access ### PostgreSQL (Recommended for Production) ```bash theme={null} SNACKBASE_DATABASE_URL=postgresql+asyncpg://user:password@host:port/database ``` **Setup**: ```bash theme={null} # Install PostgreSQL sudo apt install postgresql postgresql-contrib # Create database sudo -u postgres createdb snackbase_prod # Create user sudo -u postgres createuser snackbase -P # Grant privileges sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE snackbase_prod TO snackbase;" ``` **Connection Pooling**: SnackBase uses SQLAlchemy's async connection pooling. Configure in `.env`: ```bash theme={null} # Pool size (default: 5) SNACKBASE_DB_POOL_SIZE=10 # Max overflow (default: 10) SNACKBASE_DB_MAX_OVERFLOW=20 # Pool timeout (default: 30 seconds) SNACKBASE_DB_POOL_TIMEOUT=30 ``` ## Environment Variables ### Complete Reference | Variable | Default | Description | | --------------------------------------- | -------------------------------------------- | ---------------------------------------------- | | `SNACKBASE_ENVIRONMENT` | `development` | Environment: development, staging, production | | `SNACKBASE_DEBUG` | `false` | Enable debug mode | | `SNACKBASE_APP_NAME` | `SnackBase` | Application name | | `SNACKBASE_APP_VERSION` | `0.1.0` | Application version | | `SNACKBASE_API_PREFIX` | `/api/v1` | API route prefix | | `SNACKBASE_HOST` | `0.0.0.0` | Server bind address | | `SNACKBASE_PORT` | `8000` | Server port | | `SNACKBASE_DATABASE_URL` | `sqlite+aiosqlite:///./sb_data/snackbase.db` | Database connection URL | | `SNACKBASE_SECRET_KEY` | (auto-generated) | JWT signing key | | `SNACKBASE_ACCESS_TOKEN_EXPIRE_MINUTES` | `60` | Access token expiration | | `SNACKBASE_REFRESH_TOKEN_EXPIRE_DAYS` | `7` | Refresh token expiration | | `SNACKBASE_CORS_ORIGINS` | `*` | Allowed CORS origins (comma-separated) | | `SNACKBASE_CORS_ALLOW_CREDENTIALS` | `true` | Allow credentials in CORS | | `SNACKBASE_CORS_ALLOW_METHODS` | `*` | Allowed HTTP methods | | `SNACKBASE_CORS_ALLOW_HEADERS` | `*` | Allowed headers | | `SNACKBASE_LOG_LEVEL` | `INFO` | Logging level: DEBUG, INFO, WARNING, ERROR | | `SNACKBASE_LOG_FORMAT` | `json` | Log format: json, console | | `SNACKBASE_STORAGE_PATH` | `./sb_data/files` | File storage directory | | `SNACKBASE_AUDIT_LOGGING_ENABLED` | `true` | Enable GxP-compliant audit logging | | `SNACKBASE_SINGLE_TENANT_MODE` | `false` | Enable single-tenant mode | | `SNACKBASE_SINGLE_TENANT_ACCOUNT` | (none) | Target account slug for single-tenant mode | | `SNACKBASE_SINGLE_TENANT_ACCOUNT_NAME` | (none) | Optional display name for bootstrapped account | ## Health Checks SnackBase provides three health check endpoints: ### `/health` - Basic Health Check Returns 200 if the service is running. ```bash theme={null} curl http://localhost:8000/health ``` Response: ```json theme={null} { "status": "healthy", "service": "SnackBase", "version": "0.1.0", "audit_logging_enabled": true } ``` ### `/ready` - Readiness Check Returns 200 if the service is ready to accept requests (includes database connectivity). ```bash theme={null} curl http://localhost:8000/ready ``` Response: ```json theme={null} { "status": "ready", "service": "SnackBase", "version": "0.1.0", "database": "connected", "audit_logging_enabled": true } ``` ### `/live` - Liveness Check Returns 200 if the service is alive (simple ping). ```bash theme={null} curl http://localhost:8000/live ``` Response: ```json theme={null} { "status": "alive", "service": "SnackBase", "version": "0.1.0" } ``` ### Using Health Checks **systemd**: ```ini theme={null} [Service] ExecStartPost=/bin/sleep 5 ExecStartPost=/usr/bin/curl -f http://localhost:8000/ready || exit 1 ``` **Kubernetes** (future): ```yaml theme={null} livenessProbe: httpGet: path: /live port: 8000 initialDelaySeconds: 30 periodSeconds: 10 readinessProbe: httpGet: path: /ready port: 8000 initialDelaySeconds: 5 periodSeconds: 5 ``` ## Troubleshooting ### Service Won't Start **Check logs**: ```bash theme={null} sudo journalctl -u snackbase -n 50 --no-pager ``` **Common issues**: * Database connection failure * Port already in use * Missing environment variables * File permission issues ### Database Connection Errors **PostgreSQL**: ```bash theme={null} # Test connection psql -h localhost -U snackbase -d snackbase_prod # Check PostgreSQL is running sudo systemctl status postgresql ``` **SQLite**: ```bash theme={null} # Check file permissions ls -la sb_data/snackbase.db # Ensure directory exists mkdir -p sb_data ``` ### Permission Denied Errors ```bash theme={null} # Fix ownership sudo chown -R snackbase:snackbase /home/snackbase/app # Fix permissions chmod 755 /home/snackbase/app chmod 644 /home/snackbase/app/.env ``` ### High Memory Usage **Reduce worker count**: ```bash theme={null} # In systemd service file ExecStart=... --workers 2 ``` **Configure connection pool**: ```bash theme={null} # In .env SNACKBASE_DB_POOL_SIZE=5 SNACKBASE_DB_MAX_OVERFLOW=10 ``` ### Slow API Responses **Check database**: ```bash theme={null} # PostgreSQL query performance sudo -u postgres psql snackbase_prod -c "SELECT * FROM pg_stat_activity;" ``` **Enable query logging**: ```bash theme={null} # In .env SNACKBASE_LOG_LEVEL=DEBUG ``` ### CORS Errors **Update CORS origins**: ```bash theme={null} # In .env SNACKBASE_CORS_ORIGINS=https://yourdomain.com,https://app.yourdomain.com ``` **Check Nginx configuration**: ```bash theme={null} # Ensure proxy headers are set proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; ``` ## Monitoring and Maintenance ### Log Management **View logs**: ```bash theme={null} # systemd logs sudo journalctl -u snackbase -f # Application logs (if file-based) tail -f /home/snackbase/app/logs/snackbase.log ``` **Log rotation** (if using file-based logging): Create `/etc/logrotate.d/snackbase`: ``` /home/snackbase/app/logs/*.log { daily rotate 14 compress delaycompress notifempty create 0640 snackbase snackbase sharedscripts postrotate systemctl reload snackbase > /dev/null 2>&1 || true endscript } ``` ### Performance Monitoring **System resources**: ```bash theme={null} # CPU and memory htop # Disk usage df -h # Database size du -sh sb_data/ ``` **Application metrics** (future): * Prometheus integration * Grafana dashboards * Custom metrics endpoint ### Backup Strategy **Database backup** (PostgreSQL): ```bash theme={null} # Manual backup pg_dump -U snackbase snackbase_prod > backup_$(date +%Y%m%d_%H%M%S).sql # Automated backup (cron) 0 2 * * * pg_dump -U snackbase snackbase_prod | gzip > /backups/snackbase_$(date +\%Y\%m\%d).sql.gz ``` **File storage backup**: ```bash theme={null} # Backup uploaded files tar -czf files_backup_$(date +%Y%m%d).tar.gz sb_data/files/ ``` Automated backup/restore commands will be added in Phase 5 (F5.10) ## Security Best Practices 1. **Use strong secret keys**: Generate with `secrets.token_urlsafe(64)` 2. **Enable HTTPS**: Use Let's Encrypt for free SSL certificates 3. **Restrict CORS**: Only allow trusted domains 4. **Use PostgreSQL**: SQLite is not suitable for production 5. **Regular updates**: Keep dependencies up to date 6. **Monitor logs**: Set up log aggregation and alerts 7. **Firewall**: Only expose necessary ports (80, 443) 8. **Database security**: Use strong passwords, restrict network access 9. **File permissions**: Ensure proper ownership and permissions 10. **Rate limiting**: Add Nginx rate limiting (future enhancement) ### Security Headers SnackBase automatically sets security headers on all HTTP responses to protect against common web vulnerabilities: * **X-Content-Type-Options**: Prevents MIME type sniffing * **X-Frame-Options**: Prevents clickjacking attacks * **X-XSS-Protection**: Enables browser XSS protection * **Strict-Transport-Security** (production only): Enforces HTTPS * **Content-Security-Policy**: Prevents XSS and injection attacks * **Permissions-Policy**: Restricts browser features * **Referrer-Policy**: Controls referrer information #### Customizing Security Headers You can customize security headers via environment variables: ```bash theme={null} # Disable security headers (not recommended) SNACKBASE_SECURITY_HEADERS_ENABLED=false # Customize HSTS max-age (default: 31536000 = 1 year) SNACKBASE_HSTS_MAX_AGE=63072000 # 2 years # Customize Content Security Policy SNACKBASE_CSP_POLICY="default-src 'self'; script-src 'self' https://cdn.example.com" # Customize Permissions Policy SNACKBASE_PERMISSIONS_POLICY="geolocation=(), camera=(), microphone=()" # Enable HTTP to HTTPS redirect in production (optional) SNACKBASE_HTTPS_REDIRECT_ENABLED=true ``` **Note**: The default CSP policy is designed to work with the SnackBase Admin UI. If you're using a custom frontend with external CDNs or inline scripts, you may need to adjust the CSP policy. #### Troubleshooting CSP Issues If your frontend is blocked by CSP: 1. **Check browser console**: Look for CSP violation errors 2. **Identify blocked resources**: Note the source and type (script, style, image, etc.) 3. **Update CSP policy**: Add the necessary sources to the appropriate directive 4. **Test thoroughly**: Ensure all functionality works after CSP changes Example CSP for frontend with external CDN: ````bash theme={null} SNACKBASE_CSP_POLICY="default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self'" --- ## Single-Tenant Mode SnackBase supports a "Single-Tenant Mode" where the entire instance behaves as a dedicated application for a single account. ### How it works When enabled: 1. All new users automatically join the specified `SNACKBASE_SINGLE_TENANT_ACCOUNT`. 2. Login and Registration endpoints no longer require an `account` identifier (though it's still accepted). 3. The first user to register becomes the admin of that account. ### Configuration ```bash SNACKBASE_SINGLE_TENANT_MODE=true SNACKBASE_SINGLE_TENANT_ACCOUNT=my-awesome-app SNACKBASE_SINGLE_TENANT_ACCOUNT_NAME="My Awesome App" ```` ### Authentication Flow In single-tenant mode, the account is resolved server-side: ```bash theme={null} # Registration (no account field needed) curl -X POST http://localhost:8000/api/v1/auth/register \ -d '{"email": "user@example.com", "password": "password123"}' # Login (no account field needed) curl -X POST http://localhost:8000/api/v1/auth/login \ -d '{"email": "user@example.com", "password": "password123"}' ``` You can customize security headers via environment variables: ```bash theme={null} # Disable security headers (not recommended) SNACKBASE_SECURITY_HEADERS_ENABLED=false # Customize HSTS max-age (default: 31536000 = 1 year) SNACKBASE_HSTS_MAX_AGE=63072000 # 2 years # Customize Content Security Policy SNACKBASE_CSP_POLICY="default-src 'self'; script-src 'self' https://cdn.example.com" # Customize Permissions Policy SNACKBASE_PERMISSIONS_POLICY="geolocation=(), camera=(), microphone=()" # Enable HTTP to HTTPS redirect in production (optional) SNACKBASE_HTTPS_REDIRECT_ENABLED=true ``` The default CSP policy is designed to work with the SnackBase Admin UI. If you're using a custom frontend with external CDNs or inline scripts, you may need to adjust the CSP policy. #### Troubleshooting CSP Issues If your frontend is blocked by CSP: 1. **Check browser console**: Look for CSP violation errors 2. **Identify blocked resources**: Note the source and type (script, style, image, etc.) 3. **Update CSP policy**: Add the necessary sources to the appropriate directive 4. **Test thoroughly**: Ensure all functionality works after CSP changes Example CSP for frontend with external CDN: ```bash theme={null} SNACKBASE_CSP_POLICY="default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self'" ``` ## Next Steps After deployment: 1. **Create first account**: Use the `/api/v1/auth/register` endpoint 2. **Test API**: Use Swagger UI at `/docs` 3. **Create collections**: Use the `/api/v1/collections` endpoint 4. **Set up monitoring**: Configure log aggregation and alerts 5. **Configure backups**: Set up automated database backups 6. **Review security**: Follow security best practices checklist # Adding API Endpoints Source: https://docs.snackbase.dev/guides/adding-api-endpoints Learn how to add new API endpoints to SnackBase following established patterns and architecture This guide explains how to add new API endpoints to SnackBase, following the established patterns and architecture. ## Overview SnackBase uses **FastAPI** for REST API endpoints. All endpoints are organized in the `src/snackbase/infrastructure/api/routes/` directory. ### Existing API Routers | Router | Purpose | Path | | ----------------------- | --------------------- | ------------------------ | | `auth_router.py` | Authentication | `/api/v1/auth/*` | | `accounts_router.py` | Account management | `/api/v1/accounts/*` | | `users_router.py` | User management | `/api/v1/users/*` | | `roles_router.py` | Role management | `/api/v1/roles/*` | | `permissions_router.py` | Permission management | `/api/v1/permissions/*` | | `collections_router.py` | Collection CRUD | `/api/v1/collections/*` | | `records_router.py` | Dynamic record CRUD | `/api/v1/{collection}/*` | | `groups_router.py` | Group management | `/api/v1/groups/*` | | `invitations_router.py` | User invitations | `/api/v1/invitations/*` | | `macros_router.py` | SQL macros | `/api/v1/macros/*` | | `dashboard_router.py` | Dashboard stats | `/api/v1/dashboard/*` | | `audit_log_router.py` | Audit logs | `/api/v1/audit-logs/*` | | `migrations_router.py` | DB migrations | `/api/v1/migrations/*` | ## Architecture Review ### Layer Structure ``` Request -> API Router -> Service -> Repository -> Database ``` | Layer | Responsibility | Location | | -------------- | ------------------------------------ | ------------------------------------------------ | | **API Router** | HTTP handling, validation, responses | `infrastructure/api/routes/` | | **Service** | Business logic, orchestration | `domain/services/` or `infrastructure/services/` | | **Repository** | Data access, database queries | `infrastructure/persistence/repositories/` | | **Database** | Data storage | SQLAlchemy models | ### Clean Architecture Principles * **Routers** handle HTTP concerns (status codes, headers, parsing) * **Services** contain business logic * **Repositories** abstract database access * **Models** (Pydantic) define request/response schemas ## Where to Add Endpoints ### Decision Tree ``` Does endpoint handle dynamic collections? | +-- Yes --> Modify records_router.py | +-- No --> Does it fit existing router? | +-- Yes --> Add to existing router | +-- No --> Create new router ``` ### When to Create a New Router Create a new router when: * Adding a new major feature area * Existing routers don't match the domain concept * The feature has 3+ related endpoints Examples of when to create new routers: * `/api/v1/webhooks/*` - Webhook management * `/api/v1/scheduled-tasks/*` - Task scheduling * `/api/v1/integrations/*` - Third-party integrations ## Step-by-Step Guide Let's add a new feature: **Tags** for organizing records. ### Step 1: Define Pydantic Schemas Create request/response models in `infrastructure/api/schemas/`: ```python theme={null} # src/snackbase/infrastructure/api/schemas/tags.py from pydantic import BaseModel, ConfigDict from datetime import datetime class TagBase(BaseModel): name: str color: str | None = None class TagCreate(TagBase): pass class TagUpdate(TagBase): name: str | None = None color: str | None = None class TagResponse(TagBase): model_config = ConfigDict(from_attributes=True) id: str account_id: str created_at: datetime updated_at: datetime created_by: str | None = None ``` ### Step 2: Create Database Model Add SQLAlchemy model in `infrastructure/persistence/models/`: ```python theme={null} # src/snackbase/infrastructure/persistence/models/tag.py from sqlalchemy import Column, String, DateTime from sqlalchemy.orm import Mapped, mapped_column from src.snackbase.infrastructure.persistence.database import Base class Tag(Base): __tablename__ = "tags" id: Mapped[str] = mapped_column(String(50), primary_key=True) account_id: Mapped[str] = mapped_column(String(10), nullable=False, index=True) name: Mapped[str] = mapped_column(String(255), nullable=False) color: Mapped[str | None] = mapped_column(String(7), nullable=True) # Hex color created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) created_by: Mapped[str | None] = mapped_column(String(50), nullable=True) ``` ### Step 3: Create Repository Add repository in `infrastructure/persistence/repositories/`: ```python theme={null} # src/snackbase/infrastructure/persistence/repositories/tag_repository.py from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from src.snackbase.infrastructure.persistence.models.tag import Tag from src.snackbase.core.context import Context class TagRepository: def __init__(self, session: AsyncSession): self._session = session async def create(self, tag: Tag) -> Tag: self._session.add(tag) await self._session.flush() return tag async def get_by_id(self, tag_id: str, context: Context) -> Tag | None: result = await self._session.execute( select(Tag).where(Tag.id == tag_id, Tag.account_id == context.account_id) ) return result.scalar_one_or_none() async def find_all(self, context: Context) -> list[Tag]: result = await self._session.execute( select(Tag).where(Tag.account_id == context.account_id) ) return list(result.scalars().all()) async def update(self, tag: Tag) -> Tag: await self._session.flush() return tag async def delete(self, tag: Tag) -> None: await self._session.delete(tag) await self._session.flush() ``` ### Step 4: Create API Router Create router in `infrastructure/api/routes/`: ```python theme={null} # src/snackbase/infrastructure/api/routes/tags_router.py from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from src.snackbase.infrastructure.api.dependencies import ( get_db, get_context, require_permission, ) from src.snackbase.infrastructure.api.schemas.tags import ( TagCreate, TagUpdate, TagResponse, ) from src.snackbase.core.context import Context from src.snackbase.infrastructure.persistence.repositories.tag_repository import TagRepository from src.snackbase.infrastructure.persistence.models.tag import Tag from src.snackbase.core.id_generator import generate_id router = APIRouter(prefix="/tags", tags=["tags"]) @router.post("/", response_model=TagResponse, status_code=status.HTTP_201_CREATED) async def create_tag( tag_data: TagCreate, context: Context = Depends(get_context), db: AsyncSession = Depends(get_db), ): """Create a new tag.""" repo = TagRepository(db) tag = Tag( id=generate_id("tag"), account_id=context.account_id, name=tag_data.name, color=tag_data.color, created_at=datetime.nowUTC(), updated_at=datetime.nowUTC(), created_by=context.user_id, ) created = await repo.create(tag) return TagResponse.model_validate(created) @router.get("/", response_model=list[TagResponse]) async def list_tags( context: Context = Depends(get_context), db: AsyncSession = Depends(get_db), ): """List all tags for the current account.""" repo = TagRepository(db) tags = await repo.find_all(context) return [TagResponse.model_validate(tag) for tag in tags] @router.get("/{tag_id}", response_model=TagResponse) async def get_tag( tag_id: str, context: Context = Depends(get_context), db: AsyncSession = Depends(get_db), ): """Get a specific tag by ID.""" repo = TagRepository(db) tag = await repo.get_by_id(tag_id, context) if not tag: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Tag not found" ) return TagResponse.model_validate(tag) @router.put("/{tag_id}", response_model=TagResponse) async def update_tag( tag_id: str, tag_data: TagUpdate, context: Context = Depends(get_context), db: AsyncSession = Depends(get_db), ): """Update a tag.""" repo = TagRepository(db) tag = await repo.get_by_id(tag_id, context) if not tag: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Tag not found" ) # Update fields if tag_data.name is not None: tag.name = tag_data.name if tag_data.color is not None: tag.color = tag_data.color tag.updated_at = datetime.nowUTC() updated = await repo.update(tag) return TagResponse.model_validate(updated) @router.delete("/{tag_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_tag( tag_id: str, context: Context = Depends(get_context), db: AsyncSession = Depends(get_db), ): """Delete a tag.""" repo = TagRepository(db) tag = await repo.get_by_id(tag_id, context) if not tag: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Tag not found" ) await repo.delete(tag) ``` ### Step 5: Register Router Add the router to `app.py`: ```python theme={null} # src/snackbase/infrastructure/api/app.py from src.snackbase.infrastructure.api.routes.tags_router import router as tags_router # ... existing imports app = FastAPI(title="SnackBase") # ... existing middleware # Register routers app.include_router(auth_router, prefix=API_PREFIX, tags=["auth"]) app.include_router(accounts_router, prefix=API_PREFIX, tags=["accounts"]) # ... existing routers # NEW: Add tags router BEFORE records_router app.include_router(tags_router, prefix=API_PREFIX, tags=["tags"]) # IMPORTANT: records_router must be LAST (catches /{collection}) app.include_router(records_router, prefix=API_PREFIX, tags=["records"]) ``` The `records_router` must be registered LAST because it uses dynamic route matching (/) that will catch any unmatched paths. ### Step 6: Create Migration Generate and apply database migration: ```bash theme={null} # Generate migration uv run alembic revision --autogenerate -m "Add tags table" # Apply migration uv run alembic upgrade head ``` ### Step 7: Add Permissions (Optional) If your feature needs authorization, add permissions: ```python theme={null} # In roles management UI or via API { "role": "admin", "collection": "tags", "create": true, "read": true, "update": true, "delete": true } ``` ## Request/Response Patterns ### Request Body Validation Use Pydantic for automatic validation: ```python theme={null} from pydantic import BaseModel, Field, field_validator class TagCreate(BaseModel): name: str = Field(..., min_length=1, max_length=255) color: str | None = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$') @field_validator('name') @classmethod def name_must_not_be_empty(cls, v: str) -> str: if not v.strip(): raise ValueError('name cannot be empty or whitespace') return v.strip() ``` ### Response Formatting Use consistent response formats: ```python theme={null} # Success response { "id": "tag_abc123", "name": "Important", "color": "#ff0000", "account_id": "AB1001", "created_at": "2025-01-01T00:00:00Z", "updated_at": "2025-01-01T00:00:00Z" } # Error response { "detail": "Tag not found" } ``` ### Pagination For list endpoints, support pagination: ```python theme={null} from fastapi import Query from typing import Optional @router.get("/", response_model=list[TagResponse]) async def list_tags( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), context: Context = Depends(get_context), db: AsyncSession = Depends(get_db), ): repo = TagRepository(db) tags = await repo.find_all(context, skip=skip, limit=limit) return [TagResponse.model_validate(tag) for tag in tags] ``` ## Authentication & Authorization ### Require Authentication All endpoints automatically require authentication via `get_context()`: ```python theme={null} from src.snackbase.infrastructure.api.dependencies import get_context from src.snackbase.core.context import Context @router.get("/tags") async def list_tags( context: Context = Depends(get_context), # Ensures valid token db: AsyncSession = Depends(get_db), ): # context.user_id, context.account_id available pass ``` ### Require Permissions Use `require_permission()` for authorization: ```python theme={null} from src.snackbase.infrastructure.api.dependencies import require_permission @router.delete("/tags/{tag_id}") async def delete_tag( tag_id: str, authorized: bool = Depends(require_permission("tags", "delete")), context: Context = Depends(get_context), db: AsyncSession = Depends(get_db), ): # Only executes if user has "tags:delete" permission pass ``` ### Superadmin-Only Endpoints For superadmin-only endpoints: ```python theme={null} from src.snackbase.infrastructure.api.dependencies import require_superadmin @router.post("/accounts") async def create_account( account_data: AccountCreate, is_superadmin: bool = Depends(require_superadmin), db: AsyncSession = Depends(get_db), ): # Only superadmins can access pass ``` ## Testing Endpoints ### Unit Tests Test router logic: ```python theme={null} # tests/unit/test_tags_router.py import pytest from fastapi.testclient import TestClient from src.snackbase.infrastructure.api.app import app client = TestClient(app) def test_create_tag_requires_auth(): response = client.post("/api/v1/tags/", json={"name": "Test"}) assert response.status_code == 401 def test_create_tag_success(superadmin_token): response = client.post( "/api/v1/tags/", headers={"Authorization": f"Bearer {superadmin_token}"}, json={"name": "Test", "color": "#ff0000"} ) assert response.status_code == 201 assert response.json()["name"] == "Test" ``` ### Integration Tests Test full flow with database: ```python theme={null} # tests/integration/test_tags_integration.py import pytest from httpx import AsyncClient, ASGITransport from src.snackbase.infrastructure.api.app import app @pytest.mark.asyncio async def test_create_and_retrieve_tag(db_session): async with AsyncClient( transport=ASGITransport(app=app), base_url="http://test" ) as client: # Create tag create_response = await client.post( "/api/v1/tags/", json={"name": "Test Tag"} ) assert create_response.status_code == 201 tag_id = create_response.json()["id"] # Retrieve tag get_response = await client.get(f"/api/v1/tags/{tag_id}") assert get_response.status_code == 200 assert get_response.json()["name"] == "Test Tag" ``` ### Manual Testing with Swagger UI Visit `http://localhost:8000/docs` to test endpoints interactively. ## Best Practices ### 1. Use Appropriate Status Codes | Code | Usage | Example | | ---- | ------------------------- | -------------------------- | | 200 | Success (GET, PUT, PATCH) | Tag retrieved successfully | | 201 | Created (POST) | Tag created successfully | | 204 | No Content (DELETE) | Tag deleted successfully | | 400 | Bad Request | Validation error | | 401 | Unauthorized | Missing or invalid token | | 403 | Forbidden | Insufficient permissions | | 404 | Not Found | Tag doesn't exist | | 422 | Unprocessable Entity | Invalid request data | ### 2. Account Isolation Always filter by `account_id` in repositories: ```python theme={null} # ❌ BAD: No account filtering async def get_by_id(self, tag_id: str) -> Tag | None: result = await self._session.execute( select(Tag).where(Tag.id == tag_id) ) # ✅ GOOD: Account filtering async def get_by_id(self, tag_id: str, context: Context) -> Tag | None: result = await self._session.execute( select(Tag).where(Tag.id == tag_id, Tag.account_id == context.account_id) ) ``` ### 3. Use Dependency Injection Inject dependencies via FastAPI's `Depends()`: ```python theme={null} # ❌ BAD: Manual dependency handling @router.get("/tags/{tag_id}") async def get_tag(tag_id: str, token: str): context = decode_token(token) # Manual db = get_db() # Manual ... # ✅ GOOD: Dependency injection @router.get("/tags/{tag_id}") async def get_tag( tag_id: str, context: Context = Depends(get_context), # Automatic db: AsyncSession = Depends(get_db), # Automatic ): ... ``` ### 4. Handle Errors Gracefully ```python theme={null} @router.get("/tags/{tag_id}") async def get_tag( tag_id: str, context: Context = Depends(get_context), db: AsyncSession = Depends(get_db), ): repo = TagRepository(db) tag = await repo.get_by_id(tag_id, context) if not tag: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Tag {tag_id} not found" ) return TagResponse.model_validate(tag) ``` ### 5. Document Endpoints Use FastAPI's docstring support: ```python theme={null} @router.post( "/", response_model=TagResponse, status_code=status.HTTP_201_CREATED, summary="Create a new tag", description="Creates a new tag for the current account. Tags can be used to organize and categorize records.", responses={ 201: {"description": "Tag created successfully"}, 400: {"description": "Invalid request data"}, 401: {"description": "Unauthorized"}, } ) async def create_tag( tag_data: TagCreate, context: Context = Depends(get_context), db: AsyncSession = Depends(get_db), ): """Create a new tag.""" ... ``` ## Summary | Step | Action | Location | | ---- | ----------------------- | ------------------------------------------ | | 1 | Define Pydantic schemas | `infrastructure/api/schemas/` | | 2 | Create SQLAlchemy model | `infrastructure/persistence/models/` | | 3 | Create repository | `infrastructure/persistence/repositories/` | | 4 | Create API router | `infrastructure/api/routes/` | | 5 | Register router | `infrastructure/api/app.py` | | 6 | Create migration | Alembic | | 7 | Add permissions | Via UI or API | | 8 | Write tests | `tests/unit/`, `tests/integration/` | ## Related Guides * [Testing Guide](./testing) * [Extending SnackBase](./extending-snackbase) * [Creating Custom Hooks](./creating-custom-hooks) # Building Workflows Source: https://docs.snackbase.dev/guides/building-workflows Step-by-step guide to creating multi-step automation workflows This guide walks you through building a multi-step workflow that processes events, makes decisions, and performs actions automatically. ## Prerequisites * A running SnackBase instance * At least one collection (we'll use `orders` in this example) * Familiarity with [Workflow Automation concepts](/concepts/workflows) ## Building Your First Workflow Plan the steps before creating: ``` Order Created └─> Check if high value (>= $500) ├─ Yes: Send Slack alert └─ No: Send confirmation email └─> Wait 2 hours └─> Update order status to "processing" ``` ```ts theme={null} const workflow = await client.workflows.create({ name: "Order Processing", trigger_type: "event", trigger_config: { type: "event", event: "records.create", collection: "orders", }, steps: [ { name: "check_value", type: "condition", config: { expression: "trigger.total >= 500", on_true: "high_value_alert", on_false: "send_confirmation", }, }, { name: "high_value_alert", type: "action", config: { action_type: "send_webhook", config: { url: "https://hooks.slack.com/services/...", body_template: { text: "High-value order #{{trigger.id}}: ${{trigger.total}}", }, }, }, }, { name: "send_confirmation", type: "action", config: { action_type: "send_email", config: { to: "{{trigger.customer_email}}", subject: "Order Confirmed", template_name: "order_confirmation", variables: { order_id: "{{trigger.id}}" }, }, }, }, { name: "wait_before_processing", type: "wait_delay", config: { duration: "2h", next: "update_status", }, }, { name: "update_status", type: "action", config: { action_type: "update_record", config: { collection: "orders", record_id: "{{trigger.id}}", data: { status: "processing" }, }, }, }, ], }); ``` ```bash theme={null} curl -X POST https://api.snackbase.dev/api/v1/workflows \ -H "Authorization: Bearer {token}" \ -H "Content-Type: application/json" \ -d '{ "name": "Order Processing", "trigger_type": "event", "trigger_config": { "type": "event", "event": "records.create", "collection": "orders" }, "steps": [ { "name": "check_value", "type": "condition", "config": { "expression": "trigger.total >= 500", "on_true": "high_value_alert", "on_false": "send_confirmation" } } ] }' ``` Before relying on events, test with manual trigger: ```ts theme={null} const result = await client.workflows.trigger(workflow.id, { id: "test-order-1", total: 750, customer_email: "test@example.com", }); console.log("Instance ID:", result.instance_id); ``` Check the instance status and step logs: ```ts theme={null} const instance = await client.workflows.getInstance(result.instance_id); console.log("Status:", instance.status); console.log("Current step:", instance.current_step); for (const log of instance.step_logs) { console.log(` ${log.step_name} (${log.step_type}): ${log.status}`); if (log.output) console.log(" Output:", log.output); if (log.error_message) console.log(" Error:", log.error_message); } ``` If an instance fails, investigate and resume: ```ts theme={null} // List failed instances const failed = await client.workflows.listInstances(workflow.id, { status: "failed", }); // Resume a failed instance if (failed.items.length > 0) { await client.workflows.retryInstance(failed.items[0].id); } ``` Or cancel an instance that shouldn't continue: ```ts theme={null} await client.workflows.cancelInstance(instance.id); ``` ## Advanced Patterns ### Webhook-Triggered Workflows Accept events from external services (e.g., Stripe, GitHub): ```ts theme={null} const workflow = await client.workflows.create({ name: "Stripe Payment Handler", trigger_type: "webhook", trigger_config: { type: "webhook" }, steps: [ { name: "process_payment", type: "action", config: { action_type: "update_record", config: { collection: "orders", record_id: "{{trigger.metadata.order_id}}", data: { payment_status: "paid" }, }, }, }, ], }); // The webhook token is in workflow.trigger_config.token // External services POST to: /api/v1/workflow-webhooks/{token} ``` ### Parallel Notifications Send multiple notifications simultaneously: ```ts theme={null} { name: "notify_all", type: "parallel", config: { branches: [ ["send_email_step"], ["send_slack_step"], ["update_crm_step"] ] } } ``` ### Loops Process each item in a list: ```ts theme={null} { name: "process_line_items", type: "loop", config: { items: "{{trigger.line_items}}", step: "process_single_item" } } ``` ## Troubleshooting * Verify the workflow is `enabled` * Check `trigger_config.event` matches the actual event * If using `collection`, verify the collection name is correct * Check if a `condition` in the trigger config is filtering out events This is normal for `wait_delay` steps. The instance will resume automatically after the delay. You can also manually resume it via the API. Check the `error_message` in the step log. Common causes: * Invalid template variable references * External service unavailable (for webhook actions) * Record not found (for update/delete actions) ## Next Steps * [Workflow Automation Concept](/concepts/workflows) -- full reference * [Workflows API Reference](/api-reference/endpoints/workflows/create-workflow) -- all endpoints * [Workflows SDK Reference](/sdk/js/services/workflows) -- SDK methods # Creating Custom Endpoints Source: https://docs.snackbase.dev/guides/creating-custom-endpoints Step-by-step guide to building serverless-like HTTP endpoints This guide walks you through creating custom HTTP endpoints that execute action pipelines -- without writing backend code. ## Prerequisites * A running SnackBase instance * At least one collection with data * Familiarity with [Custom Endpoints concepts](/concepts/custom-endpoints) ## Building Your First Endpoint Plan what the endpoint does: ``` GET /api/v1/x/customers/:customer_id/summary 1. Query recent orders for this customer 2. Calculate total spend 3. Return combined summary ``` ```ts theme={null} const endpoint = await client.endpoints.create({ name: "Customer Summary", path: "/customers/:customer_id/summary", method: "GET", auth_required: true, actions: [ { type: "query_records", config: { collection: "orders", filter: "customer_id = '{{request.params.customer_id}}'", sort: "-created_at", limit: 5, }, }, { type: "aggregate_records", config: { collection: "orders", function: "sum", field: "total", filter: "customer_id = '{{request.params.customer_id}}'", }, }, { type: "transform", config: { output: { recent_orders: "{{actions[0].result}}", lifetime_spend: "{{actions[1].result}}", }, }, }, ], response_template: { status: 200, body: "{{actions[2].result}}", }, }); ``` ```bash theme={null} curl -X POST https://api.snackbase.dev/api/v1/endpoints \ -H "Authorization: Bearer {token}" \ -H "Content-Type: application/json" \ -d '{ "name": "Customer Summary", "path": "/customers/:customer_id/summary", "method": "GET", "auth_required": true, "actions": [ { "type": "query_records", "config": { "collection": "orders", "filter": "customer_id = '\''{{request.params.customer_id}}'\''", "sort": "-created_at", "limit": 5 } } ] }' ``` Call your new endpoint: ```bash theme={null} curl https://api.snackbase.dev/api/v1/x/customers/cust-123/summary \ -H "Authorization: Bearer {token}" ``` Expected response: ```json theme={null} { "recent_orders": [...], "lifetime_spend": { "value": 2450.00 } } ``` Use a condition expression to restrict access: ```ts theme={null} const endpoint = await client.endpoints.update(endpoint.id, { condition: "@has_role('admin') or auth.user_id = request.params.customer_id", }); ``` If the condition evaluates to `false`, the endpoint returns 403 Forbidden. Track how your endpoint is being used: ```ts theme={null} const executions = await client.endpoints.listExecutions(endpoint.id); for (const exec of executions.items) { console.log(`${exec.status} - ${exec.duration_ms}ms`); } ``` ## More Examples ### Form Submission Endpoint Accept form data and create a record: ```ts theme={null} await client.endpoints.create({ name: "Submit Feedback", path: "/submit-feedback", method: "POST", auth_required: false, // Public endpoint actions: [ { type: "create_record", config: { collection: "feedback", data: { message: "{{request.body.message}}", email: "{{request.body.email}}", submitted_at: "{{now}}", }, }, }, ], response_template: { status: 201, body: { message: "Thank you for your feedback!" }, }, }); ``` ### Dashboard Data Endpoint Aggregate data for a dashboard: ```ts theme={null} await client.endpoints.create({ name: "Sales Dashboard", path: "/dashboard/sales", method: "GET", auth_required: true, condition: "@has_role('admin')", actions: [ { type: "aggregate_records", config: { collection: "orders", function: "count", filter: "status = 'completed'", }, }, { type: "aggregate_records", config: { collection: "orders", function: "sum", field: "total", filter: "status = 'completed'", }, }, { type: "aggregate_records", config: { collection: "orders", function: "sum", field: "total", filter: "status = 'completed'", group_by: "category", }, }, { type: "transform", config: { output: { total_orders: "{{actions[0].result}}", total_revenue: "{{actions[1].result}}", revenue_by_category: "{{actions[2].result}}", }, }, }, ], response_template: { status: 200, body: "{{actions[3].result}}", }, }); ``` ## Troubleshooting * Verify the endpoint is `enabled` * Check the URL format: `/api/v1/x/{path}` (note the `/x/` prefix) * Confirm the HTTP method matches (GET, POST, etc.) * Check path parameter syntax uses `:param` format * The `condition` expression evaluated to `false` * If `auth_required` is `true`, ensure you're sending a valid auth token * Another endpoint with the same `path` and `method` already exists * Each (path, method) combination must be unique per account Endpoints have a 30-second execution timeout. If your action pipeline is complex, consider: * Reducing query `limit` values * Simplifying filter expressions * Breaking complex logic into separate endpoints ## Next Steps * [Custom Endpoints Concept](/concepts/custom-endpoints) -- full reference * [Endpoints API Reference](/api-reference/endpoints/custom-endpoints/create-endpoint) -- all endpoints * [Endpoints SDK Reference](/sdk/js/services/endpoints) -- SDK methods # Creating Custom Hooks Source: https://docs.snackbase.dev/guides/creating-custom-hooks Learn how to create custom hooks in SnackBase to extend functionality and automate workflows This guide explains how to create custom hooks in SnackBase to extend functionality and automate workflows. This guide covers **code-level hooks** written in Python. If you want to create hooks via the REST API without writing code, see [API-Defined Hooks](/concepts/api-hooks) and the [Hooks SDK Service](/sdk/js/services/hooks). ## Overview Hooks allow you to **execute custom code** in response to events within SnackBase. They're the primary extension mechanism for adding custom business logic. ### What Can Hooks Do? | Capability | Example | | ------------------------------- | ----------------------------------------- | | **Send Notifications** | Email when record is created | | **Transform Data** | Auto-generate slugs from titles | | **Integrate External Services** | Call webhook on record update | | **Enforce Business Rules** | Prevent deletion of published posts | | **Audit Changes** | Log all modifications to sensitive fields | | **Sync Data** | Replicate changes to external systems | ### Hook System Stability The hook registration mechanism is a **STABLE API contract** (v1.0). This means: * Hook registration syntax won't change in breaking ways * Existing hooks will continue to work in future versions * New hook types will be additive, not breaking changes The hook system API is stable and guaranteed to maintain backward compatibility. ## Hook System Review ### Hook Registration Pattern Hooks are registered using the `app.hook` decorator: ```python theme={null} from src.snackbase.infrastructure.api.app import app @app.hook.on_record_after_create("posts", priority=10) async def send_post_notification(record: dict, context: Context): """Send notification when post is created.""" await notification_service.send( user_id=record["created_by"], message="Your post has been published!" ) ``` ### Built-in Hooks SnackBase includes built-in hooks that **cannot be disabled**: | Hook | Purpose | Event | | ------------------------ | ---------------------------------- | --------------------- | | `timestamp_hook` | Auto-set `created_at`/`updated_at` | All record operations | | `account_isolation_hook` | Enforce `account_id` filtering | All record queries | | `created_by_hook` | Set `created_by` user ID | Record creation | ## Hook Categories Hooks are organized into **8 categories**: ### 1. App Lifecycle Hooks | Event | Description | When | | ----------------- | -------------- | --------------------- | | `on_app_startup` | App starts up | Server initialization | | `on_app_shutdown` | App shuts down | Server shutdown | ### 2. Model Operation Hooks | Event | Description | When | | ------------------------ | ------------------- | ---------------------- | | `on_model_before_create` | Before model insert | Before database insert | | `on_model_after_create` | After model insert | After database insert | | `on_model_before_update` | Before model update | Before database update | | `on_model_after_update` | After model update | After database update | | `on_model_before_delete` | Before model delete | Before database delete | | `on_model_after_delete` | After model delete | After database delete | ### 3. Record Operation Hooks | Event | Description | When | | ------------------------- | ----------------------- | ------------------------------- | | `on_record_before_create` | Before record creation | Before inserting dynamic record | | `on_record_after_create` | After record creation | After inserting dynamic record | | `on_record_before_update` | Before record update | Before updating dynamic record | | `on_record_after_update` | After record update | After updating dynamic record | | `on_record_before_delete` | Before record deletion | Before deleting dynamic record | | `on_record_after_delete` | After record deletion | After deleting dynamic record | | `on_record_before_query` | Before querying records | Before executing query | | `on_record_after_query` | After querying records | After executing query | ### 4. Collection Operation Hooks | Event | Description | When | | ----------------------------- | -------------------------- | -------------------------- | | `on_collection_before_create` | Before collection creation | Before creating collection | | `on_collection_after_create` | After collection creation | After creating collection | | `on_collection_before_update` | Before collection update | Before updating collection | | `on_collection_after_update` | After collection update | After updating collection | | `on_collection_before_delete` | Before collection deletion | Before deleting collection | | `on_collection_after_delete` | After collection deletion | After deleting collection | ### 5. Auth Operation Hooks | Event | Description | When | | ------------------------- | ------------------- | ----------------------------- | | `on_auth_before_login` | Before login | Before validating credentials | | `on_auth_after_login` | After login | After successful login | | `on_auth_before_logout` | Before logout | Before logout processing | | `on_auth_after_logout` | After logout | After logout processing | | `on_auth_before_register` | Before registration | Before creating user | | `on_auth_after_register` | After registration | After creating user | ### 6. User Operation Hooks | Event | Description | When | | ----------------------- | -------------------- | -------------------- | | `on_user_before_create` | Before user creation | Before creating user | | `on_user_after_create` | After user creation | After creating user | | `on_user_before_update` | Before user update | Before updating user | | `on_user_after_update` | After user update | After updating user | | `on_user_before_delete` | Before user deletion | Before deleting user | | `on_user_after_delete` | After user deletion | After deleting user | ### 7. Request Processing Hooks | Event | Description | When | | ------------------- | ------------------------- | --------------------- | | `on_request_before` | Before request processing | At start of request | | `on_request_after` | After request processing | At end of request | | `on_request_error` | On request error | When exception occurs | ### 8. Custom Event Hooks | Event | Description | When | | ----------------- | ------------ | ------------------ | | `on_custom_event` | Custom event | Triggered manually | ## Step-by-Step Guide Let's create a custom hook that **sends a Slack notification** when a post is published. ### Step 1: Create Hook File Create hooks in a dedicated file: ```python theme={null} # src/snackbase/infrastructure/hooks/custom_hooks.py import httpx from sqlalchemy.ext.asyncio import AsyncSession from src.snackbase.core.context import Context from src.snackbase.infrastructure.api.app import app from src.snackbase.core.config import settings SLACK_WEBHOOK_URL = settings.slack_webhook_url @app.hook.on_record_after_update("posts", priority=20) async def notify_slack_on_publish( record: dict, context: Context, old_record: dict | None = None, db: AsyncSession = None ): """ Send Slack notification when post status changes to 'published'. Trigger: After updating a post record Condition: status changed to 'published' """ # Check if status changed to 'published' if old_record and record.get("status") == "published": if old_record.get("status") != "published": await _send_slack_notification(record) async def _send_slack_notification(post: dict): """Send notification to Slack.""" message = { "text": f"New post published: {post.get('title')}", "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": f"*New Post Published*\n*Title:* {post.get('title')}\n*Author:* " } } ] } async with httpx.AsyncClient() as client: await client.post(SLACK_WEBHOOK_URL, json=message) ``` ### Step 2: Import Hooks Import your hooks file in `app.py` to register them: ```python theme={null} # src/snackbase/infrastructure/api/app.py # ... existing imports # Import custom hooks (this registers them) from src.snackbase.infrastructure.hooks.custom_hooks import ( notify_slack_on_publish, ) # ... rest of app.py ``` ### Step 3: Configure Environment Add required configuration to `.env`: ```bash theme={null} # Slack webhook URL SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBHOOK/URL ``` ### Step 4: Test the Hook ```bash theme={null} # Update a post to published curl -X PUT http://localhost:8000/api/v1/posts/post_abc123 \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "status": "published" }' # Check Slack for notification ``` ## Hook Context ### Hook Parameters Hooks receive different parameters based on their type: ```python theme={null} @app.hook.on_record_after_create("posts") async def my_hook( record: dict, # The record being created/updated/deleted context: Context, # Request context (user_id, account_id) old_record: dict | None = None, # Previous state (for updates) db: AsyncSession = None, # Database session (optional) ): pass ``` ### Context Object The `context` object provides request information: | Field | Type | Description | | --------------- | ------ | -------------------------- | | `user_id` | `str` | Current user ID | | `account_id` | `str` | Current account ID | | `request_id` | `str` | Correlation ID for tracing | | `is_superadmin` | `bool` | Whether user is superadmin | ## Advanced Features ### Priority Control Hooks execute in priority order (higher priority = earlier execution): ```python theme={null} @app.hook.on_record_after_create("posts", priority=100) async def high_priority_hook(record: dict, context: Context): """Executes first.""" pass @app.hook.on_record_after_create("posts", priority=10) async def low_priority_hook(record: dict, context: Context): """Executes after high_priority_hook.""" pass @app.hook.on_record_after_create("posts", priority=0) async def default_priority_hook(record: dict, context: Context): """Executes last.""" pass ``` ### Conditional Execution Only execute hooks based on conditions: ```python theme={null} @app.hook.on_record_after_update("posts") async def conditional_hook( record: dict, context: Context, old_record: dict | None = None ): """Only execute for specific conditions.""" # Only if specific field changed if old_record and record.get("status") != old_record.get("status"): # Status changed pass # Only for specific account if context.account_id == "AB1001": # Special handling for this account pass # Only if specific field value if record.get("category") == "urgent": # Handle urgent posts pass ``` ### Error Handling Handle errors gracefully in hooks: ```python theme={null} import logging logger = logging.getLogger(__name__) @app.hook.on_record_after_create("posts") async def safe_hook(record: dict, context: Context): """Hook with error handling.""" try: await external_api_call(record) except httpx.HTTPError as e: # Log error but don't fail the request logger.error(f"Hook failed: {e}", exc_info=True) # Optionally: send alert except Exception as e: # Unexpected error logger.critical(f"Unexpected hook error: {e}", exc_info=True) raise # Re-raise if critical ``` ### Aborting Operations Some `before_*` hooks can abort operations: ```python theme={null} from src.snackbase.core.exceptions import HookAbortException @app.hook.on_record_before_delete("posts") async def prevent_published_deletion( record: dict, context: Context ): """Prevent deletion of published posts.""" if record.get("status") == "published": raise HookAbortException( message="Cannot delete published posts", status_code=400 ) ``` ### Async Database Operations Hooks can perform database operations: ```python theme={null} from sqlalchemy import select @app.hook.on_record_after_create("posts") async def create_audit_log( record: dict, context: Context, db: AsyncSession ): """Create audit log entry.""" from src.snackbase.infrastructure.persistence.models.audit_log import AuditLog log = AuditLog( id=generate_id("audit"), account_id=context.account_id, user_id=context.user_id, action="create", collection="posts", record_id=record["id"], changes=record, timestamp=datetime.nowUTC() ) db.add(log) await db.commit() ``` ## Best Practices ### 1. Keep Hooks Focused Each hook should do one thing well: ```python theme={null} # ❌ BAD: Hook doing too much @app.hook.on_record_after_create("posts") async def mega_hook(record: dict, context: Context): await send_slack_notification(record) await send_email_notification(record) await update_search_index(record) await create_audit_log(record) await invalidate_cache(record) # ✅ GOOD: Separate, focused hooks @app.hook.on_record_after_create("posts", priority=50) async def send_slack_notification(record: dict, context: Context): await slack_service.notify(record) @app.hook.on_record_after_create("posts", priority=40) async def send_email_notification(record: dict, context: Context): await email_service.notify(record) @app.hook.on_record_after_create("posts", priority=30) async def update_search_index(record: dict, context: Context): await search_service.index(record) ``` ### 2. Use Priority Wisely Set appropriate priorities for execution order: | Priority | Use Case | | ----------- | ----------------------------------------- | | 100+ | Critical validation that should run first | | 50-99 | Core business logic | | 10-49 | Notifications and integrations | | 1-9 | Logging and analytics | | 0 (default) | Cleanup and finalization | ### 3. Handle Idempotency Make hooks idempotent when possible: ```python theme={null} # ❌ NOT idempotent @app.hook.on_record_after_create("posts") async def send_notification(record: dict, context: Context): await notification_service.send(record["created_by"], "Post created") # Will send duplicate if hook runs twice # ✅ Idempotent @app.hook.on_record_after_create("posts") async def send_notification(record: dict, context: Context): await notification_service.send( user_id=record["created_by"], message=f"Post {record['id']} created", deduplication_key=f"post_created:{record['id']}" ) ``` ### 4. Log Hook Execution Add logging for debugging: ```python theme={null} import logging logger = logging.getLogger(__name__) @app.hook.on_record_after_create("posts") async def logged_hook(record: dict, context: Context): logger.info( "Hook executed", extra={ "hook": "logged_hook", "record_id": record.get("id"), "user_id": context.user_id, "account_id": context.account_id } ) # ... hook logic ``` ### 5. Avoid Blocking Operations Keep hooks fast and non-blocking: ```python theme={null} # ❌ BAD: Blocking operation @app.hook.on_record_after_create("posts") async def slow_hook(record: dict, context: Context): result = await slow_external_api_call(timeout=30) # Blocks response # User waits 30 seconds for response # ✅ GOOD: Fire and forget @app.hook.on_record_after_create("posts") async def fast_hook(record: dict, context: Context): # Queue for background processing await background_queue.enqueue( "slow_operation", record_id=record["id"] ) # Returns immediately ``` ### 6. Test Hooks Write tests for your hooks: ```python theme={null} # tests/unit/test_custom_hooks.py import pytest from unittest.mock import AsyncMock @pytest.mark.asyncio async def test_slack_notification_on_publish(db_session, context): """Test Slack notification is sent when post is published.""" # Mock Slack service slack_service.send = AsyncMock() # Simulate hook execution from src.snackbase.infrastructure.hooks.custom_hooks import notify_slack_on_publish old_record = {"status": "draft"} new_record = {"status": "published", "title": "Test Post"} await notify_slack_on_publish( record=new_record, context=context, old_record=old_record, db=db_session ) # Verify Slack was called slack_service.send.assert_called_once() ``` ## Examples ### Example 1: Auto-Generate Slugs ```python theme={null} import re @app.hook.on_record_before_create("posts") async def generate_slug( record: dict, context: Context ): """Auto-generate slug from title if not provided.""" if "title" in record and "slug" not in record: title = record["title"] # Convert to slug slug = re.sub(r"[^\w\s-]", "", title.lower()) slug = re.sub(r"[-\s]+", "-", slug) record["slug"] = slug ``` ### Example 2: Enforce Validation ```python theme={null} from src.snackbase.core.exceptions import HookAbortException @app.hook.on_record_before_create("posts") async def validate_post_content( record: dict, context: Context ): """Ensure posts have minimum content length.""" content = record.get("content", "") if len(content) < 50: raise HookAbortException( message="Post content must be at least 50 characters", status_code=400 ) ``` ### Example 3: Sync to External System ```python theme={null} import httpx @app.hook.on_record_after_update("products") async def sync_to_external_crm( record: dict, context: Context, old_record: dict | None = None ): """Sync product changes to external CRM.""" if old_record: # Only sync if fields changed changed_fields = [ k for k in record if k in old_record and record[k] != old_record[k] ] if changed_fields: async with httpx.AsyncClient() as client: await client.put( f"https://crm.example.com/api/products/{record['id']}", json=record, headers={"Authorization": f"Bearer {settings.crm_api_key}"} ) ``` ### Example 4: Track Field Changes ```python theme={null} @app.hook.on_record_after_update("users") async def track_email_changes( record: dict, context: Context, old_record: dict | None = None ): """Log when user email changes.""" if old_record and record.get("email") != old_record.get("email"): await audit_service.log( user_id=context.user_id, action="email_changed", details={ "old_email": old_record.get("email"), "new_email": record.get("email") } ) ``` ## Summary | Concept | Key Takeaway | | ------------------- | ----------------------------------------------------------------------------------- | | **Hook Categories** | 8 categories: App Lifecycle, Model, Record, Collection, Auth, User, Request, Custom | | **Registration** | Use `@app.hook.on_event_name()` decorator | | **Priority** | Higher priority = earlier execution (0-100+) | | **Context** | Hooks receive record, context, old\_record, db parameters | | **Error Handling** | Log errors, handle gracefully, use HookAbortException to abort | | **Best Practices** | Keep focused, use priority wisely, ensure idempotency, log execution | ## Related Guides * [Hooks Reference](../hooks) * [Adding API Endpoints](./adding-api-endpoints) * [Testing Guide](./testing) # Extending SnackBase Source: https://docs.snackbase.dev/guides/extending-snackbase Learn various ways to extend SnackBase functionality beyond the core features This guide provides an overview of the various ways to extend SnackBase functionality beyond the core features. ## Overview SnackBase is designed to be **extensible** at multiple levels, allowing you to add custom functionality without modifying core code. ### Extension Philosophy | Principle | Description | | --------------------------------- | ------------------------------------------------------- | | **Composition over Modification** | Add functionality through composition, not core changes | | **Stable APIs** | Extension points have stable contracts | | **Pluggable** | Features can be added/removed without affecting core | | **Backward Compatible** | Extensions survive SnackBase upgrades | ### What Can Be Extended? | Area | Extension Method | | ------------------ | ------------------------------- | | **Business Logic** | Hooks, custom services | | **API Endpoints** | New routers, middleware | | **Data Models** | Custom tables, extended schemas | | **Authentication** | Custom auth providers, MFA | | **Storage** | Custom storage backends | | **Notifications** | Custom notification channels | | **Validation** | Custom validators, rules | ## Extension Methods ### 1. Hooks (Recommended) **Best for**: Business logic, event-driven automation, integrations ```python theme={null} from src.snackbase.infrastructure.api.app import app @app.hook.on_record_after_create("posts") async def custom_logic(record: dict, context: Context): """Custom business logic after post creation.""" # Your code here pass ``` **Pros:** * Stable API (v1.0 contract) * Automatic event triggering * Account isolation built-in * No core modifications **Cons:** * Limited to defined events * Can't add new API endpoints ### 2. Custom API Endpoints **Best for**: New features, external integrations, custom operations ```python theme={null} from fastapi import APIRouter from src.snackbase.infrastructure.api.app import app custom_router = APIRouter(prefix="/custom", tags=["custom"]) @custom_router.get("/analytics") async def get_analytics(context: Context = Depends(get_context)): """Custom analytics endpoint.""" return {"analytics": "data"} # Register in app.py app.include_router(custom_router, prefix=API_PREFIX) ``` **Pros:** * Full control over endpoint * Can access all SnackBase services * Leverage existing authentication **Cons:** * Must manually handle permissions * Requires more code ### 3. Custom Database Tables **Best for**: Domain-specific data, complex relationships ```python theme={null} # Create migration # alembic/versions/xxx_add_custom_table.py def upgrade(): op.create_table( "custom_features", sa.Column("id", sa.String(50), primary_key=True), sa.Column("account_id", sa.String(10), nullable=False, index=True), sa.Column("name", sa.String(255), nullable=False), sa.Column("config", sa.JSON, nullable=True), sa.ForeignKeyConstraint(["account_id"], ["accounts.id"]) ) ``` **Pros:** * Full SQL control * Complex relationships * Migrations versioned **Cons:** * Requires manual migrations * Not auto-integrated with collections ### 4. Middleware **Best for**: Request/response processing, logging, custom auth ```python theme={null} from src.snackbase.infrastructure.api.app import app @app.middleware("http") async def custom_middleware(request: Request, call_next): """Custom middleware for all requests.""" # Pre-processing start_time = time.time() # Call next middleware/route response = await call_next(request) # Post-processing duration = time.time() - start_time response.headers["X-Process-Time"] = str(duration) return response ``` **Pros:** * Runs on every request * Can modify requests/responses * Good for cross-cutting concerns **Cons:** * Adds latency to all requests * Must be carefully designed ### 5. Custom Services **Best for**: Reusable business logic, external integrations ```python theme={null} # src/snackbase/infrastructure/services/custom_service.py from typing import Any class CustomAnalyticsService: """Custom analytics service.""" def __init__(self, db: AsyncSession): self._db = db async def calculate_metrics(self, account_id: str) -> dict[str, Any]: """Calculate custom metrics for account.""" # Your logic here return {"metrics": "data"} # Use in routes @router.get("/analytics") async def get_analytics( context: Context = Depends(get_context), db: AsyncSession = Depends(get_db) ): service = CustomAnalyticsService(db) return await service.calculate_metrics(context.account_id) ``` **Pros:** * Encapsulates logic * Reusable across endpoints * Testable in isolation **Cons:** * More initial code ## Choosing the Right Approach ### Decision Tree ``` What do you want to do? | +-- Add business logic to existing operations | +--> Use Hooks | +-- Create new API endpoints | +--> Create Custom Router | +-- Store custom data structures | | | +-- Simple, per-record metadata | | +--> Use JSON field in existing collection | | | +-- Complex, relational data | +--> Create Custom Table | +-- Modify all requests/responses | +--> Use Middleware | +-- Integrate external services +--> Create Custom Service ``` ### Comparison Matrix | Method | Use Case | Complexity | Survives Updates | Example | | --------------------- | ------------------ | ---------- | ---------------- | --------------------------- | | **Hooks** | Event-driven logic | Low | Yes | Send notification on create | | **Custom Router** | New endpoints | Medium | Yes | Custom analytics API | | **Custom Table** | Complex data | High | Yes | Audit log storage | | **Middleware** | Request/response | Medium | Maybe | Custom logging | | **Core Modification** | Framework changes | Very High | No | Changing auth flow | Modifying core files will cause conflicts when updating SnackBase. Always use extension methods instead. ## Extension Points ### 1. Database Layer Extend the data layer: ```python theme={null} # Custom repository class CustomRepository: def __init__(self, session: AsyncSession): self._session = session async def custom_query(self, account_id: str) -> list[dict]: """Custom database query.""" result = await self._session.execute( select(CustomTable).where( CustomTable.account_id == account_id ) ) return [row.__dict__ for row in result.scalars()] ``` ### 2. Service Layer Add business logic services: ```python theme={null} # src/snackbase/domain/services/analytics_service.py class AnalyticsService: """Analytics business logic.""" async def generate_report( self, account_id: str, date_range: DateRange ) -> Report: """Generate analytics report.""" # Business logic here pass ``` ### 3. API Layer Extend the API: ```python theme={null} # Custom router with authentication from src.snackbase.infrastructure.api.dependencies import ( get_context, require_permission ) router = APIRouter(prefix="/analytics", tags=["analytics"]) @router.get("/report") async def get_report( context: Context = Depends(get_context), authorized: bool = Depends(require_permission("analytics", "read")) ): """Get analytics report (requires permission).""" # Your logic pass ``` ### 4. Authentication Layer Extend authentication: ```python theme={null} # Custom auth provider class CustomAuthProvider: async def authenticate( self, credentials: dict ) -> AuthResult: """Custom authentication logic.""" # Integrate with external auth provider pass # Register in auth service auth_service.register_provider("custom", CustomAuthProvider()) ``` ## Architecture Considerations ### Clean Architecture Principles When extending SnackBase, follow Clean Architecture: ``` +-----------------------------------------------------+ | API Layer | | (Routers, Controllers, Middleware) | +---------------------+-------------------------------+ | +---------------------+-------------------------------+ | Application Layer | | (Use Cases, Orchestration, Services) | +---------------------+-------------------------------+ | +---------------------+-------------------------------+ | Domain Layer | | (Entities, Business Logic, Interfaces) | +---------------------+-------------------------------+ | +---------------------+-------------------------------+ | Infrastructure Layer | | (Database, External APIs, Storage) | +-----------------------------------------------------+ ``` ### Dependency Direction ``` CORRECT: Dependencies point inward Router -> Service -> Repository -> Database INCORRECT: Dependencies point outward Repository -> Service -> Router ``` ### Separation of Concerns Keep extensions organized: ``` src/snackbase/ ├── extensions/ # Your extensions │ ├── analytics/ # Analytics feature │ │ ├── router.py # API endpoints │ │ ├── service.py # Business logic │ │ └── repository.py # Data access │ └── integrations/ # External integrations │ └── slack.py # Slack integration ``` ## Deployment Considerations ### Surviving Updates | Extension Method | Survives SnackBase Updates | | ---------------------- | -------------------------- | | **Hooks** | Yes (stable API) | | **Custom Routers** | Yes (separate files) | | **Custom Tables** | Yes (separate migrations) | | **Middleware** | Maybe (if core changes) | | **Core Modifications** | No (will conflict) | ### Extension Isolation Keep extensions isolated to avoid conflicts: ```python theme={null} # BAD: Modifying core files # src/snackbase/infrastructure/api/routes/users_router.py # (Adding custom logic here will conflict with updates) # GOOD: Separate extension file # src/snackbase/extensions/custom_users.py # (Separate file survives updates) ``` ### Configuration Use configuration for extension behavior: ```python theme={null} # .env ENABLE_ANALYTICS_FEATURE=true SLACK_WEBHOOK_URL=https://hooks.slack.com/... CUSTOM_API_KEY=your-key-here # config.py from pydantic import Settings class ExtensionSettings(BaseSettings): enable_analytics: bool = False slack_webhook_url: str | None = None custom_api_key: str | None = None ``` ## Examples ### Example 1: Analytics Dashboard Add custom analytics: ```python theme={null} # 1. Create custom table (migration) op.create_table( "page_views", sa.Column("id", sa.String(50), primary_key=True), sa.Column("account_id", sa.String(10)), sa.Column("path", sa.String(255)), sa.Column("views", sa.Integer), sa.Column("date", sa.Date) ) # 2. Create service class AnalyticsService: async def get_page_views(self, account_id: str, days: int = 30): """Get page views for last N days.""" # Query and aggregate pass # 3. Create router @router.get("/analytics/page-views") async def page_views( days: int = 30, context: Context = Depends(get_context) ): service = AnalyticsService(db) return await service.get_page_views(context.account_id, days) ``` ### Example 2: Slack Integration Add Slack notifications: ```python theme={null} # 1. Create hook @app.hook.on_record_after_create("posts") async def notify_slack(record: dict, context: Context): """Send Slack notification on post creation.""" if record.get("status") == "published": await slack_service.send_notification( webhook_url=settings.slack_webhook_url, message=f"New post: {record['title']}" ) # 2. Create service class SlackService: async def send_notification(self, webhook_url: str, message: str): """Send notification to Slack.""" async with httpx.AsyncClient() as client: await client.post(webhook_url, json={"text": message}) ``` ### Example 3: Custom Validation Add field validation: ```python theme={null} # 1. Create hook @app.hook.on_record_before_create("posts") async def validate_post_content(record: dict, context: Context): """Validate post content before creation.""" content = record.get("content", "") # Custom validation if len(content) < 50: raise HookAbortException( message="Content must be at least 50 characters", status_code=400 ) # Check for prohibited words prohibited = ["spam", "advertisement"] if any(word in content.lower() for word in prohibited): raise HookAbortException( message="Content contains prohibited words", status_code=400 ) ``` ### Example 4: Custom Endpoint with Permissions Add protected endpoint: ```python theme={null} # Custom router with permissions router = APIRouter(prefix="/reports", tags=["reports"]) @router.get("/sales") async def sales_report( context: Context = Depends(get_context), authorized: bool = Depends(require_permission("reports", "read")) ): """Generate sales report (requires reports:read permission).""" # Generate report report = await report_service.generate_sales_report(context.account_id) return report # Register permission # Via UI or API: # { # "role": "manager", # "collection": "reports", # "read": true, # "create": false, # "update": false, # "delete": false # } ``` ## Summary | Concept | Key Takeaway | | ---------------------- | ----------------------------------------------------- | | **Extension Methods** | Hooks, routers, tables, middleware, services | | **Choosing Approach** | Decision tree based on requirements | | **Clean Architecture** | Follow layering, dependency direction | | **Deployment** | Keep extensions isolated for updates | | **Best Practices** | Don't modify core, use configuration, test thoroughly | ## Related Guides * [Creating Custom Hooks](./creating-custom-hooks) * [Adding API Endpoints](./adding-api-endpoints) * [Architecture](../architecture) # Frontend Developer Guide Source: https://docs.snackbase.dev/guides/frontend Complete guide to the SnackBase React admin UI architecture, development patterns, and best practices This guide covers the SnackBase React admin UI architecture, development patterns, and how to build and extend the frontend. ## Tech Stack The SnackBase admin UI is built with modern, production-ready technologies: | Technology | Version | Purpose | | ------------------- | ------- | ------------------------------- | | **React** | 19.2.0 | UI framework | | **TypeScript** | 5.9.3 | Type safety | | **Vite** | 7.2.4 | Build tool and dev server | | **React Router** | 7.11.0 | Client-side routing | | **TailwindCSS** | 4.1.18 | Utility-first styling | | **Radix UI** | Latest | Accessible component primitives | | **ShadCN** | Latest | Pre-built component library | | **TanStack Query** | 5.90.12 | Server state management | | **Zustand** | 5.0.9 | Client state management | | **Zod** | 4.2.1 | Schema validation | | **Axios** | 1.13.2 | HTTP client | | **React Hook Form** | 7.69.0 | Form state management | ## Project Structure ``` ui/ ├── src/ │ ├── main.tsx # Application entry point │ ├── App.tsx # Root component with Router │ ├── App.css # Global styles with TailwindCSS │ │ │ ├── pages/ # Page components (15 pages) │ │ ├── LoginPage.tsx │ │ ├── DashboardPage.tsx │ │ ├── AccountsPage.tsx │ │ ├── UsersPage.tsx │ │ └── ... │ │ │ ├── components/ # Reusable components (83 components) │ │ ├── ui/ # ShadCN components (DO NOT EDIT) │ │ ├── accounts/ │ │ ├── collections/ │ │ ├── records/ │ │ ├── AppSidebar.tsx │ │ └── ProtectedRoute.tsx │ │ │ ├── services/ # API service layer (15 services) │ │ ├── api.ts # Axios configuration │ │ ├── auth.service.ts │ │ ├── users.service.ts │ │ └── ... │ │ │ ├── stores/ # Zustand state stores │ │ └── auth.store.ts │ │ │ ├── hooks/ # Custom React hooks │ ├── lib/ # Utilities and helpers │ └── types/ # TypeScript type definitions │ ├── index.html # HTML entry point ├── package.json # Dependencies and scripts ├── vite.config.ts # Vite build configuration └── components.json # ShadCN configuration ``` ## Architecture Overview The frontend follows a **layered architecture** with clear separation of concerns: ``` ┌─────────────────────────────────┐ │ Pages Layer │ │ (Route handlers, orchestration)│ └────────────┬────────────────────┘ │ ┌────────────▼────────────────────┐ │ Components Layer │ │ (Reusable UI components) │ └────────────┬────────────────────┘ │ ┌────────────▼────────────────────┐ │ Business Logic Layer │ │ (Zustand stores, custom hooks) │ └────────────┬────────────────────┘ │ ┌────────────▼────────────────────┐ │ Data Layer │ │ (Services with TanStack Query) │ └────────────┬────────────────────┘ │ ┌────────────▼────────────────────┐ │ Axios API │ │ (HTTP client with interceptors) │ └─────────────────────────────────┘ ``` ## State Management ### Global State: Zustand Authentication state is managed in `src/stores/auth.store.ts`: ```typescript theme={null} interface AuthState { // State user: UserInfo | null; token: string | null; isAuthenticated: boolean; isLoading: boolean; // Actions login: (email: string, password: string) => Promise; logout: () => void; restoreSession: () => Promise; } export const useAuthStore = create()( persist( (set, get) => ({ // Initial state and actions... }), { name: 'auth-storage', partialize: (state) => ({ user: state.user, token: state.token, isAuthenticated: state.isAuthenticated, }), } ) ); ``` **Key Features:** * Token stored in localStorage with key `auth-storage` * Persist middleware for session persistence * `restoreSession()` verifies token validity on app load * Automatic logout on 401 responses ### Server State: TanStack Query All data from the API is managed by TanStack Query: ```typescript theme={null} const { data: collections, isLoading, error } = useQuery({ queryKey: ['collections'], queryFn: collectionsService.getCollections, }); ``` ## API Service Layer All services follow a consistent pattern in `src/services/`: ```typescript theme={null} // src/services/users.service.ts import { apiClient } from '@/lib/api'; export const usersService = { getAll: async (): Promise => { const response = await apiClient.get('/users'); return response.data; }, create: async (data: CreateUserDto): Promise => { const response = await apiClient.post('/users', data); return response.data; }, update: async (id: string, data: UpdateUserDto): Promise => { const response = await apiClient.put(`/users/${id}`, data); return response.data; }, delete: async (id: string): Promise => { await apiClient.delete(`/users/${id}`); }, }; ``` ### Axios Configuration The `lib/api.ts` file configures the Axios instance: ```typescript theme={null} const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1'; export const apiClient = axios.create({ baseURL: API_BASE_URL, headers: { 'Content-Type': 'application/json' }, }); // Request interceptor - add auth token apiClient.interceptors.request.use((config) => { const authState = localStorage.getItem('auth-storage'); if (authState) { const parsedState = JSON.parse(authState); const token = parsedState?.state?.token; if (token) { config.headers.Authorization = `Bearer ${token}`; } } return config; }); // Response interceptor - handle token refresh apiClient.interceptors.response.use( (response) => response, async (error) => { if (error.response?.status === 401 && !error.config._retry) { error.config._retry = true; // Attempt token refresh... } return Promise.reject(error); } ); ``` ## Authentication Flow ### Login Flow ``` User enters credentials ↓ POST /auth/login (auth.service.ts) ↓ Store in Zustand store with persist ↓ Redirect to /admin/dashboard ``` ### Protected Routes The `ProtectedRoute` component wraps routes that require authentication: ```typescript theme={null} export default function ProtectedRoute({ children }) { const { isAuthenticated, isLoading, restoreSession } = useAuthStore(); useEffect(() => { restoreSession(); }, [restoreSession]); if (isLoading) return
Loading...
; if (!isAuthenticated) return ; return <>{children}; } ``` ### Usage in App.tsx All admin routes are under the `/admin` prefix: ```typescript theme={null} } /> } /> }> } /> } /> {/* ... more routes */} ``` ## Components & Patterns ### ShadCN Components ShadCN provides pre-built, accessible components. **Never edit ShadCN components directly** in `src/components/ui/`. To add new ShadCN components: ```bash theme={null} cd ui npx shadcn@latest add button npx shadcn@latest add dialog npx shadcn@latest add table ``` ### Component Composition Pattern Build complex components by composing ShadCN primitives: ```typescript theme={null} import { Button } from '@/components/ui/button'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Badge } from '@/components/ui/badge'; export function UsersTable({ users, onEdit, onDelete }) { return ( Name Email Actions {users.map((user) => ( {user.name} {user.email} ))}
); } ``` ### Dialog-Based CRUD Operations Most CRUD operations use ShadCN Dialog components: ```typescript theme={null} import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; export function CreateUserDialog({ open, onOpenChange, onSuccess }) { const form = useForm({ resolver: zodResolver(schema) }); const createUser = useMutation({ mutationFn: usersService.create, onSuccess: () => { onSuccess(); onOpenChange(false); }, }); return ( Create User
createUser.mutate(data))}> {/* Form fields */}
); } ``` ## Routing All routes are under the `/admin` prefix: ```typescript theme={null} }> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> ``` ## Styling SnackBase uses **TailwindCSS 4** with the new `@tailwindcss/vite` plugin. ### Theme Configuration Theme is configured in `src/App.css`: ```css theme={null} @import "tailwindcss"; @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); } :root { --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); --primary: oklch(0.205 0 0); } .dark { --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); } ``` ### Common Patterns | Pattern | Classes | Usage | | --------------- | ------------------------------------------------------ | ------------------------ | | Card | `bg-white rounded-lg shadow-sm border p-6` | Container for content | | Button Group | `flex gap-2` | Horizontal button layout | | Grid | `grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4` | Responsive grid | | Section Spacing | `space-y-4` | Vertical spacing | ## Development Workflow ### Environment Setup Create a `.env` file in the `ui` directory: ```bash theme={null} VITE_API_BASE_URL=/api/v1 ``` ### Running the Dev Server ```bash theme={null} cd ui npm run dev ``` The Vite dev server starts at `http://localhost:5173` with: * Hot Module Replacement (HMR) * Fast refresh * TypeScript checking ### Build for Production ```bash theme={null} cd ui npm run build ``` ## Best Practices ### 1. Component Organization * Keep components focused and single-purpose * Extract reusable logic into custom hooks * Co-locate related components in feature folders ### 2. Type Safety * Always define TypeScript interfaces for API responses * Use Zod schemas for runtime validation * Avoid `any` type - use `unknown` if truly unknown ### 3. Error Handling * Always handle loading and error states from TanStack Query * Show user-friendly error messages * Use the `handleApiError()` utility from `lib/api.ts` ### 4. Performance * Use TanStack Query's caching to avoid redundant requests * Implement pagination for large datasets * Lazy load components with React.lazy() ## Related Guides * [API Examples](../api-examples) * [Architecture](../architecture) # OAuth Authentication Overview Source: https://docs.snackbase.dev/guides/oauth-overview Understanding the OAuth 2.0 Authorization Code Flow in SnackBase SnackBase implements the OAuth 2.0 Authorization Code Flow with PKCE (for supported providers) and state validation for security. ## Overview The OAuth flow allows users to sign in to SnackBase using their existing accounts from providers like Google, GitHub, Microsoft, and Apple. ## Detailed Flow ### 1. Initiate Login * The frontend calls `POST /api/v1/auth/oauth/{provider}/authorize` with a `redirect_uri`. * SnackBase generates a secure random `state` token and stores it. * SnackBase returns an authorization URL for the provider. ### 2. User Authorization * The user is redirected to the provider's login page. * The user grants permission to SnackBase. ### 3. Callback Handling * The provider redirects the user back to the `redirect_uri` with a `code` and `state`. * The frontend forwards these parameters to `POST /api/v1/auth/oauth/{provider}/callback`. ### 4. Token Exchange * SnackBase verifies the `state` token against the stored one to prevent CSRF attacks. * SnackBase exchanges the authorization `code` for an access token (and ID token) from the provider. ### 5. User Profile Retrieval * SnackBase uses the access token to fetch user profile information (email, name). ### 6. Account Creation/Login * If the email exists, the user is logged in. * If the email is new, a new user account is created (auto-provisioning). * SnackBase issues its own JWT access and refresh tokens for the session. ## Configuration OAuth providers can be configured at: * **System Level**: `config.yaml` or environment variables. Applies to all tenants. * **Account Level**: Overrides via the `configs` table. Specific to a tenant account. ## Security * **State Parameter**: Used to prevent Cross-Site Request Forgery (CSRF). * **PKCE**: Proof Key for Code Exchange (where supported) adds protection against code interception. * **Token Storage**: External provider tokens are used only during the callback to fetch data and are not permanently stored unless linked functionalities are enabled. ## Supported Providers * [Google](./oauth-setup-google) - Google Account login * [GitHub](./oauth-setup-github) - GitHub account login * [Microsoft](./oauth-setup-microsoft) - Microsoft / Azure AD login * [Apple](./oauth-setup-apple) - Sign in with Apple ## Next Steps * Configure your preferred OAuth provider using the setup guides above * See [Authentication Concepts](/concepts/authentication) for more details on multi-account users # Apple OAuth Setup Source: https://docs.snackbase.dev/guides/oauth-setup-apple Configure Sign in with Apple for SnackBase This guide explains how to configure Sign in with Apple for SnackBase. ## Prerequisites * An Apple Developer account * SnackBase installed and running on a domain with HTTPS (Apple requires HTTPS) Apple requires HTTPS for all OAuth callbacks. Local development will need to use a service like ngrok or similar to test. ## Step 1: Create an App ID 1. Log in to the [Apple Developer Console](https://developer.apple.com/account/) 2. Go to **Certificates, Identifiers & Profiles** > **Identifiers** 3. Click **+** to add a new identifier 4. Select **App IDs**, continue 5. Select **App**, continue 6. Enter a **Description** and **Bundle ID** 7. Scroll down to **Capabilities** and check **Sign In with Apple** 8. Click **Continue** and **Register** ## Step 2: Create a Service ID 1. Go back to **Identifiers** 2. Click **+**, select **Service IDs**, continue 3. Enter a **Description** and **Identifier** (e.g., `com.example.snackbase.client`) 4. Click **Continue** and **Register** 5. Click on the newly created Service ID to edit it 6. Enable **Sign In with Apple** and click **Configure** 7. Select your **Primary App ID** 8. Add your **Web Domain** (verify ownership if required) 9. Add your **Return URLs** (Redirect URIs) * Format: `https:///api/v1/auth/oauth/apple/callback` 10. Click **Save**, **Continue**, and **Save** ## Step 3: Create a Client Secret (Private Key) 1. Go to **Keys** 2. Click **+** to create a new key 3. Enter a name and check **Sign In with Apple** 4. Click **Configure**, select your Primary App ID 5. Click **Save**, **Continue**, and **Register** 6. **Download** the `.p8` file (save this securely, you cannot download it again) 7. Note the **Key ID** 8. Get your **Team ID** from the top right of the developer console ## Step 4: Configure SnackBase In SnackBase, configure the Apple provider: | Field | Value | | --------------- | ------------------------------------------- | | `client_id` | Your Service ID Identifier (from Step 2) | | `team_id` | Your Apple Team ID | | `key_id` | The Key ID (from Step 3) | | `client_secret` | The contents of your `.p8` private key file | | `redirect_uri` | The Return URL from Step 2 | | `scopes` | `name email` (default) | ## Testing 1. Save your configuration 2. Attempt to sign in via the Apple button on the login page ## Troubleshooting The `.p8` private key file is only available for download once. Store it securely as you cannot retrieve it again. **Common Issues:** * **invalid\_client**: Check that your client\_id (Service ID), team\_id, and key\_id are correct * **HTTPS required**: Apple requires HTTPS for all callbacks * **Domain verification**: Ensure your domain is verified in Apple Developer Console ## Related Guides * [OAuth Overview](./oauth-overview) - Understanding the OAuth flow * [Google OAuth Setup](./oauth-setup-google) * [GitHub OAuth Setup](./oauth-setup-github) # GitHub OAuth Setup Source: https://docs.snackbase.dev/guides/oauth-setup-github Configure GitHub as an OAuth 2.0 provider for SnackBase This guide explains how to configure GitHub as an OAuth 2.0 provider for SnackBase. ## Prerequisites * A GitHub account * SnackBase installed and running ## Step 1: Register a New OAuth Application 1. Go to your GitHub account **Settings** 2. Navigate to **Developer settings** > **OAuth Apps** 3. Click **New OAuth App** 4. Fill in the application details: * **Application name**: SnackBase * **Homepage URL**: `https://` * **Authorization callback URL**: `https:///api/v1/auth/oauth/github/callback` * For local development: `http://localhost:8000/api/v1/auth/oauth/github/callback` ## Step 2: Generate Client Secret 1. After creating the app, you will see the **Client ID** 2. Click **Generate a new client secret** 3. Copy the **Client Secret** ## Step 3: Configure SnackBase In SnackBase, configure the GitHub provider: | Field | Value | | --------------- | ------------------------------------------ | | `client_id` | Your GitHub Client ID | | `client_secret` | Your GitHub Client Secret | | `redirect_uri` | The Authorization callback URL from Step 1 | | `scopes` | `user:email read:user` (default) | ## Testing 1. Save your configuration 2. Attempt to sign in via the GitHub button on the login page ## Troubleshooting The client secret is only shown once. Make sure to copy it immediately after generating. **Common Issues:** * **redirect\_uri\_mismatch**: Ensure the callback URL exactly matches what you configured * **Application suspended**: GitHub may suspend apps that violate their terms of service ## Related Guides * [OAuth Overview](./oauth-overview) - Understanding the OAuth flow * [Google OAuth Setup](./oauth-setup-google) * [Microsoft OAuth Setup](./oauth-setup-microsoft) # Google OAuth Setup Source: https://docs.snackbase.dev/guides/oauth-setup-google Configure Google as an OAuth 2.0 provider for SnackBase This guide explains how to configure Google as an OAuth 2.0 provider for SnackBase. ## Prerequisites * A Google Cloud Platform (GCP) project * SnackBase installed and running ## Step 1: Configure OAuth Consent Screen 1. Go to the [Google Cloud Console](https://console.cloud.google.com/) 2. Navigate to **APIs & Services** > **OAuth consent screen** 3. Select **User Type** (Internal or External) and click **Create** 4. Fill in the required application information (Name, User support email, etc.) 5. Add scopes: `.../auth/userinfo.email`, `.../auth/userinfo.profile`, `openid` 6. Add test users (if External and in Testing mode) ## Step 2: Create Credentials 1. Navigate to **APIs & Services** > **Credentials** 2. Click **Create Credentials** > **OAuth client ID** 3. Select **Web application** 4. **Name**: Enter "SnackBase" 5. **Authorized redirect URIs**: Add your SnackBase callback URL * Format: `https:///api/v1/auth/oauth/google/callback` * For local development: `http://localhost:8000/api/v1/auth/oauth/google/callback` ## Step 3: Configure SnackBase Copy the **Client ID** and **Client Secret** from the Google Cloud Console. In SnackBase, configure the Google provider: | Field | Value | | --------------- | ------------------------------------ | | `client_id` | Your Google Client ID | | `client_secret` | Your Google Client Secret | | `redirect_uri` | The Redirect URI you added in Step 2 | | `scopes` | `openid email profile` (default) | ## Testing 1. Save your configuration 2. Attempt to sign in via the Google button on the login page ## Troubleshooting Make sure your redirect URI exactly matches what you configured in Google Cloud Console, including the protocol (http/https) and port. **Common Issues:** * **redirect\_uri\_mismatch**: Ensure the redirect URI in Google Cloud Console matches exactly * **access\_denied**: User declined the authorization request * **invalid\_client**: Check that your client ID and secret are correct ## Related Guides * [OAuth Overview](./oauth-overview) - Understanding the OAuth flow * [GitHub OAuth Setup](./oauth-setup-github) * [Microsoft OAuth Setup](./oauth-setup-microsoft) # Microsoft OAuth Setup Source: https://docs.snackbase.dev/guides/oauth-setup-microsoft Configure Microsoft Azure AD as an OAuth 2.0 provider for SnackBase This guide explains how to configure Microsoft (Azure AD) as an OAuth 2.0 provider for SnackBase. ## Prerequisites * An Azure account * SnackBase installed and running ## Step 1: Register an Application 1. Go to the [Azure Portal](https://portal.azure.com/) 2. Navigate to **Microsoft Entra ID** (formerly Azure Active Directory) > **App registrations** 3. Click **New registration** 4. **Name**: SnackBase 5. **Supported account types**: Select who can use this application (e.g., "Accounts in any organizational directory and personal Microsoft accounts") 6. **Redirect URI**: Select **Web** and enter your callback URL * Format: `https:///api/v1/auth/oauth/microsoft/callback` * For local development: `http://localhost:8000/api/v1/auth/oauth/microsoft/callback` 7. Click **Register** ## Step 2: Create Client Secret 1. In the app overview, note the **Application (client) ID** 2. Navigate to **Certificates & secrets** > **Client secrets** 3. Click **New client secret** 4. Add a description and expiry 5. Copy the **Value** (not the Secret ID) immediately ## Step 3: Configure SnackBase In SnackBase, configure the Microsoft provider: | Field | Value | | --------------- | --------------------------------------------------------- | | `client_id` | Your Application (client) ID | | `client_secret` | Your Client Secret Value | | `tenant_id` | `common` (for multi-tenant) or your specific Tenant ID | | `redirect_uri` | The Redirect URI from Step 1 | | `scopes` | `User.Read email openid profile offline_access` (default) | ## Testing 1. Save your configuration 2. Attempt to sign in via the Microsoft button on the login page ## Troubleshooting Microsoft Azure AD has been renamed to "Microsoft Entra ID". The functionality remains the same. **Common Issues:** * **AADSTS50105**: Your application doesn't have the required permissions. Check your scopes. * **AADSTS700016**: Application with identifier was not found. Verify your client ID. * **invalid\_client**: Check that your client secret is correct and hasn't expired. ## Related Guides * [OAuth Overview](./oauth-overview) - Understanding the OAuth flow * [Google OAuth Setup](./oauth-setup-google) * [GitHub OAuth Setup](./oauth-setup-github) # Azure AD SAML Setup Source: https://docs.snackbase.dev/guides/saml-setup-azure-ad Configure Microsoft Entra ID (Azure AD) as a SAML Identity Provider for SnackBase This guide explains how to configure Azure Active Directory (Microsoft Entra ID) as a SAML Identity Provider (IdP) for SnackBase. ## Prerequisites * Administrator access to your Azure AD tenant * SnackBase installed and running ## Step 1: Create an Enterprise Application 1. Log in to the [Azure Portal](https://portal.azure.com/) 2. Navigate to **Microsoft Entra ID** > **Enterprise applications** 3. Click **New application** 4. Click **Create your own application** 5. Enter a name (e.g., "SnackBase") and select **Integrate any other application you don't find in the gallery (Non-gallery)** 6. Click **Create** ## Step 2: Set up Single Sign-On 1. In your new application, go to **Single sign-on** in the left menu 2. Select **SAML** ## Step 3: Configure Basic SAML Configuration Click **Edit** on the **Basic SAML Configuration** card. 1. **Identifier (Entity ID)**: Enter a unique identifier for SnackBase * Format: `https://` (must match `sp_entity_id` in SnackBase) 2. **Reply URL (Assertion Consumer Service URL)**: Enter your SnackBase ACS URL * Format: `https:///api/v1/auth/saml/acs` 3. Click **Save** ## Step 4: Configure Attributes & Claims Click **Edit** on the **Attributes & Claims** card. Ensure the following claims are present (Azure AD usually adds them by default): * `emailaddress` or `name` (user principal name) for email * `givenname` and `surname` for name mapping ## Step 5: Configure SnackBase 1. On the **SAML-based Sign-on** page, scroll down to **SAML Certificates** 2. Download **Certificate (Base64)**, open it in a text editor to copy the content 3. Scroll down to the **Set up ** section 4. In SnackBase, create a new SAML provider configuration with the following values: | SnackBase Field | Azure AD Value | | ------------------------ | ------------------------------------------------ | | `idp_entity_id` | **Azure AD Identifier** | | `idp_sso_url` | **Login URL** | | `idp_x509_cert` | Content of the **Certificate (Base64)** file | | `sp_entity_id` | The **Identifier (Entity ID)** you set in Step 3 | | `assertion_consumer_url` | The **Reply URL** you set in Step 3 | ## Testing 1. Save your configuration in SnackBase 2. Assign a user to the application in Azure AD (Users and groups > Add user/group) 3. Attempt to sign in via the SAML SSO button ## Troubleshooting Microsoft Azure Active Directory has been renamed to "Microsoft Entra ID". The functionality remains the same. **Common Issues:** * **AADSTS50105**: The user is not assigned to the application. Assign the user or group in Azure AD * **Invalid certificate**: Ensure you've copied the entire certificate content including BEGIN and END markers * **Entity ID mismatch**: Verify the `sp_entity_id` in SnackBase matches the Identifier in Azure AD exactly ## Related Guides * [Generic SAML Setup](./saml-setup-generic) * [Okta SAML Setup](./saml-setup-okta) * [Authentication Concepts](/concepts/authentication) # Generic SAML Provider Setup Source: https://docs.snackbase.dev/guides/saml-setup-generic Configure a generic SAML 2.0 Identity Provider for SnackBase This guide explains how to configure a generic SAML Identity Provider (IdP) for SnackBase. ## Prerequisites * A SAML 2.0 compliant Identity Provider (e.g., Auth0, OneLogin, Keycloak, Shibboleth) * SnackBase installed and running ## Step 1: Get Service Provider (SP) Information from SnackBase You will need to provide the following information to your IdP: 1. **SP Entity ID (Audience URI)**: A unique identifier for your SnackBase instance * Example: `https://snackbase.yourdomain.com` 2. **Assertion Consumer Service (ACS) URL**: The endpoint where the IdP sends the SAML assertion * Example: `https://snackbase.yourdomain.com/api/v1/auth/saml/acs` 3. **NameID Format**: `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress` ## Step 2: Configure your Identity Provider 1. Create a new SAML application in your IdP 2. Input the **Entity ID** and **ACS URL** from Step 1 3. Ensure the IdP signs the assertion (SnackBase requires signed assertions or signed responses) 4. Configure attribute mapping (optional but recommended): * Map user email to `email`, `mail`, or `Email` * Map name to `firstName`/`lastName` or `displayName` ## Step 3: Configure SnackBase Collect the following information from your IdP metadata: 1. **IdP Entity ID (Issuer)**: The unique identifier of your IdP 2. **IdP SSO URL**: The URL where SnackBase will redirect users for login 3. **X.509 Certificate**: The public certificate used to verify the IdP's signature In SnackBase, configure the Generic SAML provider: | Field | Description | | ------------------------ | ------------------------------------------------------------------------- | | `idp_entity_id` | The Issuer URI from your IdP | | `idp_sso_url` | The Single Sign-On URL from your IdP | | `idp_x509_cert` | The public certificate (PEM format) | | `sp_entity_id` | The Entity ID you defined in Step 1 | | `assertion_consumer_url` | The ACS URL you defined in Step 1 | | `binding` | Set to `HTTP-Redirect` (default) | | `name_id_format` | Set to `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress` (default) | ## Testing 1. Save your configuration 2. Attempt to sign in via the Generic SAML SSO button ## Troubleshooting Ensure the IdP is configured to sign either the assertion or the response. SnackBase requires at least one to be signed for security. **Common Issues:** * **Invalid signature**: Check that the X.509 certificate is correctly copied and matches the IdP's current certificate * **NameID format mismatch**: Ensure the NameID format matches between IdP and SnackBase configuration * **Attribute mapping**: Verify that attribute names in the SAML response match your SnackBase configuration ## Related Guides * [Okta SAML Setup](./saml-setup-okta) * [Azure AD SAML Setup](./saml-setup-azure-ad) * [Authentication Concepts](/concepts/authentication) # Okta SAML Setup Source: https://docs.snackbase.dev/guides/saml-setup-okta Configure Okta as a SAML Identity Provider for SnackBase This guide explains how to configure Okta as a SAML Identity Provider (IdP) for SnackBase. ## Prerequisites * Administrator access to your Okta organization * SnackBase installed and running ## Step 1: Create an App Integration in Okta 1. Log in to your Okta Admin Console 2. Go to **Applications** > **Applications** 3. Click **Create App Integration** 4. Select **SAML 2.0** and click **Next** ## Step 2: General Settings 1. **App name**: Enter "SnackBase" (or your preferred name) 2. **App logo**: (Optional) Upload a logo 3. Click **Next** ## Step 3: Configure SAML 1. **Single Sign On URL (ACS URL)**: Enter your SnackBase ACS URL * Format: `https:///api/v1/auth/saml/acs` 2. **Audience URI (SP Entity ID)**: Enter a unique identifier for SnackBase * Format: `https://` (or `snackbase-app`) * **Note**: This value must match the `sp_entity_id` in your SnackBase configuration 3. **Name ID format**: Select `EmailAddress` 4. **Application username**: Select `Email` 5. **Update application username on**: Create and update ## Step 4: Attribute Statements (Optional but Recommended) Add the following attribute statements to map user details: | Name | Name format | Value | | --------- | ----------- | -------------- | | email | Unspecified | user.email | | firstName | Unspecified | user.firstName | | lastName | Unspecified | user.lastName | Click **Next** and then **Finish**. ## Step 5: Configure SnackBase 1. In Okta, go to the **Sign On** tab of your new application 2. Scroll down to **SAML Signing Certificates** 3. Locate the active certificate and click **View SAML setup instructions** 4. In SnackBase, create a new SAML provider configuration with the following values: | SnackBase Field | Okta Value | | ------------------------ | ---------------------------------------------------------- | | `idp_entity_id` | **Identity Provider Issuer** | | `idp_sso_url` | **Identity Provider Single Sign-On URL** | | `idp_x509_cert` | **X.509 Certificate** (Paste the full certificate content) | | `sp_entity_id` | The **Audience URI** you set in Step 3 | | `assertion_consumer_url` | The **Single Sign On URL** you set in Step 3 | ## Testing 1. Save your configuration in SnackBase 2. Attempt to sign in via the SAML SSO button ## Troubleshooting Okta certificates rotate periodically. Make sure to update the certificate in SnackBase after rotation. **Common Issues:** * **SAML response validation failed**: Verify the `sp_entity_id` matches exactly between Okta and SnackBase * **User not found**: Check that the application username format matches the user email in Okta * **Certificate expired**: Okta certificates expire - update to the new certificate in SnackBase ## Related Guides * [Generic SAML Setup](./saml-setup-generic) * [Azure AD SAML Setup](./saml-setup-azure-ad) * [Authentication Concepts](/concepts/authentication) # Setting Up Webhooks Source: https://docs.snackbase.dev/guides/setting-up-webhooks Step-by-step guide to configuring outbound webhooks for external notifications This guide walks you through setting up outbound webhooks to send HTTP notifications to external services when data changes in your collections. ## Prerequisites * A running SnackBase instance * At least one collection with data * An external HTTPS endpoint to receive webhooks (for production) ## Setup Create a webhook using the API or SDK: ```ts theme={null} const webhook = await client.webhooks.create({ url: "https://your-server.com/webhooks/orders", collection: "orders", events: ["create", "update"], }); // IMPORTANT: Save this secret console.log("Webhook secret:", webhook.secret); ``` ```bash theme={null} curl -X POST https://api.snackbase.dev/api/v1/webhooks \ -H "Authorization: Bearer {token}" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-server.com/webhooks/orders", "collection": "orders", "events": ["create", "update"] }' ``` The webhook `secret` is only returned in the creation response. Store it securely -- you'll need it to verify signatures. Store the returned secret in your server's environment variables: ```bash theme={null} export SNACKBASE_WEBHOOK_SECRET="your-64-char-hex-secret" ``` Never hardcode the secret in your source code. Every webhook delivery includes an `X-SnackBase-Signature` header. Verify it on your server: ```js theme={null} const crypto = require("crypto"); function verifyWebhook(payload, secret, signatureHeader) { const expected = "sha256=" + crypto.createHmac("sha256", secret).update(payload).digest("hex"); return crypto.timingSafeEqual( Buffer.from(expected), Buffer.from(signatureHeader) ); } // Express middleware app.post("/webhooks/orders", express.raw({ type: "application/json" }), (req, res) => { const signature = req.headers["x-snackbase-signature"]; if (!verifyWebhook(req.body, process.env.SNACKBASE_WEBHOOK_SECRET, signature)) { return res.status(401).send("Invalid signature"); } const event = JSON.parse(req.body); console.log("Event:", event.event); console.log("Record:", event.record); res.status(200).send("OK"); }); ``` ```python theme={null} import hmac import hashlib def verify_webhook(payload: bytes, secret: str, signature_header: str) -> bool: expected = "sha256=" + hmac.new( secret.encode(), payload, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature_header) # FastAPI example @app.post("/webhooks/orders") async def handle_webhook(request: Request): body = await request.body() signature = request.headers.get("x-snackbase-signature") if not verify_webhook(body, os.environ["SNACKBASE_WEBHOOK_SECRET"], signature): raise HTTPException(status_code=401, detail="Invalid signature") event = await request.json() print(f"Event: {event['event']}, Record: {event['record']}") return {"status": "ok"} ``` Use the built-in test endpoint to verify everything works: ```ts theme={null} const result = await client.webhooks.test("webhook-id"); console.log(result.success); // true console.log(result.status_code); // 200 ``` ```bash theme={null} curl -X POST https://api.snackbase.dev/api/v1/webhooks/{webhook_id}/test \ -H "Authorization: Bearer {token}" ``` Check delivery history to debug issues: ```ts theme={null} const deliveries = await client.webhooks.listDeliveries("webhook-id"); for (const d of deliveries.items) { console.log(`${d.event} - ${d.status} (HTTP ${d.response_status})`); if (d.status === "failed") { console.log("Error:", d.response_body); } } ``` ## Adding Filters Only fire webhooks when specific conditions are met: ```ts theme={null} const webhook = await client.webhooks.create({ url: "https://your-server.com/webhooks/high-value", collection: "orders", events: ["create"], filter: 'total >= 500 and status = "confirmed"', }); ``` ## Troubleshooting Check the delivery history for error details. Common causes: * Your endpoint is returning non-2xx status codes * Your endpoint is taking longer than 30 seconds to respond * DNS resolution is failing for your URL Ensure you're verifying against the raw request body (bytes), not a parsed/re-serialized JSON object. The signature is computed over the exact bytes sent. * Verify the webhook is `enabled` * Check that the event type matches (`create`, `update`, `delete`) * If using a filter, verify the expression matches your record data * Filters that fail to evaluate will still fire the webhook (fail-open) In production, SnackBase requires HTTPS URLs. For local development, HTTP is allowed. Private IPs (localhost, 10.x, 192.168.x) are also blocked in production. ## Next Steps * [Outbound Webhooks Concept](/concepts/webhooks) -- full reference * [Webhooks API Reference](/api-reference/endpoints/webhooks/create-webhook) -- all endpoints * [Webhooks SDK Reference](/sdk/js/services/webhooks) -- SDK methods # Testing Guide Source: https://docs.snackbase.dev/guides/testing Learn how to write and run tests for SnackBase, covering unit tests, integration tests, and best practices This guide explains how to write and run tests for SnackBase, covering unit tests, integration tests, and best practices. ## Overview SnackBase uses **pytest** as its testing framework with comprehensive support for async operations and database testing. ### Test Stack | Component | Purpose | | ------------------ | ----------------------------- | | **pytest** | Test framework and runner | | **pytest-asyncio** | Async test support | | **httpx** | Async HTTP client for testing | | **ASGITransport** | Test FastAPI without network | | **pytest-cov** | Code coverage reporting | ### Test Coverage Goals | Area | Target Coverage | | ---------------------------------------------- | --------------- | | **Core Logic** (domain/) | 90%+ | | **Repositories** (infrastructure/persistence/) | 85%+ | | **API Routes** (infrastructure/api/routes/) | 80%+ | | **Services** (infrastructure/services/) | 85%+ | | **Hooks** (infrastructure/hooks/) | 75%+ | | **Overall** | 85%+ | ## Testing Philosophy ### Test Pyramid ``` /\ / \ / E2E \ ← Few (manual/expensive) /--------\ /Integration \ ← More (API/database) /--------------\ / Unit Tests \ ← Most (fast/isolated) /------------------\ ``` ### Testing Principles | Principle | Description | | ----------------- | ------------------------------------------------------ | | **Fast** | Tests should run quickly (unit tests \< 1 second each) | | **Isolated** | Tests shouldn't depend on each other | | **Deterministic** | Same input should always produce same output | | **Readable** | Test names should describe what they test | | **Maintainable** | Tests should be easy to update when code changes | ## Test Structure ### Directory Layout ``` tests/ ├── unit/ # Unit tests (isolated) │ ├── test_config.py # Configuration tests │ ├── test_id_generator.py # ID generation tests │ ├── test_rules_lexer.py # Rule lexer tests │ ├── test_rules_parser.py # Rule parser tests │ └── test_password_hasher.py # Password hasher tests │ ├── integration/ # Integration tests (API + DB) │ ├── test_auth_endpoints.py # Authentication API tests │ ├── test_collections.py # Collection CRUD tests │ ├── test_records.py # Record CRUD tests │ ├── test_permissions.py # Permission tests │ └── test_hooks.py # Hook integration tests │ ├── security/ # Security-specific tests │ ├── test_authentication.py # Auth security tests │ ├── test_authorization.py # Permission security tests │ └── test_injection.py # SQL injection tests │ ├── conftest.py # Shared fixtures and configuration └── pytest.ini # Pytest configuration ``` ### Test Naming Convention Use descriptive test names that explain what is being tested: ```python theme={null} # BAD: Vague def test_create(): pass def test_update(): pass # GOOD: Descriptive def test_create_post_returns_201_with_valid_data(): pass def test_update_post_fails_with_invalid_id(): pass def test_update_post_requires_authentication(): pass ``` ## Running Tests ### Basic Commands ```bash theme={null} # Run all tests uv run pytest # Run unit tests only uv run pytest tests/unit/ # Run integration tests only uv run pytest tests/integration/ # Run specific test file uv run pytest tests/unit/test_id_generator.py # Run specific test uv run pytest tests/unit/test_id_generator.py::test_generate_id_format # Run with verbose output uv run pytest -v # Run with coverage uv run pytest --cov=snackbase --cov-report=html ``` ### Coverage Report ```bash theme={null} # Generate HTML coverage report uv run pytest --cov=snackbase --cov-report=html # Open report open htmlcov/index.html ``` ### Test Discovery Pytest automatically discovers tests: ``` Tests are discovered in files matching: - test_*.py - *_test.py Test functions must: - Start with "test_" - Be in a discovered file Test classes must: - Start with "Test" - Have methods starting with "test_" ``` ## Writing Unit Tests ### What to Unit Test Unit tests should cover: * **Business logic** (domain layer) * **Pure functions** (no side effects) * **Data transformations** * **Validation logic** ### Example: Testing ID Generator ```python theme={null} # tests/unit/test_id_generator.py import pytest from src.snackbase.core.id_generator import generate_id class TestIDGenerator: """Test ID generation functionality.""" def test_generate_id_format(self): """Generated ID should match XX#### format.""" id = generate_id("test") assert isinstance(id, str) assert len(id) == 9 # XX + #### assert id[:2].isalpha() assert id[2:].isdigit() assert id[2:].isnumeric() def test_generate_id_prefix(self): """Generated ID should use correct prefix.""" id = generate_id("user") assert id.startswith("user_") assert len(id) == 14 # user_ + XX#### def test_generate_ids_are_unique(self): """Each generated ID should be unique.""" ids = [generate_id("test") for _ in range(100)] assert len(set(ids)) == 100 # All unique def test_generate_id_deterministic_prefix(self): """Prefix should be consistent.""" id1 = generate_id("test") id2 = generate_id("test") assert id1.startswith("test_") assert id2.startswith("test_") ``` ### Example: Testing Rule Parser ```python theme={null} # tests/unit/test_rules_parser.py import pytest from src.snackbase.core.rules.parser import Parser from src.snackbase.core.rules.ast import * class TestRuleParser: """Test rule parsing functionality.""" def test_parse_simple_comparison(self): """Parser should handle simple comparisons.""" parser = Parser() ast = parser.parse("user.id == 'user_123'") assert isinstance(ast, BinaryOpNode) assert ast.operator == "==" assert isinstance(ast.left, FieldAccessNode) assert ast.left.field == "user.id" def test_parse_logical_and(self): """Parser should handle AND operations.""" parser = Parser() ast = parser.parse("@has_role('admin') and @owns_record()") assert isinstance(ast, BinaryOpNode) assert ast.operator == "and" assert isinstance(ast.left, FunctionCallNode) def test_parse_grouping(self): """Parser should handle parentheses grouping.""" parser = Parser() ast = parser.parse("(@has_role('a') or @has_role('b')) and status == 'draft'") assert isinstance(ast, BinaryOpNode) assert ast.operator == "and" assert isinstance(ast.left, BinaryOpNode) # (a or b) ``` ## Writing Integration Tests ### What to Integration Test Integration tests should cover: * **API endpoints** (request/response) * **Database operations** (CRUD) * **Authentication flows** * **Permission enforcement** * **Hook execution** ### Example: Testing POST Endpoint ```python theme={null} # tests/integration/test_posts.py import pytest from httpx import AsyncClient, ASGITransport from sqlalchemy.ext.asyncio import AsyncSession from src.snackbase.infrastructure.api.app import app from src.snackbase.infrastructure.persistence.models.post import Post from src.snackbase.core.id_generator import generate_id @pytest.mark.asyncio class TestPostsAPI: """Test posts API endpoints.""" async def test_create_post_success( self, client: AsyncClient, superadmin_token: str ): """Creating a post with valid data should return 201.""" response = await client.post( "/api/v1/posts", headers={"Authorization": f"Bearer {superadmin_token}"}, json={ "title": "Test Post", "content": "This is test content", "status": "draft" } ) assert response.status_code == 201 data = response.json() assert data["title"] == "Test Post" assert data["content"] == "This is test content" assert data["status"] == "draft" assert "id" in data assert "created_at" in data async def test_create_post_requires_auth( self, client: AsyncClient ): """Creating a post without auth should return 401.""" response = await client.post( "/api/v1/posts", json={"title": "Test Post"} ) assert response.status_code == 401 async def test_create_post_validates_required_fields( self, client: AsyncClient, superadmin_token: str ): """Creating a post without required fields should return 422.""" response = await client.post( "/api/v1/posts", headers={"Authorization": f"Bearer {superadmin_token}"}, json={} # Missing required fields ) assert response.status_code == 422 data = response.json() assert "detail" in data ``` ### Example: Testing Permissions ```python theme={null} # tests/integration/test_permissions.py import pytest from httpx import AsyncClient @pytest.mark.asyncio class TestPermissions: """Test permission enforcement.""" async def test_viewer_cannot_delete_posts( self, client: AsyncClient, viewer_token: str, test_post: dict ): """Viewer role should not be able to delete posts.""" response = await client.delete( f"/api/v1/posts/{test_post['id']}", headers={"Authorization": f"Bearer {viewer_token}"} ) assert response.status_code == 403 async def test_editor_can_update_own_drafts( self, client: AsyncClient, editor_token: str, test_post: dict ): """Editor should be able to update their own draft posts.""" response = await client.put( f"/api/v1/posts/{test_post['id']}", headers={"Authorization": f"Bearer {editor_token}"}, json={"title": "Updated Title"} ) assert response.status_code == 200 assert response.json()["title"] == "Updated Title" ``` ## Test Fixtures Fixtures provide reusable test setup. ### Available Fixtures ```python theme={null} # tests/conftest.py import pytest from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker from httpx import AsyncClient, ASGITransport from src.snackbase.infrastructure.api.app import app from src.snackbase.infrastructure.persistence.database import get_db from src.snackbase.core.config import settings # Test database (in-memory SQLite) TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" @pytest.fixture(scope="session") def engine(): """Create test database engine.""" engine = create_async_engine(TEST_DATABASE_URL) yield engine engine.dispose() @pytest.fixture async def db_session(engine): """Create test database session.""" async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) async_session = sessionmaker( engine, class_=AsyncSession, expire_on_commit=False ) async with async_session() as session: yield session await session.rollback() @pytest.fixture async def client(db_session): """Create test HTTP client.""" async def override_get_db(): yield db_session app.dependency_overrides[get_db] = override_get_db async with AsyncClient( transport=ASGITransport(app=app), base_url="http://test" ) as ac: yield ac app.dependency_overrides.clear() @pytest.fixture async def superadmin_token(client: AsyncClient): """Create and return superadmin auth token.""" response = await client.post( "/api/v1/auth/login", json={ "account": "system", "email": "superadmin@example.com", "password": "SuperAdmin123!" } ) return response.json()["access_token"] ``` ### Using Fixtures ```python theme={null} @pytest.mark.asyncio async def test_with_fixtures( client: AsyncClient, # HTTP client fixture db_session: AsyncSession, # Database session fixture superadmin_token: str # Auth token fixture ): """Test using fixtures.""" # Use client for API calls response = await client.get( "/api/v1/posts", headers={"Authorization": f"Bearer {superadmin_token}"} ) # Use db_session for direct database access result = await db_session.execute(select(Post).limit(1)) post = result.scalar_one_or_none() assert response.status_code == 200 assert post is not None ``` ## Async Testing ### Marking Async Tests Use `@pytest.mark.asyncio` for async test functions: ```python theme={null} import pytest @pytest.mark.asyncio async def test_async_function(): """Async test function.""" result = await async_function() assert result is not None ``` ### Async Test Classes ```python theme={null} @pytest.mark.asyncio class TestAsyncOperations: """Class containing async tests.""" async def setup_method(self): """Run before each test method.""" self.data = await load_test_data() async def test_async_operation(self): """Test async operation.""" result = await async_operation(self.data) assert result.success async def teardown_method(self): """Run after each test method.""" await cleanup(self.data) ``` ## Best Practices ### 1. Arrange-Act-Assert Pattern Structure tests clearly: ```python theme={null} def test_user_can_login(): # Arrange: Set up test data user = create_test_user(email="test@example.com", password="password123") # Act: Execute the function being tested result = auth_service.login("test@example.com", "password123") # Assert: Verify the result assert result.success is True assert result.token is not None ``` ### 2. Use Descriptive Assertions ```python theme={null} # BAD: Generic assertion assert result is not None # GOOD: Descriptive assertion assert result.id == "user_123" assert result.email == "test@example.com" assert result.is_active is True ``` ### 3. Test Edge Cases ```python theme={null} @pytest.mark.parametrize("input,expected", [ ("", False), # Empty string ("a", True), # Single character ("a" * 1000, True), # Long string (" spaces ", True), # Whitespace (None, False), # None value ]) def test_validate_email(input, expected): """Test email validation with various inputs.""" result = validate_email(input) assert result == expected ``` ### 4. Mock External Dependencies ```python theme={null} from unittest.mock import AsyncMock, patch @pytest.mark.asyncio async def test_send_notification(): """Test notification sending with mocked external API.""" # Mock the external service with patch("src.snackbase.services.notification_service.send_email") as mock_send: mock_send.return_value = AsyncMock(return_value=True) # Call the function await send_user_notification("user_123", "Test message") # Verify the mock was called mock_send.assert_called_once_with( email="user@example.com", subject="Notification", body="Test message" ) ``` ### 5. Use Factory Boy for Test Data ```python theme={null} # tests/factories.py import factory from factory.alchemy import SQLAlchemyModelFactory from src.snackbase.infrastructure.persistence.models.user import User from src.snackbase.infrastructure.persistence.models.post import Post class UserFactory(SQLAlchemyModelFactory): """Factory for creating test users.""" class Meta: model = User sqlalchemy_session_persistence = "commit" id = factory.LazyFunction(lambda: generate_id("user")) email = factory.Sequence(lambda n: f"user{n}@example.com") account_id = "AB1001" password_hash = "$argon2id$v=19$m=65536,t=3,p=4$test" class PostFactory(SQLAlchemyModelFactory): """Factory for creating test posts.""" class Meta: model = Post sqlalchemy_session_persistence = "commit" id = factory.LazyFunction(lambda: generate_id("post")) account_id = "AB1001" title = factory.Faker("sentence") content = factory.Faker("paragraph") status = "draft" # Use in tests @pytest.mark.asyncio async def test_with_factory(db_session: AsyncSession): """Test using factory for test data.""" post = await PostFactory.create_async( title="Custom Title", status="published" ) assert post.title == "Custom Title" assert post.status == "published" ``` ### 6. Run Tests Frequently ```bash theme={null} # Run tests on file save (using pytest-watch) uv pip install pytest-watch ptw # Or use pytest-xdist for parallel runs uv run pytest -n auto ``` ## Summary | Concept | Key Takeaway | | --------------------- | ------------------------------------------------------------- | | **Test Framework** | pytest with pytest-asyncio for async tests | | **Test Structure** | tests/unit/, tests/integration/, tests/security/ | | **Unit Tests** | Test business logic, pure functions, validation | | **Integration Tests** | Test API endpoints, database operations, permissions | | **Fixtures** | Reusable test setup (db\_session, client, tokens) | | **Async Testing** | Use @pytest.mark.asyncio decorator | | **Best Practices** | Arrange-Act-Assert, descriptive assertions, edge cases, mocks | ## Related Guides * [Adding API Endpoints](./adding-api-endpoints) * [Creating Custom Hooks](./creating-custom-hooks) * [Writing Rules](./writing-rules) # Writing Permission Rules Source: https://docs.snackbase.dev/guides/writing-rules Learn how to write and use permission rules in SnackBase's rule engine for fine-grained access control SnackBase includes a **powerful rule engine** that compiles expression-based rules into efficient SQL WHERE clauses. This guide explains the syntax, variables, and best practices for writing rules. ## Overview Rules are **boolean expressions** associated with collection operations (`list`, `view`, `create`, `update`, `delete`). ```python theme={null} # Rule example: Users can only see their own records created_by = @request.auth.id ``` ### When to Use Rules | Scenario | Example | | | | :---------------------- | :------------------------------------------------- | - | -------------------------------- | | **Record ownership** | `created_by = @request.auth.id` | | | | **Role-based access** | `@request.auth.role = "admin"` | | | | **Status-based access** | \`status = "published" | | @request.auth.role = "admin"\` | | **Pattern matching** | `email ~ "%@company.com"` | | | | **Complex logic** | \`(priority > 5 && @request.auth.role = "manager") | | created\_by = @request.auth.id\` | ## Rule Syntax ### Basic Expressions ```python theme={null} # Field comparisons @request.auth.id = "user_abc123" @request.auth.email = "admin@example.com" status = "published" # Numeric comparisons views > 1000 priority >= 3 quantity <= 50 # Pattern matching (SQL LIKE) title ~ "Draft%" # Starts with Draft email ~ "%@gmail.com" # Ends with @gmail.com # Negation !is_archived status != "archived" ``` ### Logical Operators | Operator | Usage | Description | | | | | | :------- | :--------------- | :--------------------------- | ----------- | ----------------------- | ------- | ----------------------------------- | | `&&` | `cond1 && cond2` | Both conditions must be true | | | | | | \` | | \` | \`cond1 | | cond1\` | At least one condition must be true | | `!` | `!condition` | Simple negation | | | | | | `()` | \`(c1 | | c2) && c3\` | Grouping for precedence | | | **Operator Precedence**: `()` > `!` > comparisons > `&&` > `||` ## Evaluation Context Rules have access to the following variables: ### 1. `@request.auth` (Standard) | Field | Description | | :------------------------- | :---------------------- | | `@request.auth.id` | Current user ID | | `@request.auth.email` | Current user email | | `@request.auth.role` | Current user role | | `@request.auth.account_id` | Current account context | ### 2. `@request.data` (Action-specific) Available during **create** and **update** operations. * `@request.data.fieldname`: Accesses the value being sent in the request body. Example: `!@request.data.is_admin` (Don't allow setting admin flag via this route). ### 3. Record Fields (Direct Access) You can reference any field in the record directly by name. * `created_by` * `status` * `your_custom_field` ## Built-in Macros | Macro | Description | Example | | :------------------ | :---------------------- | :----------------------- | | `@has_role(role)` | User has specific role | `@has_role("admin")` | | `@has_group(group)` | User has specific group | `@has_group("managers")` | | `@owns_record()` | User is the creator | `@owns_record()` | | `@is_creator()` | Alias for @owns\_record | `@is_creator()` | ## Rule Examples ### Example 1: Edit Own Drafts Only allow editing if the user is the creator AND the status is "draft". ```python theme={null} @owns_record() && status = "draft" ``` ### Example 2: Admin or Owner ```python theme={null} @request.auth.role = "admin" || @owns_record() ``` ### Example 3: String Pattern Matching Allow viewing only if the record type starts with "public". ```python theme={null} type ~ "public%" ``` ### Example 4: Complex Multi-Condition ```python theme={null} # User can edit if: # - They're an admin, OR # - They own it AND it's not locked @request.auth.role = "admin" || (@owns_record() && !is_locked) ``` ## Testing Rules ### Manual Testing in Admin UI SnackBase provides a built-in **Rule Tester** in the Collections settings: 1. Navigate to **Collections** -> **Edit Collection** -> **Rules**. 2. Write your rule. 3. Use the "Test Rule" button to simulate evaluation against a mock user and record. ### API Testing You can also test rules via the API: ```bash theme={null} POST /api/v1/roles/test-rule { "rule": "@owns_record() && status = 'draft'", "user_id": "usr_123", "record": {"created_by": "usr_123", "status": "draft"} } ``` ## Best Practices 1. **Keep Rules Simple**: Complex logic is hard to debug. Use **SQL Macros** for complex multi-table checks. 2. **Use Parentheses**: Always group conditions in complex expressions to ensure correct evaluation order. 3. **Optimistic Rules**: In SnackBase, rules are compiled to `WHERE` clauses, so they are extremely fast. 4. **Deny by Default**: If no rule is defined for an operation, access is denied. ## Related Guides * [Permissions Concept](/permissions) * [SQL Macros Reference](../macros) * [API Examples](../api-reference/examples) # Hook System Reference Source: https://docs.snackbase.dev/hooks Complete reference for the SnackBase Hook System v1.0 - a stable extensibility framework **Version**: 1.0 (Stable API) **Status**: Production Ready This page covers the **code-level Python hook system** for extending SnackBase internals with custom Python code. To manage hooks via the REST API without writing code (event triggers, cron schedules, and manual triggers), see [API-Defined Hooks](/concepts/api-hooks). ## Overview The SnackBase Hook System is an **extensibility framework** that allows developers to inject custom logic at specific points in the application lifecycle. It provides a stable, event-driven API for extending SnackBase without modifying core code. ### Key Features * **Event-Driven Architecture**: Subscribe to lifecycle events * **Priority-Based Execution**: Control hook execution order * **Tag-Based Filtering**: Target specific collections or resources * **Before/After Hooks**: Modify data or react to changes * **Built-in Hooks**: Core functionality (timestamps, account isolation) * **Abort Capability**: Cancel operations from before hooks * **Async Support**: Full async/await support * **Stable API**: Guaranteed backward compatibility The Hook System API is stable and follows semantic versioning. Breaking changes will only occur in major version releases. ## Stable API Contract ### Guaranteed Stability **Stable (will not change)**: * `HookRegistry.register()` method signature * `HookRegistry.trigger()` method signature * `HookRegistry.unregister()` method signature * `HookContext` dataclass structure * `AbortHookException` behavior * Hook event naming convention * Priority-based execution order * Tag-based filtering mechanism * Built-in hook behavior **Additive Changes (non-breaking)**: * New hook events * New hook categories * New `HookContext` fields (optional) * New built-in hooks * New utility functions ## Hook Categories Hooks are organized into **8 categories**: | Category | Description | Examples | | ------------------------- | ---------------------------- | ----------------------------------------------------------- | | **App Lifecycle** | Application startup/shutdown | `on_bootstrap`, `on_serve`, `on_terminate` | | **Model Operations** | Internal SQLAlchemy models | `on_model_before_create`, `on_model_after_update` | | **Record Operations** | Dynamic collection records | `on_record_before_create`, `on_record_after_delete` | | **Collection Operations** | Schema changes | `on_collection_before_create`, `on_collection_after_update` | | **Auth Operations** | Authentication events | `on_auth_after_login`, `on_auth_before_register` | | **Request Processing** | HTTP request lifecycle | `on_before_request`, `on_after_request` | | **Realtime** | WebSocket/SSE events | `on_realtime_connect`, `on_realtime_message` | | **Mailer** | Email sending | `on_mailer_before_send`, `on_mailer_after_send` | ## Hook Events ### Naming Convention All hook events follow a consistent pattern: ``` on___ Examples: - on_record_before_create - on_auth_after_login - on_collection_before_delete ``` **Timing**: * `before_*`: Called before operation (can modify data or abort) * `after_*`: Called after successful operation (read-only, side effects) ### Complete Event List #### Record Operations (Dynamic Collections) | Event | Timing | Can Modify | Can Abort | | ------------------------- | ------ | ---------- | --------- | | `on_record_before_create` | Before | Yes | Yes | | `on_record_after_create` | After | No | No | | `on_record_before_update` | Before | Yes | Yes | | `on_record_after_update` | After | No | No | | `on_record_before_delete` | Before | No | Yes | | `on_record_after_delete` | After | No | No | | `on_record_before_query` | Before | Yes | Yes | | `on_record_after_query` | After | Yes | No | #### Auth Operations | Event | Timing | Can Modify | Can Abort | | ------------------------------- | ------ | ---------- | --------- | | `on_auth_before_login` | Before | Yes | Yes | | `on_auth_after_login` | After | No | No | | `on_auth_before_register` | Before | Yes | Yes | | `on_auth_after_register` | After | No | No | | `on_auth_before_logout` | Before | No | Yes | | `on_auth_after_logout` | After | No | No | | `on_auth_before_password_reset` | Before | Yes | Yes | | `on_auth_after_password_reset` | After | No | No | #### Collection Operations | Event | Timing | Can Modify | Can Abort | | ----------------------------- | ------ | ---------- | --------- | | `on_collection_before_create` | Before | Yes | Yes | | `on_collection_after_create` | After | No | No | | `on_collection_before_update` | Before | Yes | Yes | | `on_collection_after_update` | After | No | No | | `on_collection_before_delete` | Before | No | Yes | | `on_collection_after_delete` | After | No | No | ## Usage Guide ### Decorator Registration (Recommended) ```python theme={null} from snackbase.core.hooks import HookEvent @app.state.hook.on_record_before_create("posts", priority=100) async def validate_post(event, data, context): """Validate post before creation.""" if data and len(data.get("title", "")) < 5: from snackbase.domain.entities.hook_context import AbortHookException raise AbortHookException("Title must be at least 5 characters") return data ``` ### Available Decorator Methods **Record Operations:** * `on_record_before_create(collection, priority=0)` * `on_record_after_create(collection, priority=0)` * `on_record_before_update(collection, priority=0)` * `on_record_after_update(collection, priority=0)` * `on_record_before_delete(collection, priority=0)` * `on_record_after_delete(collection, priority=0)` * `on_record_before_query(collection, priority=0)` * `on_record_after_query(collection, priority=0)` **Collection Operations:** * `on_collection_before_create(priority=0)` * `on_collection_after_create(priority=0)` * `on_collection_before_update(priority=0)` * `on_collection_after_update(priority=0)` * `on_collection_before_delete(priority=0)` * `on_collection_after_delete(priority=0)` **Auth Operations:** * `on_auth_before_login(priority=0)` * `on_auth_after_login(priority=0)` * `on_auth_before_register(priority=0)` * `on_auth_after_register(priority=0)` ### HookContext Structure ```python theme={null} @dataclass class HookContext: """Context passed to hooks.""" app: Any # FastAPI app instance user: Optional["User"] # Current authenticated user account_id: Optional[str] # Current account ID request_id: str # Request correlation ID request: Optional["Request"] # FastAPI/Starlette Request object ip_address: Optional[str] # Client IP address user_agent: Optional[str] # Client user agent user_name: Optional[str] # User display name ``` ### Aborting Operations Use `AbortHookException` to cancel an operation from a `before_*` hook: ```python theme={null} from snackbase.domain.entities.hook_context import AbortHookException @app.state.hook.on_record_before_create("posts") async def validate_post(event, data, context): """Prevent spam posts.""" if data and "spam" in data.get("content", "").lower(): raise AbortHookException("Spam content detected") return data ``` ## Built-in Hooks SnackBase includes **4 built-in hooks** that provide core functionality. These hooks are **always active** and cannot be disabled. ### 1. Timestamp Hook **Purpose**: Automatically set `created_at` and `updated_at` timestamps. **Events**: `on_record_before_create`, `on_record_before_update` **Priority**: `-100` (runs early) ### 2. Account Isolation Hook **Purpose**: Enforce multi-tenancy by setting `account_id` from context. **Events**: `on_record_before_create` **Priority**: `-200` (runs very early) ### 3. Created By Hook **Purpose**: Track which user created/updated records. **Events**: `on_record_before_create`, `on_record_before_update` **Priority**: `-150` ### 4. Audit Capture Hook **Purpose**: Automatically capture audit log entries for record operations. **Events**: `on_record_after_create`, `on_record_after_update`, `on_record_after_delete` **Priority**: `100` (runs after all user hooks) This hook respects the `SNACKBASE_AUDIT_LOGGING_ENABLED` configuration. When disabled, no audit entries are captured. ### Built-in Hook Execution Order ``` Priority -200: account_isolation_hook (set account_id) ↓ Priority -150: created_by_hook (set created_by/updated_by) ↓ Priority -100: timestamp_hook (set timestamps) ↓ Priority 0+: User hooks (custom logic) ↓ Priority 100: audit_capture_hook (capture audit logs) ``` ## Creating Custom Hooks ### Example 1: Data Validation ```python theme={null} from snackbase.domain.entities.hook_context import AbortHookException @app.state.hook.on_record_before_create("products", priority=50) async def validate_product_price(event, data, context): """Ensure product price is positive.""" if data and data.get("price", 0) <= 0: raise AbortHookException("Price must be greater than 0") return data ``` ### Example 2: Data Transformation ```python theme={null} @app.state.hook.on_record_before_create("users", priority=50) async def normalize_email(event, data, context): """Normalize email to lowercase.""" if data and "email" in data: data["email"] = data["email"].lower().strip() return data ``` ### Example 3: Computed Fields ```python theme={null} @app.state.hook.on_record_before_create("orders", priority=50) async def calculate_total(event, data, context): """Calculate order total from line items.""" if data and "items" in data: total = sum(item["price"] * item["quantity"] for item in data["items"]) data["total"] = total return data ``` ### Example 4: Notifications ```python theme={null} @app.state.hook.on_record_after_create("orders", priority=50) async def send_order_notification(event, data, context): """Send email notification when order is created.""" if data: await send_email( to=context.user.email, subject="Order Confirmation", body=f"Your order {data['id']} has been created." ) return data ``` ## Advanced Features ### Priority-Based Execution Hooks execute in **priority order** (higher priority = earlier execution): ```python theme={null} # Priority 200 - runs first @app.state.hook.on_record_before_create("posts", priority=200) async def hook_1(event, data, context): data["step"] = "1" return data # Priority 100 - runs second @app.state.hook.on_record_before_create("posts", priority=100) async def hook_2(event, data, context): data["step"] = "2" return data # Priority 0 (default) - runs last @app.state.hook.on_record_before_create("posts") async def hook_3(event, data, context): data["step"] = "3" return data ``` ### Tag-Based Filtering Target specific collections or resources: ```python theme={null} # Only for 'posts' collection registry.register( event=HookEvent.ON_RECORD_BEFORE_CREATE, callback=my_hook, filters={"collection": "posts"} ) ``` ### Error Handling Hooks can fail without crashing the system: ```python theme={null} # Default: errors are logged but don't stop execution registry.register( event=HookEvent.ON_RECORD_AFTER_CREATE, callback=my_hook, stop_on_error=False # Default ) ``` ## Best Practices ### 1. Keep Hooks Focused ```python theme={null} # Bad - does too much async def mega_hook(event, data, context): validate_data(data) send_email(data) update_cache(data) return data # Good - single responsibility async def validate_data_hook(event, data, context): validate_data(data) return data ``` ### 2. Use Appropriate Priorities ```python theme={null} # Validation: High priority (runs early) @app.state.hook.on_record_before_create("posts", priority=100) async def validate(event, data, context): pass # Enrichment: Medium priority @app.state.hook.on_record_before_create("posts", priority=50) async def enrich(event, data, context): pass # Side effects: Low priority (runs late) @app.state.hook.on_record_after_create("posts", priority=10) async def notify(event, data, context): pass ``` ### 3. Handle Errors Gracefully ```python theme={null} @app.state.hook.on_record_after_create("posts") async def send_notification(event, data, context): """Send notification, but don't fail if it errors.""" try: await send_email(data) except Exception as e: logger.error("Failed to send notification", error=str(e)) return data ``` ## API Reference ### HookRegistry ```python theme={null} class HookRegistry: def register( self, event: str, callback: Callable, filters: Optional[dict[str, Any]] = None, priority: int = 0, stop_on_error: bool = False, is_builtin: bool = False, ) -> str: """Register a hook.""" async def trigger( self, event: str, data: Optional[dict[str, Any]] = None, context: Optional[HookContext] = None, filters: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: """Execute all hooks for an event.""" def unregister(self, hook_id: str) -> bool: """Remove a registered hook.""" ``` ### AbortHookException ```python theme={null} class AbortHookException(Exception): """Raise to abort an operation from a before hook.""" def __init__( self, message: str, status_code: int = 400 ): super().__init__(message) self.status_code = status_code ``` ## Related Guides * [Creating Custom Hooks](./creating-custom-hooks) * [Testing](./testing) * [Extending SnackBase](./extending-snackbase) # SnackBase Source: https://docs.snackbase.dev/index

Build like a Startup. Comply like an Enterprise.

The open-source Python backend with immutable audit logs, GxP compliance, and row-level security built-in.

No credit card required
Setup in 5 minutes
SnackBase Admin Interface *** ## Why SnackBase? Define your data schema and get fully functional CRUD REST APIs instantly. No boilerplate, no manual endpoint creation. Built-in support for multiple accounts/organizations with complete data isolation. Perfect for SaaS applications. SQL-native row-level security (RLS), field-level access, single-tenant mode, OAuth, SAML, and audit logs. *** ## Everything You Need to Ship Fast SnackBase provides a complete backend foundation so you can focus on building your product. Define your data models with a simple schema syntax. Get validation, types, and auto-generated APIs. Fine-grained access control at the collection, record, or field level. Write rules, not boilerplate. Built-in OAuth (Google, GitHub, Microsoft, Apple) and SAML support. Or bring your own auth provider. Webhooks, scheduled hooks, multi-step workflows, and custom endpoints -- all configurable via API without writing backend code. Write custom hooks, macros, and business logic in Python. Extend everything. Reliable async job execution with automatic retries, priority queues, and built-in monitoring. *** ## Quick Start in 3 Steps Install SnackBase locally or deploy to your cloud in seconds. Create collections and define your schema using our intuitive syntax. Use the auto-generated REST APIs or React Admin UI immediately. *** ## Developer Resources ***

Start Building Today

Start building compliant apps today

# SQL Macros Source: https://docs.snackbase.dev/macros Learn how to use SQL Macros to extend SnackBase's rule engine with custom database logic SQL Macros allow you to extend the rule expression language by injecting custom SQL queries directly into the permission engine. They are perfect for complex multi-table checks or business logic that requires joining data. ## Overview A SQL Macro is a predefined SQL fragment that is expanded during rule compilation. It behaves like a boolean function when called in a rule. ```python theme={null} # Rule using a macro @is_project_member(project_id) ``` ### Why use SQL Macros? * **Database performance**: Checks are executed as part of the primary query. * **Complexity management**: Move complex logic out of rules and into reusable SQL. * **Transactional safety**: Macros execute within the same transaction as the request. * **Context awareness**: Macros have access to the currently authenticated user. ## Core Concepts ### 1. Naming Macro names must start with `@` when called in a rule, but are defined without it. Example: Defined as `owns_record`, called as `@owns_record()`. ### 2. Parameters Macros can accept parameters. Use `$1`, `$2`, etc., in your SQL query to reference them. Parameters are passed from the rule expression. ### 3. Context Variables Macros can also reference context variables directly in the SQL: * `@request.auth.id`: Current user ID * `@request.auth.account_id`: Current account ID ## Built-in Macros SnackBase comes with several high-performance macros pre-installed: | Macro | SQL Equivalent (approx) | Description | | :------------------- | :---------------------------------------- | :--------------------------------- | | `@has_role("name")` | `@request.auth.role = $1` | Checks if user has a specific role | | `@has_group("name")` | `EXISTS(SELECT 1 FROM group_members ...)` | Checks group membership | | `@owns_record()` | `created_by = @request.auth.id` | Checks if user created the record | | `@is_creator()` | `created_by = @request.auth.id` | Alias for @owns\_record | ## Creating Custom Macros ### Example: `@is_project_member(project_id)` 1. **Name**: `is_project_member` 2. **Description**: Checks if a user is a member of a project. 3. **SQL Query**: ```sql theme={null} SELECT count(*) > 0 FROM project_members WHERE project_id = $1 AND user_id = @request.auth.id AND account_id = @request.auth.account_id ``` **Usage in Rule**: ```python theme={null} @is_project_member(project_id) ``` ### Security Constraints To ensure system stability, SQL macros are subject to the following rules: * **SELECT only**: Only `SELECT` statements are allowed. `INSERT`, `UPDATE`, `DELETE` will be rejected. * **Timeout**: Macros have a 5-second execution timeout. * **Parameter Binding**: All parameters are bound safely to prevent SQL injection. * **Read-Only**: Macros execute in a read-only transaction context. ## API Management ### List Macros `GET /api/v1/macros` ### Create Macro `POST /api/v1/macros` **Payload**: ```json theme={null} { "name": "can_edit_document", "description": "Checks if user has editor rights on a document", "sql_query": "SELECT count(*) > 0 FROM doc_access WHERE doc_id = $1 AND user_id = @request.auth.id AND role = 'editor'" } ``` ### Test Macro `POST /api/v1/macros/{id}/test` Allows testing a macro with specific parameter values before using it in a rule. ## Best Practices 1. **Return Booleans**: Custom SQL macros should always return a result that can be interpreted as a boolean (usually `count(*) > 0` or `EXISTS(...)`). 2. **Index Columns**: Ensure columns used in `WHERE` clauses (like `user_id`, `project_id`) are indexed. 3. **Account Isolation**: Always include `account_id = @request.auth.account_id` in your queries to maintain proper isolation. 4. **Descriptive Names**: Use names like `is_subscriber`, `can_approve_invoice` to make rules self-documenting. ## Related Guides * [Permissions Concept](/permissions) * [Writing Rules](./guides/writing-rules) # Configuration Source: https://docs.snackbase.dev/mcp/configuration Configure the SnackBase MCP server for your needs The SnackBase MCP server uses environment variables for configuration. This guide covers all available options. ## Required Configuration ### Base URL The URL of your SnackBase backend instance: ```bash theme={null} export SNACKBASE_URL="https://your-snackbase-instance.com" ``` For local development, use: `http://localhost:8000` ### API Key Your API key for authentication: ```bash theme={null} export SNACKBASE_API_KEY="sb_ak.payload.signature" ``` The API key format is `sb_ak..` where: * `sb_ak` identifies this as a SnackBase API key * `payload` contains encoded metadata (account, permissions, expiry) * `signature` verifies the key's authenticity ## Optional Configuration ### Account ID (Multi-Tenant) If your API key has access to multiple accounts, specify which one to use: ```bash theme={null} export SNACKBASE_ACCOUNT_ID="your-account-id" ``` If not specified, the MCP server will use the API key's default account. ### Timeout Request timeout in milliseconds (default: 30000ms): ```bash theme={null} export SNACKBASE_TIMEOUT=60000 # 60 seconds ``` ### Debug Mode Enable debug logging for troubleshooting: ```bash theme={null} export SNACKBASE_DEBUG=true ``` This will log additional information to stderr, including: * Incoming tool calls * SDK requests * Response times * Error details ## Configuration Examples ### Local Development ```bash theme={null} # .env for local development SNACKBASE_URL=http://localhost:8000 SNACKBASE_API_KEY=your-dev-api-key SNACKBASE_DEBUG=true ``` ### Production (Self-Hosted) ```bash theme={null} SNACKBASE_URL=https://api.example.com SNACKBASE_API_KEY=sb_ak.xxx.xxx SNACKBASE_TIMEOUT=60000 ``` ### SnackBase Cloud ```bash theme={null} SNACKBASE_URL=https://api.snackbase.dev SNACKBASE_API_KEY=sb_ak.xxx.xxx ``` ## Claude Code Configuration To use the MCP server with Claude Code, add it to your Claude configuration: ### Locate Your Config The Claude Code configuration file is at: * **macOS/Linux**: `~/.claude/settings.json` * **Windows**: `%APPDATA%\claude\settings.json` ### Add MCP Server Add the MCP server to the `mcpServers` section: ```json theme={null} { "mcpServers": { "snackbase": { "command": "snackbase-mcp", "args": [], "env": { "SNACKBASE_URL": "https://your-snackbase-instance.com", "SNACKBASE_API_KEY": "your-api-key" } } } } ``` ### Multiple Instances If you need to connect to multiple SnackBase instances: ```json theme={null} { "mcpServers": { "snackbase-dev": { "command": "snackbase-mcp", "args": [], "env": { "SNACKBASE_URL": "http://localhost:8000", "SNACKBASE_API_KEY": "dev-api-key" } }, "snackbase-prod": { "command": "snackbase-mcp", "args": [], "env": { "SNACKBASE_URL": "https://api.example.com", "SNACKBASE_API_KEY": "prod-api-key" } } } } ``` Don't commit API keys to version control. Use different API keys for different environments and rotate them regularly. ## Cursor Configuration For Cursor IDE, the configuration is similar but in a different location: ### Locate Your Config * **macOS/Linux**: `~/.cursor/settings.json` * **Windows**: `%APPDATA%\cursor\settings.json` ### Add MCP Server ```json theme={null} { "mcpServers": { "snackbase": { "command": "snackbase-mcp", "args": [], "env": { "SNACKBASE_URL": "https://your-snackbase-instance.com", "SNACKBASE_API_KEY": "your-api-key" } } } } ``` ## Troubleshooting ### Connection Errors If you see connection errors: 1. **Verify the URL** is correct and accessible 2. **Check your API key** is valid and not expired 3. **Ensure the backend is running** (for self-hosted) 4. **Test with curl**: ```bash theme={null} curl -H "X-API-Key: $SNACKBASE_API_KEY" \ "$SNACKBASE_URL/api/v1/health" ``` ### Permission Errors If you get permission errors: 1. **Verify the API key** has the required permissions 2. **Check the account** the API key belongs to 3. **Review collection rules** if accessing specific collections ### Debug Mode Enable debug mode to see detailed logs: ```bash theme={null} export SNACKBASE_DEBUG=true snackbase-mcp ``` Look for errors in the stderr output. ## Security Best Practices ### API Key Management * **Use separate keys** for different environments * **Rotate keys regularly** (e.g., every 90 days) * **Use minimal permissions** - only grant what's needed * **Monitor usage** via audit logs * **Revoke compromised keys** immediately ### Environment Isolation * **Never use production keys** in development * **Use different API keys** for different applications * **Document key purposes** in key names ### Audit Trail Monitor MCP server usage via the audit logs: ```typescript theme={null} import { SnackBaseClient } from '@snackbase/sdk'; const client = new SnackBaseClient({ baseUrl: 'https://api.example.com', apiKey: 'admin-api-key', }); // Get recent MCP activity const logs = await client.auditLogs.list({ filter: { operation: { $in: ['create', 'update', 'delete'] } }, sort: '-created_at', limit: 50 }); ``` ## Next Steps * **[Integration Guide](/mcp/integration)** - Use with Claude Code and other clients * **[Tools Reference](/mcp/tools)** - Detailed documentation for each tool # MCP Server Source: https://docs.snackbase.dev/mcp/index Integrate SnackBase with AI assistants using the Model Context Protocol (MCP) The Model Context Protocol (MCP) is a standardized protocol for connecting AI assistants to external data sources and tools. SnackBase's MCP server allows AI models to directly interact with your SnackBase backend. * 15+ tools for complete SnackBase operations * Type-safe data access * Secure API key authentication * Compatible with Claude, Cursor, and other MCP clients * Natural language database queries * AI-powered data analysis * Automated workflows * Developer productivity enhancement *** ## Overview The SnackBase MCP server (`@snackbase/mcp`) exposes SnackBase's functionality as MCP tools that AI assistants can use. It provides a standardized interface for: * **Data Operations**: Query, create, update, and delete records in any collection * **User Management**: Manage users, groups, roles, and permissions * **Account Management**: Handle multi-tenant accounts and their configurations * **Admin Operations**: Access audit logs, email templates, macros, and more * **Monitoring**: Retrieve dashboard statistics and migration status ## How It Works The MCP server runs as a separate process that communicates via stdio (standard input/output). AI assistants that support MCP can: 1. Discover available tools through the MCP protocol 2. Call tools with appropriate parameters 3. Receive structured responses with data or errors The AI assistant (e.g., Claude Code) sends a tool call to the MCP server via stdio. The server authenticates using an API key, validates the request, and calls the appropriate SnackBase SDK service. The SDK communicates with your SnackBase backend via REST API. The MCP server formats the response and returns it to the AI assistant, which can then use the information to help you. ## Available Tools The MCP server exposes 15 tools covering all major SnackBase operations: | Tool | Description | | ---------------------------- | ------------------------------------------------ | | `snackbase_collections` | Manage collections (schemas) and their structure | | `snackbase_records` | CRUD operations on collection records | | `snackbase_collection_rules` | Configure access control rules for collections | | `snackbase_users` | User management and authentication | | `snackbase_groups` | Group management for team-based access | | `snackbase_roles` | Role-based access control | | `snackbase_accounts` | Multi-tenant account management | | `snackbase_invitations` | User invitation management | | `snackbase_api_keys` | API key creation and management | | `snackbase_admin` | Admin operations and configurations | | `snackbase_dashboard` | Dashboard statistics and metrics | | `snackbase_audit_logs` | Audit log access and export | | `snackbase_email_templates` | Email template management | | `snackbase_macros` | SQL macro operations | | `snackbase_migrations` | Migration status and history | The `auth` and `realtime` services are excluded from the MCP server because they don't fit the request/response model well. Authentication should be handled by your application, and realtime requires persistent connections. ## Security Model The MCP server uses **API key authentication** exclusively: * **No user sessions**: MCP tools operate with the permissions of the API key * **Service account**: Use a dedicated service account with appropriate permissions * **Scoped access**: Create API keys with minimal required permissions * **Audit trail**: All MCP operations are logged in the audit logs Never expose API keys with admin privileges to untrusted AI assistants. Always use the minimum required permissions for your use case. ## Architecture ``` ┌─────────────────┐ stdio ┌──────────────────┐ │ AI Assistant │◄──────────────►│ SnackBase MCP │ │ (Claude Code) │ │ Server │ └─────────────────┘ └────────┬─────────┘ │ ▼ ┌──────────────────┐ │ @snackbase/sdk │ └────────┬─────────┘ │ ▼ ┌──────────────────┐ │ SnackBase API │ │ (Your Backend) │ └──────────────────┘ ``` ## Next Steps * **[Installation](/mcp/installation)** - Set up the MCP server * **[Configuration](/mcp/configuration)** - Configure environment variables * **[Integration Guide](/mcp/integration)** - Use with Claude Code and other clients * **[Tools Reference](/mcp/tools)** - Detailed documentation for each tool # Installation Source: https://docs.snackbase.dev/mcp/installation Install and set up the SnackBase MCP server The SnackBase MCP server can be installed via npm or used directly with npx. ## Prerequisites Before installing the MCP server, ensure you have: * **Node.js 20+** installed * **A running SnackBase backend** (self-hosted or SnackBase Cloud) * **An API key** from your SnackBase instance ## Installation Methods ### Option 1: Global Installation (Recommended) Install the MCP server globally for system-wide availability: ```bash theme={null} npm install -g @snackbase/mcp # or pnpm add -g @snackbase/mcp # or yarn global add @snackbase/mcp ``` After installation, the `snackbase-mcp` command will be available: ```bash theme={null} snackbase-mcp ``` ### Option 2: Local Installation Install in your project for development: ```bash theme={null} npm install @snackbase/mcp # or pnpm add @snackbase/mcp # or yarn add @snackbase/mcp ``` Run using npx: ```bash theme={null} npx snackbase-mcp ``` ### Option 3: Docker (Coming Soon) ```bash theme={null} docker pull ghcr.io/lalitgehani/snackbase-mcp:latest ``` ## Verification Verify the installation by running the server with the required environment variables: ```bash theme={null} export SNACKBASE_URL="https://your-snackbase-instance.com" export SNACKBASE_API_KEY="your-api-key-here" snackbase-mcp ``` You should see output like: ``` SnackBase MCP Server starting... SnackBase MCP Server running on stdio ``` The MCP server communicates via stdio, so you won't see an HTTP port. It's designed to be used by MCP clients like Claude Code or Cursor. ## Creating an API Key You need an API key to use the MCP server. Here's how to create one: ### Using the SnackBase Admin UI 1. Navigate to your SnackBase Admin UI 2. Go to **Settings** → **API Keys** 3. Click **Create API Key** 4. Give it a descriptive name (e.g., "MCP Server") 5. Set appropriate permissions (see below) 6. Copy the generated key ### Using the SDK ```typescript theme={null} import { SnackBaseClient } from '@snackbase/sdk'; const client = new SnackBaseClient({ baseUrl: 'https://your-snackbase-instance.com', apiKey: 'your-admin-api-key', // Use an admin key to create new keys }); const apiKey = await client.apiKeys.create({ name: 'MCP Server', expiresAt: new Date('2026-12-31'), // Optional expiry }); console.log(apiKey.key); // Save this securely! ``` ## Recommended Permissions The permissions you grant to the MCP API key depend on your use case: ### For General Development * Read access to all collections * Create/update/delete access to development collections * User management (if testing auth flows) ### For Data Analysis Only * Read access to relevant collections * No write permissions ### For Full Admin Access Use with caution! Only for trusted environments. Never commit API keys to version control. Store them in environment variables or a secure secrets manager. ## Environment Variables The MCP server requires two environment variables: | Variable | Required | Description | Example | | ------------------- | -------- | ------------------------------- | --------------------------- | | `SNACKBASE_URL` | Yes | Your SnackBase backend URL | `https://api.snackbase.dev` | | `SNACKBASE_API_KEY` | Yes | Your API key for authentication | `sb_ak.xxx.xxx` | The API key format changed in v0.3.0 to use three parts: `sb_ak..`. Old keys will continue to work, but new keys use this format. ## Setting Environment Variables ### On macOS/Linux Add to your shell profile (\~/.zshrc, \~/.bashrc): ```bash theme={null} export SNACKBASE_URL="https://your-snackbase-instance.com" export SNACKBASE_API_KEY="sb_ak.xxx.xxx" ``` Then reload: `source ~/.zshrc` ### On Windows (PowerShell) ```powershell theme={null} $env:SNACKBASE_URL="https://your-snackbase-instance.com" $env:SNACKBASE_API_KEY="sb_ak.xxx.xxx" ``` Or set permanently: ```powershell theme={null} [System.Environment]::SetEnvironmentVariable('SNACKBASE_URL', 'https://your-snackbase-instance.com', 'User') [System.Environment]::SetEnvironmentVariable('SNACKBASE_API_KEY', 'sb_ak.xxx.xxx', 'User') ``` ### Using .env file (for development) Create a `.env` file in your project root: ```env theme={null} SNACKBASE_URL=https://your-snackbase-instance.com SNACKBASE_API_KEY=sb_ak.xxx.xxx ``` Then load it before running the server: ```bash theme={null} source .env && snackbase-mcp ``` ## Next Steps * **[Configuration](/mcp/configuration)** - Advanced configuration options * **[Integration Guide](/mcp/integration)** - Use with Claude Code and other clients # Integration Guide Source: https://docs.snackbase.dev/mcp/integration Use the SnackBase MCP server with Claude Code, Cursor, and other AI assistants This guide shows you how to integrate the SnackBase MCP server with various AI assistants and development tools. ## Claude Code Integration Claude Code is a CLI tool that provides AI assistance directly in your terminal. It has native MCP support. ### Setup 1. **Install Claude Code** (if you haven't already): ```bash theme={null} npm install -g claude-code ``` 2. **Configure MCP Server**: Edit `~/.claude/settings.json` (or `%APPDATA%\claude\settings.json` on Windows): ```json theme={null} { "mcpServers": { "snackbase": { "command": "snackbase-mcp", "args": [], "env": { "SNACKBASE_URL": "https://your-snackbase-instance.com", "SNACKBASE_API_KEY": "your-api-key" } } } } ``` 3. **Restart Claude Code** to load the MCP server. ### Usage Examples Once configured, you can interact with your SnackBase data naturally: ```bash theme={null} # List all users claude "List all users in my account" # Create a record claude "Create a new post in the posts collection with title 'Hello World'" # Query with filters claude "Show me all posts created in the last 7 days" # Get statistics claude "What are my dashboard statistics?" ``` ### Common Workflows #### Data Exploration ``` You: "Show me the structure of my 'products' collection" Claude: [Uses snackbase_collections tool to get collection details] ``` #### Quick CRUD Operations ``` You: "Create a new user with email john@example.com" Claude: [Uses snackbase_users tool to create user] ``` #### Data Analysis ``` You: "What are the top 10 most viewed articles?" Claude: [Uses snackbase_records tool with sort and limit] ``` ## Cursor IDE Integration Cursor is an AI-powered code editor with MCP support. ### Setup 1. **Install Cursor** from [cursor.sh](https://cursor.sh) 2. **Configure MCP Server**: Edit `~/.cursor/settings.json` (or `%APPDATA%\cursor\settings.json` on Windows): ```json theme={null} { "mcpServers": { "snackbase": { "command": "snackbase-mcp", "args": [], "env": { "SNACKBASE_URL": "https://your-snackbase-instance.com", "SNACKBASE_API_KEY": "your-api-key" } } } } ``` 3. **Restart Cursor** to load the MCP server. ### Usage in Cursor * Use **Cmd/Ctrl + K** to open the AI chat * The AI will have access to your SnackBase data * Ask questions about your data naturally ## Continue.dev Integration Continue.dev is a VS Code extension for AI code assistance. ### Setup 1. **Install Continue.dev** from the VS Code marketplace 2. **Configure MCP Server**: Edit your VS Code settings.json or Continue config: ```json theme={null} { "continue.mcpServers": { "snackbase": { "command": "snackbase-mcp", "args": [], "env": { "SNACKBASE_URL": "https://your-snackbase-instance.com", "SNACKBASE_API_KEY": "your-api-key" } } } } ``` ### Usage in VS Code * Use **Cmd/Ctrl + Shift + L** to open Continue * Ask questions about your SnackBase data * Get code suggestions based on your schema ## Cline (Formerly Claude Dev) Integration Cline is an autonomous coding agent for VS Code. ### Setup 1. **Install Cline** from the VS Code marketplace 2. **Configure MCP Server** in Cline's settings: ```json theme={null} { "cline.mcpServers": { "snackbase": { "command": "snackbase-mcp", "args": [], "env": { "SNACKBASE_URL": "https://your-snackbase-instance.com", "SNACKBASE_API_KEY": "your-api-key" } } } } ``` ## Custom Integration If you want to build your own MCP client, you can use the MCP SDK directly: ### TypeScript Example ```typescript theme={null} import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; async function main() { // Create transport to the MCP server const transport = new StdioClientTransport({ command: 'snackbase-mcp', args: [], env: { SNACKBASE_URL: 'https://your-snackbase-instance.com', SNACKBASE_API_KEY: 'your-api-key', }, }); // Create and connect client const client = new Client({ name: 'my-mcp-client', version: '1.0.0', }); await client.connect(transport); // List available tools const tools = await client.listTools(); console.log('Available tools:', tools); // Call a tool const result = await client.callTool({ name: 'snackbase_collections', arguments: { action: 'list', }, }); console.log('Collections:', result); } ``` ### Python Example ```python theme={null} import asyncio import subprocess import json async def call_mcp_tool(tool_name, arguments): """Call an MCP tool via stdio""" process = subprocess.Popen( ['snackbase-mcp'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env={ 'SNACKBASE_URL': 'https://your-snackbase-instance.com', 'SNACKBASE_API_KEY': 'your-api-key', } ) # Send request request = { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": tool_name, "arguments": arguments } } process.stdin.write(json.dumps(request) + '\n') process.stdin.flush() # Read response response = json.loads(process.stdout.readline()) return response # Usage asyncio.run(call_mcp_tool('snackbase_collections', {'action': 'list'})) ``` ## Common Patterns ### Natural Language Queries Transform natural language into SnackBase queries: ``` User: "Show me active users" → snackbase_records tool with filter: { status: 'active' } User: "Count records by type" → snackbase_records with aggregation ``` ### Schema-Aware Code Generation The AI can generate code based on your collection schemas: ``` User: "Generate a TypeScript interface for the products collection" → [Uses snackbase_collections to get schema] → Generates TypeScript interface ``` ### Data Validation Use AI to validate data before insertion: ``` User: "Validate this user data before creating" → [Checks collection rules] → Validates required fields → Checks constraints ``` ## Troubleshooting ### MCP Server Not Starting 1. **Verify installation**: `which snackbase-mcp` 2. **Check environment variables**: `echo $SNACKBASE_URL` 3. **Test manually**: Run `snackbase-mcp` in a terminal ### Tools Not Available 1. **Restart the AI assistant** after configuration 2. **Check the MCP server logs** for errors 3. **Verify API key** has required permissions ### Permission Errors 1. **Check API key** permissions 2. **Verify account** access 3. **Review collection rules** if accessing specific data ## Best Practices ### Security * **Use scoped API keys** with minimal permissions * **Rotate keys regularly** * **Monitor audit logs** for unusual activity * **Never expose keys** in client-side code ### Performance * **Use pagination** for large result sets * **Request only needed fields** * **Cache frequently accessed data** * **Use filters** to reduce data transfer ### Error Handling ```typescript theme={null} try { const result = await client.callTool({ name: 'snackbase_records', arguments: { action: 'list', collection: 'posts' }, }); } catch (error) { if (error.message.includes('401')) { // API key invalid or expired } else if (error.message.includes('403')) { // Insufficient permissions } } ``` ## Next Steps * **[Tools Reference](/mcp/tools)** - Detailed documentation for each tool # Tools Reference Source: https://docs.snackbase.dev/mcp/tools/index Complete reference for all SnackBase MCP tools The SnackBase MCP server provides 15 tools covering all major SnackBase operations. Each tool supports multiple actions for CRUD operations and queries. ## Tool Overview | Tool | Actions | Description | | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------- | | [`snackbase_collections`](#collections) | list, list\_names, get, create, update, delete, export, import | Manage collection schemas | | [`snackbase_records`](#records) | list, get, create, update, patch, delete | CRUD operations on records | | [`snackbase_collection_rules`](#collection-rules) | get, update, validate, test | Configure access control | | [`snackbase_users`](#users) | list, get, create, update, delete, set\_password, verify\_email | User management | | [`snackbase_groups`](#groups) | list, get, create, update, delete, add\_member, remove\_member | Group management | | [`snackbase_roles`](#roles) | list, get, create, update, delete | Role-based access control | | [`snackbase_accounts`](#accounts) | list, get, create, update, delete, get\_users | Multi-tenant accounts | | [`snackbase_invitations`](#invitations) | list, create, resend, cancel | User invitations | | [`snackbase_api_keys`](#api-keys) | list, create, revoke | API key management | | [`snackbase_admin`](#admin) | get\_stats, get\_recent, list\_system, list\_account, get\_values, update\_values, create, list\_providers, test\_connection | Admin operations | | [`snackbase_dashboard`](#dashboard) | get\_stats | Dashboard metrics | | [`snackbase_audit_logs`](#audit-logs) | list, get, export | Audit log access | | [`snackbase_email_templates`](#email-templates) | list, get, update, render, send\_test, list\_logs | Email management | | [`snackbase_macros`](#macros) | list, get, create, update, delete, test | SQL macro operations | | [`snackbase_migrations`](#migrations) | list, get\_current, get\_history | Migration status | *** ## Collections **Tool Name**: `snackbase_collections` Manage collection schemas and their structure. ### Actions #### `list` List all collections in the current account. ```json theme={null} { "action": "list", "page": 1, "page_size": 30 } ``` #### `list_names` Get a simplified list of collection names. ```json theme={null} { "action": "list_names" } ``` #### `get` Get details of a specific collection by ID. ```json theme={null} { "action": "get", "collection_id": "collection-id" } ``` #### `create` Create a new collection with defined fields. ```json theme={null} { "action": "create", "name": "posts", "fields": [ { "name": "title", "type": "text", "required": true }, { "name": "content", "type": "text" }, { "name": "published", "type": "boolean", "default": false } ], "list_rule": "user_id = {user_id}", "view_rule": "user_id = {user_id}", "create_rule": "user_id = {user_id}", "update_rule": "user_id = {user_id}", "delete_rule": "user_id = {user_id}" } ``` #### `update` Update an existing collection's schema. ```json theme={null} { "action": "update", "collection_id": "collection-id", "fields": [...] } ``` #### `delete` Delete a collection and all its records. ```json theme={null} { "action": "delete", "collection_id": "collection-id" } ``` *** ## Records **Tool Name**: `snackbase_records` CRUD operations on collection records. ### Actions #### `list` List records from a collection with filtering and pagination. ```json theme={null} { "action": "list", "collection": "posts", "filter": { "status": "published" }, "sort": "-created_at", "limit": 10, "skip": 0, "fields": ["id", "title", "created_at"], "expand": ["author"] } ``` #### `get` Get a single record by ID. ```json theme={null} { "action": "get", "collection": "posts", "record_id": "record-id", "expand": ["author", "comments"] } ``` #### `create` Create a new record. ```json theme={null} { "action": "create", "collection": "posts", "data": { "title": "My Post", "content": "Post content", "status": "published" } } ``` #### `update` Full update of a record (replaces all fields). ```json theme={null} { "action": "update", "collection": "posts", "record_id": "record-id", "data": { "title": "Updated Title", "content": "Updated content" } } ``` #### `patch` Partial update of a record (only updates specified fields). ```json theme={null} { "action": "patch", "collection": "posts", "record_id": "record-id", "data": { "title": "New Title Only" } } ``` #### `delete` Delete a record. ```json theme={null} { "action": "delete", "collection": "posts", "record_id": "record-id" } ``` *** ## Collection Rules **Tool Name**: `snackbase_collection_rules` Configure access control rules for collections. ### Actions #### `get` Get access rules for a collection. ```json theme={null} { "action": "get", "collection_name": "posts" } ``` #### `update` Update access rules for a collection. ```json theme={null} { "action": "update", "collection_name": "posts", "data": { "list_rule": "status = 'published' or user_id = {user_id}", "view_rule": "user_id = {user_id}", "create_rule": "user_id = {user_id}", "update_rule": "user_id = {user_id}", "delete_rule": "user_id = {user_id}" } } ``` #### `validate` Validate a rule expression. ```json theme={null} { "action": "validate", "rule": "user_id = {user_id}", "operation": "list", "collection_fields": ["user_id", "title", "content"] } ``` #### `test` Test a rule with sample context. ```json theme={null} { "action": "test", "rule": "user_id = {user_id}", "context": { "user_id": "user-123" } } ``` *** ## Users **Tool Name**: `snackbase_users` Manage user accounts and authentication. ### Actions #### `list` List users with filtering and pagination. ```json theme={null} { "action": "list", "search": "john", "is_active": true, "sort_by": "created_at", "sort_order": "desc", "page": 1, "page_size": 30 } ``` #### `get` Get a specific user by ID. ```json theme={null} { "action": "get", "user_id": "user-id" } ``` #### `create` Create a new user. ```json theme={null} { "action": "create", "email": "user@example.com", "account_id": "account-id", "password": "SecurePassword123!", "role": "member" } ``` #### `update` Update user details. ```json theme={null} { "action": "update", "user_id": "user-id", "is_active": true, "role": "admin" } ``` #### `delete` Deactivate a user. ```json theme={null} { "action": "delete", "user_id": "user-id" } ``` #### `set_password` Set a user's password (admin operation). ```json theme={null} { "action": "set_password", "user_id": "user-id", "password": "NewPassword123!" } ``` #### `verify_email` Manually verify a user's email. ```json theme={null} { "action": "verify_email", "user_id": "user-id" } ``` *** ## Groups **Tool Name**: `snackbase_groups` Manage groups for team-based access control. ### Actions #### `list` List all groups. ```json theme={null} { "action": "list", "search": "engineering", "page": 1, "page_size": 30 } ``` #### `get` Get group details. ```json theme={null} { "action": "get", "group_id": "group-id" } ``` #### `create` Create a new group. ```json theme={null} { "action": "create", "name": "Engineering", "description": "Engineering team members" } ``` #### `update` Update group details. ```json theme={null} { "action": "update", "group_id": "group-id", "name": "Engineering Team", "description": "All engineering staff" } ``` #### `delete` Delete a group. ```json theme={null} { "action": "delete", "group_id": "group-id" } ``` #### `add_member` Add a user to a group. ```json theme={null} { "action": "add_member", "group_id": "group-id", "user_id": "user-id" } ``` #### `remove_member` Remove a user from a group. ```json theme={null} { "action": "remove_member", "group_id": "group-id", "user_id": "user-id" } ``` *** ## Roles **Tool Name**: `snackbase_roles` Manage roles for role-based access control. ### Actions #### `list` List all roles. ```json theme={null} { "action": "list" } ``` #### `get` Get role details. ```json theme={null} { "action": "get", "role_id": "role-id" } ``` #### `create` Create a new role. ```json theme={null} { "action": "create", "name": "Editor", "description": "Can edit but not delete content" } ``` #### `update` Update role details. ```json theme={null} { "action": "update", "role_id": "role-id", "name": "Content Editor", "description": "Editors with limited permissions" } ``` #### `delete` Delete a role. ```json theme={null} { "action": "delete", "role_id": "role-id" } ``` *** ## Accounts **Tool Name**: `snackbase_accounts` Manage multi-tenant accounts. ### Actions #### `list` List all accounts. ```json theme={null} { "action": "list", "search": "acme", "is_active": true, "sort_by": "name", "sort_order": "asc", "page": 1, "page_size": 30 } ``` #### `get` Get account details. ```json theme={null} { "action": "get", "account_id": "account-id" } ``` #### `create` Create a new account. ```json theme={null} { "action": "create", "name": "Acme Corp", "slug": "acme-corp" } ``` #### `update` Update account details. ```json theme={null} { "action": "update", "account_id": "account-id", "name": "Acme Corporation" } ``` #### `delete` Delete an account. ```json theme={null} { "action": "delete", "account_id": "account-id" } ``` #### `get_users` Get all users in an account. ```json theme={null} { "action": "get_users", "account_id": "account-id", "page": 1, "page_size": 30 } ``` *** ## Invitations **Tool Name**: `snackbase_invitations` Manage user invitations. ### Actions #### `list` List all invitations. ```json theme={null} { "action": "list", "status": "pending", "page": 1, "page_size": 30 } ``` #### `create` Create a new invitation. ```json theme={null} { "action": "create", "email": "newuser@example.com", "role_id": "role-id" } ``` #### `resend` Resend a pending invitation. ```json theme={null} { "action": "resend", "invitation_id": "invitation-id" } ``` #### `cancel` Cancel a pending invitation. ```json theme={null} { "action": "cancel", "invitation_id": "invitation-id" } ``` *** ## API Keys **Tool Name**: `snackbase_api_keys` Manage API keys for programmatic access. ### Actions #### `list` List all API keys. ```json theme={null} { "action": "list" } ``` #### `create` Create a new API key. ```json theme={null} { "action": "create", "name": "Production API Key", "expires_at": "2026-12-31T23:59:59Z" } ``` #### `revoke` Revoke an API key. ```json theme={null} { "action": "revoke", "key_id": "key-id" } ``` *** ## Admin **Tool Name**: `snackbase_admin` Admin operations for configurations and providers. ### Actions #### `get_stats` Get admin statistics. ```json theme={null} { "action": "get_stats" } ``` #### `get_recent` Get recent configurations. ```json theme={null} { "action": "get_recent", "limit": 10 } ``` #### `list_system` List system-level configurations. ```json theme={null} { "action": "list_system" } ``` #### `list_account` List account-level configurations. ```json theme={null} { "action": "list_account", "account_id": "account-id" } ``` #### `get_values` Get configuration values. ```json theme={null} { "action": "get_values", "config_id": "config-id" } ``` #### `update_values` Update configuration values. ```json theme={null} { "action": "update_values", "config_id": "config-id", "values": { "smtp_host": "smtp.example.com", "smtp_port": 587 } } ``` #### `create` Create a new configuration. ```json theme={null} { "action": "create", "name": "Custom Provider", "provider_name": "custom", "values": {...} } ``` #### `list_providers` List available providers. ```json theme={null} { "action": "list_providers", "category": "email" } ``` #### `test_connection` Test a provider connection. ```json theme={null} { "action": "test_connection", "config": { "provider": "smtp", "host": "smtp.example.com", "port": 587 } } ``` *** ## Dashboard **Tool Name**: `snackbase_dashboard` Get dashboard statistics and metrics. ### Actions #### `get_stats` Get dashboard statistics. ```json theme={null} { "action": "get_stats" } ``` Returns: * Total accounts * Total users * Total collections * Total records * Recent activity * System health *** ## Audit Logs **Tool Name**: `snackbase_audit_logs` Query and export audit logs. ### Actions #### `list` List audit log entries. ```json theme={null} { "action": "list", "user_id": "user-id", "table_name": "users", "operation": "create", "from_date": "2026-01-01T00:00:00Z", "to_date": "2026-01-31T23:59:59Z", "page": 1, "limit": 50 } ``` #### `get` Get a specific audit log entry. ```json theme={null} { "action": "get", "log_id": "log-id" } ``` #### `export` Export audit logs. ```json theme={null} { "action": "export", "format": "json", "from_date": "2026-01-01T00:00:00Z", "to_date": "2026-01-31T23:59:59Z" } ``` *** ## Email Templates **Tool Name**: `snackbase_email_templates` Manage email templates. ### Actions #### `list` List email templates. ```json theme={null} { "action": "list", "template_type": "welcome", "locale": "en" } ``` #### `get` Get email template details. ```json theme={null} { "action": "get", "template_id": "template-id" } ``` #### `update` Update email template. ```json theme={null} { "action": "update", "template_id": "template-id", "subject": "Welcome to {{app_name}}!", "html_body": "...", "text_body": "Plain text version", "enabled": true } ``` #### `render` Render template with variables. ```json theme={null} { "action": "render", "template_type": "welcome", "locale": "en", "variables": { "user_name": "John", "app_name": "MyApp" } } ``` #### `send_test` Send a test email. ```json theme={null} { "action": "send_test", "template_id": "template-id", "recipient_email": "test@example.com", "variables": {...} } ``` #### `list_logs` List email send logs. ```json theme={null} { "action": "list_logs", "start_date": "2026-01-01", "end_date": "2026-01-31" } ``` *** ## Macros **Tool Name**: `snackbase_macros` Manage SQL macros for complex queries. ### Actions #### `list` List all macros. ```json theme={null} { "action": "list" } ``` #### `get` Get macro details. ```json theme={null} { "action": "get", "macro_id": "macro-id" } ``` #### `create` Create a new macro. ```json theme={null} { "action": "create", "name": "active_users", "description": "Get active users in date range", "sql_query": "SELECT * FROM users WHERE last_login >= {start_date} AND last_login < {end_date}", "parameters": ["start_date", "end_date"] } ``` #### `update` Update macro. ```json theme={null} { "action": "update", "macro_id": "macro-id", "sql_query": "SELECT * FROM users WHERE last_login >= {start_date}", "parameters": ["start_date"] } ``` #### `delete` Delete macro. ```json theme={null} { "action": "delete", "macro_id": "macro-id" } ``` #### `test` Test macro with parameters. ```json theme={null} { "action": "test", "macro_id": "macro-id", "params": { "start_date": "2026-01-01", "end_date": "2026-02-01" } } ``` *** ## Migrations **Tool Name**: `snackbase_migrations` Query migration status and history. ### Actions #### `list` List all migrations. ```json theme={null} { "action": "list" } ``` #### `get_current` Get current migration version. ```json theme={null} { "action": "get_current" } ``` #### `get_history` Get migration history. ```json theme={null} { "action": "get_history" } ``` The MCP server provides read-only access to migrations. To apply migrations, use the SnackBase CLI: `snackbase migrate up` *** ## Response Format All MCP tools return responses in this format: ```json theme={null} { "content": [ { "type": "text", "text": "{...json data...}" } ] } ``` The text field contains a JSON string with the tool's response data. ## Error Handling Errors are returned in the same format with an error message: ```json theme={null} { "content": [ { "type": "text", "text": "{\"error\": \"Error message here\"}" } ], "isError": true } ``` Common errors: * `400` - Bad request (invalid parameters) * `401` - Unauthorized (invalid API key) * `403` - Forbidden (insufficient permissions) * `404` - Not found * `422` - Validation error (invalid data) * `500+` - Server error ## Next Steps * **[Integration Guide](/mcp/integration)** - Use these tools with AI assistants * **[Configuration](/mcp/configuration)** - Set up the MCP server # Permissions & Authorization Source: https://docs.snackbase.dev/permissions Complete guide to SnackBase's collection-centric access control rules SnackBase uses a database-centric rule engine that compiles expression-based rules into efficient SQL WHERE clauses for granular row-level security (RLS). ## Overview In snackbase v0.2, permissions have shifted from a role-based model to a **Collection-centric** model. Instead of defining what each role can do, you define **Rules** directly on the collection. Each collection has 5 distinct operations that can be controlled: 1. **list**: Controls which records appear in list results (row-level filtering). 2. **view**: Controls access to a single record by ID. 3. **create**: Validates whether a new record can be created. 4. **update**: Controls whether an existing record can be modified. 5. **delete**: Controls whether a record can be removed. ### Role Management While rules are defined on collections, they still leverage user roles. Roles are now used as labels/identifiers within rule expressions (e.g., `@request.auth.role = "admin"`). ### Performance Rule expressions are compiled into native SQL. This means: * **No performance overhead**: Checks happen at the database level. * **Scalable**: Works efficiently even with millions of records. * **SQL-Native**: Supports complex joining logic via SQL Macros. ## Rule Syntax ### Variables | Variable | Description | Example | | :---------------- | :------------------------------------- | :---------------------------------- | | `@request.auth.*` | The authenticated user's data | `id`, `email`, `role`, `account_id` | | `@request.data.*` | The incoming data (create/update only) | `@request.data.status` | | `fieldname` | Direct access to record fields | `created_by`, `status`, `title` | ### Operators | Category | Operator | Description | Example | | | | | | :------------- | :-------- | :-------------------- | :----------------------------------- | ---------- | ------------------------------ | - | -------------------------------- | | **Comparison** | `=` | Equal to | `created_by = @request.auth.id` | | | | | | | `!=` | Not equal to | `status != "archived"` | | | | | | | `<` `>` | Less/Greater than | `priority > 10` | | | | | | | `<=` `>=` | Less/Greater or equal | `amount >= 100` | | | | | | | `~` | LIKE (string match) | `title ~ "draft%"` | | | | | | **Logical** | `&&` | Logical AND | `status = "public" && active = true` | | | | | | | \` | | \` | Logical OR | \`@request.auth.role = "admin" | | created\_by = @request.auth.id\` | | | `!` | Logical NOT | `!is_locked` | | | | | ### Literals * **Strings**: `"text"` or `'text'` * **Numbers**: `123`, `45.67` * **Booleans**: `true`, `false` ## Built-in Macros Macros are reusable expression fragments that simplify common patterns. * `@has_role("role_name")`: Convenience for `@request.auth.role = "role_name"` * `@has_group("group_name")`: Checks if user is in a specific group * `@owns_record()`: Convenience for `created_by = @request.auth.id` * `@is_creator()`: Same as `@owns_record()` ## SQL Macros SQL macros allow you to create custom permission logic using raw SQL queries. ### Example: `@is_project_member(project_id)` * **SQL Query**: ```sql theme={null} SELECT count(*) > 0 FROM project_members WHERE project_id = $1 AND user_id = @request.auth.id ``` * **Usage in Rule**: ```python theme={null} @is_project_member(project_id) ``` ## System Fields | Field | Description | | :----------- | :---------------------------------------- | | `id` | Auto-generated record identifier | | `account_id` | Account/tenant identifier (auto-set) | | `created_at` | Record creation timestamp (auto-set) | | `updated_at` | Record update timestamp (auto-set) | | `created_by` | User ID who created the record (auto-set) | | `updated_by` | User ID who last updated the record | ## Field-Level Access Control You can restrict field visibility per operation. This is now managed via `list_fields`, `view_fields`, etc. in the collection rules. ```json theme={null} { "list_fields": ["id", "title", "status"], "view_fields": "*" } ``` ## API Management Collection rules are managed via the Collections API: * `GET /api/v1/collections/{name}/rules` * `PUT /api/v1/collections/{name}/rules` ## Common Patterns ### 1. Public Read Access * **list\_rule**: `true` * **view\_rule**: `true` * **modify\_rules**: `@request.auth.role = "admin"` ### 2. Owner-Only Access * **list\_rule**: `created_by = @request.auth.id` * **view\_rule**: `created_by = @request.auth.id` * **update\_rule**: `created_by = @request.auth.id` ## Related Guides * [Writing Rules](./guides/writing-rules) * [Macros Reference](./macros) * [Deployment Guide](./deployment) # Quick Start Guide Source: https://docs.snackbase.dev/quickstart Get up and running with SnackBase in 5 minutes Get up and running with SnackBase in 5 minutes. This guide will walk you through the essential steps to set up your instance, create your first collection, add data, set up permissions, and make API requests. ## Prerequisites Before you begin, ensure you have: * **Python 3.12+** installed * **Node.js 18+** installed (for the admin UI) * **uv** package manager installed * **macOS/Linux**: `curl -LsSf https://astral.sh/uv/install.sh | sh` * **Windows**: `powershell -c "irm https://astral.sh/uv/install.ps1 | iex"` * **Git** installed (for cloning the repository) **Windows Users**: If you don't have Node.js installed, download it from [nodejs.org](https://nodejs.org/). For Python, use the [Microsoft Store](https://apps.microsoft.com/store/detail/python-3-12/9NRWMJP3717K) or [python.org](https://www.python.org/downloads/). ## Step 1: Install and Start SnackBase ### 1.1 Clone the Repository ```bash theme={null} git clone https://github.com/lalitgehani/snackbase.git cd SnackBase ``` ### 1.2 Install Backend Dependencies SnackBase uses `uv` for fast, reliable package management: ```bash theme={null} uv sync ``` ### 1.3 Install Frontend Dependencies Navigate to the UI directory and install Node.js dependencies: ```bash theme={null} cd ui npm install cd .. ``` ### 1.4 Configure Environment Create a `.env` file from the example template: ```bash theme={null} cp .env.example .env ``` For local development, the default values work fine. However, for production, update at minimum: * `SNACKBASE_SECRET_KEY` - Generate a secure random string * `SNACKBASE_DATABASE_URL` - Switch to PostgreSQL * `SNACKBASE_CORS_ORIGINS` - Add your frontend URL ### 1.5 Initialize the Database ```bash theme={null} uv run python -m snackbase init-db ``` This command: * Creates the database file at `./sb_data/snackbase.db` * Runs Alembic migrations to set up all tables * Creates the `system` account (ID: `SY0000`) for superadmin operations ### 1.6 Configure Email (Optional but Recommended) SnackBase requires email configuration for user email verification. Choose one of these options: **Option A: Use a Development SMTP Server (Recommended for Testing)** ```bash theme={null} # Install MailHog for local email testing # macOS brew install mailhog # Start MailHog mailhog ``` Then update your `.env`: ```bash theme={null} # For MailHog (localhost:1025) EMAIL_PROVIDER=smtp SMTP_HOST=localhost SMTP_PORT=1025 SMTP_USER= SMTP_PASSWORD= SMTP_FROM=noreply@snackbase.local ``` **Option B: Use Resend (Recommended for Production)** Sign up at [resend.com](https://resend.com) and get an API key: ```bash theme={null} EMAIL_PROVIDER=resend RESEND_API_KEY=re_xxxxxxxxxxxxx EMAIL_FROM=noreply@yourdomain.com ``` **Option C: Use AWS SES** ```bash theme={null} EMAIL_PROVIDER=aws_ses AWS_SES_REGION=us-east-1 AWS_ACCESS_KEY_ID=your_key AWS_SECRET_ACCESS_KEY=your_secret EMAIL_FROM=noreply@yourdomain.com ``` ### 1.7 Create a Superadmin User ```bash theme={null} uv run python -m snackbase create-superadmin ``` You'll be prompted to enter: * Email address (e.g., `admin@example.com`) * Password (minimum 8 characters, recommended: mix of letters, numbers, symbols) * Password confirmation The superadmin account will require email verification if email is configured. You can verify the email via the API or auto-verify superadmin emails in development mode. ### 1.8 Start the Backend Server ```bash theme={null} uv run python -m snackbase serve ``` You should see output indicating the server is running: ``` INFO: Uvicorn running on http://0.0.0.0:8000 INFO: Application startup complete. ``` ### 1.9 Start the Frontend (New Terminal) Open a new terminal and start the React development server: ```bash theme={null} cd ui npm run dev ``` You should see: ``` VITE v7.x.x ready in xxx ms ➜ Local: http://localhost:5173/ ``` ### 1.10 Access the Admin UI Open your browser and navigate to: ``` http://localhost:5173 ``` ## Step 2: Log In to the Admin UI ### 2.1 Enter Your Credentials On the login page, enter: * **Account**: `system` (or `SY0000`) * **Email**: The superadmin email you created * **Password**: The superadmin password you created Click **Sign In**. The superadmin account is always linked to the `system` account (ID: `SY0000`). Use "system" as the account field when logging in. ### 2.2 Verify Your Email (If Required) If email verification is enabled, you'll see a message asking you to verify your email. **For Development with MailHog:** 1. Open `http://localhost:8025` in your browser 2. Find the verification email 3. Click the verification link **To Skip Verification in Development:** Use the admin API to verify your email: ```bash theme={null} # Get your access token first, then: curl -X POST http://localhost:8000/api/v1/admin/verify-email \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"email": "admin@example.com"}' ``` ### 2.3 Welcome to the Dashboard After logging in (and verifying your email if required), you'll see the main dashboard with: * Sidebar navigation on the left * Statistics cards (collections, records, users, etc.) * Recent activity or quick actions ## Step 3: Create Your First Collection A **Collection** is like a table in a traditional database. It defines the structure of your data with fields and types. ### 3.1 Navigate to Collections Click **Collections** in the sidebar. ### 3.2 Create a New Collection Click the **+ New Collection** button. A modal or form will appear. Enter: * **Name**: `posts` (this will become the API endpoint: `/api/v1/records/posts`) * **Description**: `Blog posts and articles` (optional) Click **Create**. ### 3.3 Add Fields to Your Collection After creating the collection, you'll see the collection detail page. Now let's add fields. Click **+ Add Field** and add the following fields: | Field Name | Type | Required | Options | | -------------- | ------ | -------- | ----------------------------------------- | | `title` | Text | Yes | - | | `content` | Text | No | Multi-line: Yes | | `status` | Select | Yes | Options: `draft`, `published`, `archived` | | `published_at` | Date | No | - | | `views` | Number | No | Default: `0` | ## Step 4: Add Your First Records Now that your collection is set up, let's add some data. ### 4.1 Navigate to Records Click **Records** in the sidebar, then select the **posts** collection. ### 4.2 Create a Record Click **+ New Record**. Fill in the form: * **title**: `My First Blog Post` * **content**: `This is my first post using SnackBase!` * **status**: `published` * **published\_at**: Select today's date * **views**: Leave as `0` (default) Click **Save**. ### 4.3 View Your Record After saving, you'll see your record in the data table. ### 4.4 Add More Records Create a few more records to have some test data: 1. "Getting Started with SnackBase" (status: `published`) 2. "Draft Post About API Design" (status: `draft`) 3. "Archived Announcement" (status: `archived`) ## Step 5: Set Up Roles and Permissions SnackBase uses Role-Based Access Control (RBAC) to manage who can do what with your data. ### 5.1 Navigate to Roles Click **Roles** in the sidebar. ### 5.2 Understand Default Roles By default, you'll see: * **admin** - Full access to all collections and operations The superadmin user you created has the `admin` role. ### 5.3 Create a New Role Click **+ New Role**. Enter: * **Name**: `editor` * **Description**: `Can create and edit posts, but cannot delete` Click **Create**. ### 5.4 Configure Permissions On the role detail page for `editor`: 1. Click **+ Add Permission** 2. Configure: * **Collection**: `posts` * **Create**: Enabled * **Read**: Enabled * **Update**: Enabled * **Delete**: Disabled Click **Save**. ### 5.5 Create a Read-Only Role Repeat the process to create a `viewer` role: * **Name**: `viewer` * **Collection**: `posts` * **Read**: Enabled only ## Step 6: Create Additional Users Now let's create users with different roles to test permissions. ### 6.1 Navigate to Users Click **Users** in the sidebar. ### 6.2 Create an Editor User Click **+ New User**. Enter: * **Email**: `editor@example.com` * **Password**: `EditorPass123!` * **Role**: `editor` * **Active**: Yes Click **Create**. ### 6.3 Create a Viewer User Repeat to create a viewer user: * **Email**: `viewer@example.com` * **Password**: `ViewerPass123!` * **Role**: `viewer` ## Step 7: Make Your First API Request SnackBase automatically generates REST APIs for your collections. Let's test it! ### 7.1 Get Your Access Token Open your browser's developer tools: * Press `F12` or `Cmd+Option+I` (Mac) * Go to the **Application** or **Storage** tab * Find **Local Storage** → `http://localhost:5173` * Copy the value of `access_token` ### 7.2 Test the API with curl Open a new terminal and try fetching all posts: ```bash theme={null} curl http://localhost:8000/api/v1/records/posts \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ -H "Content-Type: application/json" ``` Replace `YOUR_ACCESS_TOKEN` with the token you copied. ### 7.3 Create a Record via API Create a new post using the API: ```bash theme={null} curl -X POST http://localhost:8000/api/v1/records/posts \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "title": "Created via API", "content": "This post was created using the REST API", "status": "published", "views": 0 }' ``` ### 7.4 Explore the Interactive API Docs SnackBase includes auto-generated API documentation powered by Swagger UI. Open in your browser: ``` http://localhost:8000/docs ``` ## Step 8: Test Permissions Let's verify that the permission system works correctly. ### 8.1 Log In as Editor Open an incognito/private window and navigate to: ``` http://localhost:5173 ``` Log in as: * **Account**: `system` * **Email**: `editor@example.com` * **Password**: `EditorPass123!` ### 8.2 Verify Edit Capabilities Navigate to **Records** → **posts**. You should see: * Edit buttons on existing records * No Delete buttons (editor role cannot delete) ### 8.3 Test Delete Restriction Try to delete a record using the API with editor credentials: ```bash theme={null} curl -X DELETE http://localhost:8000/api/v1/records/posts/RECORD_ID \ -H "Authorization: Bearer EDITOR_ACCESS_TOKEN" ``` You should receive a `403 Forbidden` error. ## Development Commands ### Backend Commands ```bash theme={null} # Server management uv run python -m snackbase serve # Start server (0.0.0.0:8000) uv run python -m snackbase serve --reload # Dev mode with auto-reload uv run python -m snackbase info # Show configuration # Database uv run python -m snackbase init-db # Initialize database (dev only) uv run python -m snackbase create-superadmin # Create superadmin user # Interactive shell uv run python -m snackbase shell # IPython REPL with pre-loaded context # Code quality uv run ruff check . # Lint uv run ruff format . # Format uv run mypy src/ # Type check # Testing uv run pytest # Run all tests uv run pytest tests/unit/ # Unit tests only uv run pytest tests/integration/ # Integration tests only uv run pytest --cov=snackbase # With coverage uv run pytest -k "test_name" # Run specific test ``` ### Frontend Commands ```bash theme={null} cd ui npm run dev # Start dev server (Vite) npm run build # Production build npm run lint # ESLint npm run preview # Preview production build ``` ## Troubleshooting ### Database Errors **Problem**: `sqlite3.OperationalError: unable to open database file` **Solution**: Ensure the `sb_data` directory exists and is writable: ```bash theme={null} mkdir -p sb_data chmod 755 sb_data ``` **Windows**: Create the folder manually in File Explorer if needed. ### Port Conflicts **Problem**: `Error: listen tcp 0.0.0.0:8000: bind: address already in use` **Solution**: Another process is using port 8000. Find and stop it: **macOS/Linux**: ```bash theme={null} # Find the process lsof -ti:8000 | xargs kill -9 ``` **Windows**: ```cmd theme={null} # Find the process netstat -ano | findstr :8000 # Kill the process (replace PID with the actual process ID) taskkill /PID PID /F ``` Alternatively, change the port in `.env`: ```bash theme={null} SNACKBASE_PORT=8001 ``` ### Email Verification Problems **Problem**: Users can't log in due to unverified email **Solution**: Manually verify the user via API: ```bash theme={null} curl -X POST http://localhost:8000/api/v1/admin/verify-email \ -H "Authorization: Bearer YOUR_SUPERADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"email": "user@example.com"}' ``` **Problem**: Verification emails not being sent **Solution**: Check your email configuration: 1. Verify email settings in `.env` 2. If using MailHog, ensure it's running (`mailhog` command) 3. Check server logs for email errors ## API Endpoints Reference SnackBase provides 20+ API routers: | Router | Endpoints | Description | | ------------------------------ | --------------------------------- | -------------------------- | | `/api/v1/auth` | Login, register, refresh, me | Authentication | | `/api/v1/auth/oauth` | OAuth flow (Google, GitHub, etc.) | OAuth authentication | | `/api/v1/auth/saml` | SAML SSO flow | SAML authentication | | `/api/v1/accounts` | CRUD operations | Account management | | `/api/v1/collections` | CRUD operations | Collection management | | `/api/v1/records/{collection}` | CRUD operations | Dynamic collection records | | `/api/v1/users` | CRUD operations | User management | | `/api/v1/roles` | CRUD, permissions | Role management | | `/api/v1/permissions` | CRUD operations | Permission management | | `/api/v1/macros` | CRUD, execute | SQL macro management | | `/api/v1/groups` | CRUD operations | Group management | | `/api/v1/invitations` | Create, accept | User invitations | | `/api/v1/dashboard` | Statistics | Dashboard data | | `/api/v1/audit-logs` | List, export, filter | Audit log retrieval | | `/api/v1/migrations` | Status, history | Alembic migration status | | `/api/v1/files` | Upload, download, delete | File management | | `/api/v1/admin` | System configuration | Admin controls | | `/api/v1/admin/email` | Email templates, settings | Email management | ## Common Gotchas & Tips ### Gotcha 1: Account Context in API Requests When making API requests, your user is always associated with an **account**. All data is isolated by `account_id`, even if you're using a single account setup. **Solution**: Be aware that filtering by account happens automatically. You don't need to specify `account_id` in your queries. ### Gotcha 2: Collection Names Become URL Endpoints The collection name you choose becomes part of the API URL: * Collection `blog-posts` → `/api/v1/records/blog-posts` * Collection `posts` → `/api/v1/records/posts` **Solution**: Use simple, URL-friendly names with lowercase letters, numbers, and hyphens. ### Gotcha 3: Built-in Hooks Auto-Set Timestamps Every record automatically gets `created_at` and `updated_at` timestamps. You don't need to add these as fields. **Solution**: Don't create manual timestamp fields—they're built-in! ### Gotcha 4: Superadmin vs Admin | Aspect | Superadmin | Admin (role) | | ------- | ----------------- | -------------- | | Account | `system` (SY0000) | Any account | | Access | All accounts | Single account | **Solution**: Use superadmin only for system-level operations. Use admin roles for day-to-day account management. ### Gotcha 5: Permission Caching Permissions are cached for 5 minutes. If you change a role's permissions, it may take up to 5 minutes to take effect. **Solution**: Wait 5 minutes or restart the server after permission changes for immediate effect. ## Production Considerations Before deploying to production, review these important security and configuration items: ### Security Settings ```bash theme={null} # Generate a secure secret key python -c "import secrets; print(secrets.token_urlsafe(32))" # Update .env with secure values SNACKBASE_SECRET_KEY=your_generated_secret_key_here ``` ### Database Setup For production, switch from SQLite to PostgreSQL: ```bash theme={null} # Install PostgreSQL # macOS brew install postgresql # Ubuntu/Debian sudo apt install postgresql postgresql-contrib # Update .env SNACKBASE_DATABASE_URL=postgresql+asyncpg://user:password@localhost/snackbase ``` ### Environment Variables Key production environment variables: ```bash theme={null} # Required SNACKBASE_SECRET_KEY= SNACKBASE_DATABASE_URL=postgresql+asyncpg://... # Recommended SNACKBASE_ENVIRONMENT=production SNACKBASE_DEBUG=false SNACKBASE_CORS_ORIGINS=https://yourdomain.com # Email Configuration EMAIL_PROVIDER=smtp # or resend, aws_ses SMTP_HOST=smtp.yourprovider.com SMTP_PORT=587 SMTP_USER=your_email SMTP_PASSWORD=your_password SMTP_FROM=noreply@yourdomain.com ``` ## Next Steps Congratulations! You've completed the SnackBase Quick Start. Here's what to explore next: * **[Deployment Guide](/deployment)** - Deploy SnackBase in development and production * **[Authentication Model](/concepts/authentication)** - Deep dive into auth, multi-account users, and tokens * **[Multi-Tenancy Model](/concepts/multi-tenancy)** - How accounts and data isolation work * **[Hooks System](/hooks)** - Automate workflows with event-driven hooks * **[API Examples](/api-examples)** - Practical API usage examples # API Key Authentication Source: https://docs.snackbase.dev/sdk/js/auth/api-keys Use API keys for server-to-server authentication API keys provide a secure way to authenticate server-to-server requests without requiring a user session. This guide explains how to use API keys with the SnackBase JavaScript SDK. ## What are API Keys? API keys are long-lived credentials that allow your server to authenticate with SnackBase without requiring a user login flow. They're ideal for: * Backend services and cron jobs * Webhook endpoints * Server-side data processing * Automated scripts API keys should only be used on the server side. Never expose API keys in client-side code or public repositories. ## Setting Up API Keys ### Generate an API Key 1. Log in to your SnackBase admin panel 2. Navigate to **API Keys** 3. Click **+ New API Key** 4. Enter a description (e.g., "Production API") 5. Copy the generated key API keys are only shown once at creation. Store them securely in your environment variables. ## Using API Keys ### Basic Usage ```ts theme={null} import { SnackBaseClient } from "@snackbase/sdk"; const client = new SnackBaseClient({ baseUrl: "https://api.example.com", apiKey: process.env.SNACKBASE_API_KEY, }); // All requests will now use the API key const posts = await client.records.list("posts"); ``` ### Environment Variables Store your API key in environment variables: ```bash theme={null} # .env SNACKBASE_API_KEY=sbak_live_xxxxxxxxxxxxx ``` ```ts theme={null} const client = new SnackBaseClient({ baseUrl: process.env.SNACKBASE_URL!, apiKey: process.env.SNACKBASE_API_KEY!, }); ``` ### Different Keys for Different Environments Use different API keys for different environments: ```bash theme={null} # .env.development SNACKBASE_API_KEY=sbak_test_xxxxxxxxxxxxx SNACKBASE_URL=http://localhost:8000 # .env.production SNACKBASE_API_KEY=sbak_live_xxxxxxxxxxxxx SNACKBASE_URL=https://api.example.com ``` ## API Key vs User Authentication | Feature | API Key | User Authentication | | ------------ | ---------------------- | ------------------------ | | Use Case | Server-to-server | End-user requests | | Token Type | Long-lived | Short-lived (15 minutes) | | Permissions | Full account access | Role-based | | Rate Limits | Higher limits | Standard limits | | Location | Server only | Client + Server | | User Context | None (service account) | Specific user | ## Server-Side Operations ### Express.js Example ```ts theme={null} import express from "express"; import { SnackBaseClient } from "@snackbase/sdk"; const app = express(); const client = new SnackBaseClient({ baseUrl: process.env.SNACKBASE_URL!, apiKey: process.env.SNACKBASE_API_KEY!, }); app.get("/posts", async (req, res) => { try { const posts = await client.records.list("posts", { filter: { status: "published" }, limit: 10, }); res.json(posts); } catch (error) { res.status(500).json({ error: "Failed to fetch posts" }); } }); app.post("/posts", async (req, res) => { try { const post = await client.records.create("posts", req.body); res.json(post); } catch (error) { res.status(500).json({ error: "Failed to create post" }); } }); app.listen(3000); ``` ### Next.js Example ```tsx theme={null} // app/posts/page.tsx import { SnackBaseClient } from "@snackbase/sdk"; const client = new SnackBaseClient({ baseUrl: process.env.SNACKBASE_URL!, apiKey: process.env.SNACKBASE_API_KEY!, }); export default async function PostsPage() { const posts = await client.records.list("posts", { filter: { status: "published" }, }); return (

Posts

    {posts.items.map((post) => (
  • {post.title}
  • ))}
); } ``` ### Background Jobs ```ts theme={null} import { SnackBaseClient } from "@snackbase/sdk"; const client = new SnackBaseClient({ baseUrl: process.env.SNACKBASE_URL!, apiKey: process.env.SNACKBASE_API_KEY!, }); // Run daily cleanup job async function cleanupOldRecords() { const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); const oldRecords = await client.records.list("logs", { filter: { createdAt: `< ${thirtyDaysAgo.toISOString()}` }, }); for (const record of oldRecords.items) { await client.records.delete("logs", record.id); } console.log(`Deleted ${oldRecords.total} old records`); } // Run with a scheduler like node-cron ``` ## Webhook Handling ```ts theme={null} import { SnackBaseClient } from "@snackbase/sdk"; const client = new SnackBaseClient({ baseUrl: process.env.SNACKBASE_URL!, apiKey: process.env.SNACKBASE_API_KEY!, }); app.post("/webhooks/payment", async (req, res) => { const { event, data } = req.body; switch (event) { case "payment.succeeded": // Update order in SnackBase await client.records.update("orders", data.orderId, { status: "paid", paidAt: new Date().toISOString(), }); break; case "payment.failed": await client.records.update("orders", data.orderId, { status: "payment_failed", }); break; } res.sendStatus(200); }); ``` ## Admin Operations API keys have full access to account operations: ```ts theme={null} // Create a new user const user = await client.users.create({ email: "newuser@example.com", password: "SecurePassword123!", accountRole: "member", }); // Assign a role await client.roles.addUserToRole("roleId", "userId"); // Update collection rules await client.collectionRules.update("collectionId", { rules: [ { name: "read-only", permissions: ["read"], expression: "user.role == 'viewer'", }, ], }); ``` ## Error Handling ```ts theme={null} import { AuthenticationError, AuthorizationError, NetworkError, } from "@snackbase/sdk"; try { const posts = await client.records.list("posts"); } catch (error) { if (error instanceof AuthenticationError) { console.error("Invalid API key"); } else if (error instanceof AuthorizationError) { console.error("API key lacks required permissions"); } else if (error instanceof NetworkError) { console.error("Network error"); } else { console.error("Unknown error:", error); } } ``` ## Security Best Practices ### 1. Environment Variables Never hardcode API keys: ```ts theme={null} // Bad const client = new SnackBaseClient({ baseUrl: "https://api.example.com", apiKey: "sbak_live_xxxxxxxxxxxxx", }); // Good const client = new SnackBaseClient({ baseUrl: process.env.SNACKBASE_URL!, apiKey: process.env.SNACKBASE_API_KEY!, }); ``` ### 2. Use Different Keys for Different Services Create separate API keys for different services: ```bash theme={null} # .env SNACKBASE_API_KEY_WEB=sbak_live_web_xxxxxxxxxxxxx SNACKBASE_API_KEY_CRON=sbak_live_cron_xxxxxxxxxxxxx SNACKBASE_API_KEY_WEBHOOKS=sbak_live_hooks_xxxxxxxxxxxxx ``` ### 3. Rotate API Keys Regularly Periodically rotate your API keys: 1. Create a new API key 2. Update your environment variables 3. Deploy the changes 4. Delete the old API key ### 4. Use Key Prefixes API keys include prefixes to identify their type: * `sbak_live_` - Production keys * `sbak_test_` - Test/development keys Verify the key type in production: ```ts theme={null} const apiKey = process.env.SNACKBASE_API_KEY!; if (!apiKey.startsWith("sbak_live_")) { throw new Error("Production API key required"); } ``` ### 5. Monitor Usage Monitor your API key usage in the SnackBase admin panel to detect unauthorized access. ## Rate Limits API keys have higher rate limits than user authentication: | Authentication | Rate Limit | | -------------- | -------------------- | | User Token | 100 requests/minute | | API Key | 1000 requests/minute | Actual rate limits depend on your SnackBase plan. Contact support for custom limits. ## Testing Use test API keys during development: ```ts theme={null} // Test environment const client = new SnackBaseClient({ baseUrl: "https://test-api.example.com", apiKey: process.env.SNACKBASE_TEST_API_KEY!, }); ``` ## Next Steps * **[Email/Password Auth](/sdk/js/auth/email-password)** - User authentication * **[OAuth Guide](/sdk/js/auth/oauth)** - Social login * **[Error Handling](/sdk/js/errors/overview)** - Handle errors gracefully # Email and Password Authentication Source: https://docs.snackbase.dev/sdk/js/auth/email-password Implement traditional email and password authentication Email and password authentication is the most common authentication method. This guide shows you how to implement it with the SnackBase JavaScript SDK. ## User Registration Register a new user with email and password: ```ts theme={null} const result = await client.auth.register({ email: "newuser@example.com", password: "secure-password-123", accountName: "My New Account", }); console.log("User registered:", result.user.email); console.log("Account created:", result.account.name); ``` The password must meet your SnackInstance's password requirements. The default minimum is 8 characters. ### Registration Options ```ts theme={null} interface RegisterData { email: string; password: string; accountName: string; accountSlug?: string; // Optional custom account slug } ``` ## User Login Log in an existing user with email and password: ```ts theme={null} const auth = await client.auth.loginWithPassword({ account: "my-account", // Account slug or ID email: "user@example.com", password: "user-password", }); console.log("Logged in as:", auth.user.email); console.log("Account:", auth.account.name); ``` ### Login Options ```ts theme={null} interface LoginCredentials { account: string; // Account slug or ID email: string; password: string; } ``` If you configured `defaultAccount` in the client, you can omit the account parameter from login requests. ## Login with Default Account ```ts theme={null} const client = new SnackBaseClient({ baseUrl: "https://api.example.com", defaultAccount: "my-account", }); // No need to specify account const auth = await client.auth.loginWithPassword({ email: "user@example.com", password: "user-password", }); ``` ## Get Current User After authentication, get the current user's profile: ```ts theme={null} const user = await client.auth.getCurrentUser(); console.log(user.id); console.log(user.email); console.log(user.fullName); console.log(user.isActive); console.log(user.isEmailVerified); ``` ## Password Reset Flow ### 1. Request Password Reset Send a password reset email to the user: ```ts theme={null} await client.auth.forgotPassword({ email: "user@example.com", }); console.log("Password reset email sent"); ``` ### 2. Reset Password with Token Use the token from the reset email to set a new password: ```ts theme={null} await client.auth.resetPassword({ token: "reset-token-from-email", newPassword: "new-secure-password", }); console.log("Password reset successfully"); ``` ## Email Verification ### Check Email Verification Status ```ts theme={null} const user = await client.auth.getCurrentUser(); if (user.isEmailVerified) { console.log("Email is verified"); } else { console.log("Email is not verified"); } ``` ### Send Verification Email ```ts theme={null} await client.auth.sendVerificationEmail(); console.log("Verification email sent"); ``` ### Verify Email with Token ```ts theme={null} await client.auth.verifyEmail("verification-token-from-email"); console.log("Email verified successfully"); ``` ### Resend Verification Email ```ts theme={null} await client.auth.resendVerificationEmail(); console.log("Verification email resent"); ``` ## Complete Authentication Example Here's a complete example showing the full authentication flow: ```ts theme={null} import { SnackBaseClient } from "@snackbase/sdk"; const client = new SnackBaseClient({ baseUrl: "https://api.example.com", defaultAccount: "my-account", }); // Register a new user async function register() { try { const result = await client.auth.register({ email: "newuser@example.com", password: "SecurePass123!", accountName: "My Account", }); console.log("Registration successful:", result.user.email); // Send verification email await client.auth.sendVerificationEmail(); console.log("Verification email sent"); } catch (error) { console.error("Registration failed:", error); } } // Login async function login() { try { const auth = await client.auth.loginWithPassword({ email: "user@example.com", password: "SecurePass123!", }); console.log("Login successful:", auth.user.email); // Get current user const user = await client.auth.getCurrentUser(); console.log("Current user:", user); } catch (error) { console.error("Login failed:", error); } } // Reset password async function resetPassword() { try { // Step 1: Request reset await client.auth.forgotPassword({ email: "user@example.com", }); // Step 2: User receives email with token // Step 3: Reset with token const token = "token-from-email"; await client.auth.resetPassword({ token, newPassword: "NewSecurePass456!", }); console.log("Password reset successful"); } catch (error) { console.error("Password reset failed:", error); } } // Logout async function logout() { await client.auth.logout(); console.log("Logged out"); } ``` ## React Integration Using email/password authentication with React: ```tsx theme={null} import { useAuth } from "@snackbase/sdk/react"; function LoginForm() { const { login, logout, user, isLoading } = useAuth(); const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); const email = (e.currentTarget.elements.namedItem("email") as HTMLInputElement).value; const password = (e.currentTarget.elements.namedItem("password") as HTMLInputElement).value; try { await login({ email, password }); } catch (error) { console.error("Login failed:", error); } }; if (isLoading) return

Loading...

; if (user) { return (

Welcome, {user.email}!

); } return (
); } ``` ## Error Handling Handle common authentication errors: ```ts theme={null} import { AuthenticationError, ValidationError, NetworkError, } from "@snackbase/sdk"; try { await client.auth.loginWithPassword({ email: "user@example.com", password: "wrong-password", }); } catch (error) { if (error instanceof AuthenticationError) { console.error("Invalid credentials"); } else if (error instanceof ValidationError) { console.error("Validation error:", error.fields); } else if (error instanceof NetworkError) { console.error("Network error - please check your connection"); } else { console.error("Unknown error:", error); } } ``` ## Security Best Practices ### 1. Password Requirements Ensure your SnackBase instance has secure password requirements: * Minimum 8 characters * Mix of letters, numbers, and symbols * No common passwords ### 2. HTTPS Only Always use HTTPS in production: ```ts theme={null} // Good const client = new SnackBaseClient({ baseUrl: "https://api.example.com", }); // Bad - never use HTTP in production const client = new SnackBaseClient({ baseUrl: "http://api.example.com", }); ``` ### 3. Token Storage Use appropriate storage for your use case: ```ts theme={null} // For web apps - persists across sessions const client = new SnackBaseClient({ storageBackend: "localStorage", }); // For shared/public devices - cleared on tab close const client = new SnackBaseClient({ storageBackend: "sessionStorage", }); ``` ### 4. Auto-Logout on Token Expiry Configure the SDK to handle token expiry: ```ts theme={null} const client = new SnackBaseClient({ baseUrl: "https://api.example.com", onAuthError: (error) => { // Redirect to login on auth error window.location.href = "/login"; }, }); ``` ## Next Steps * **[OAuth Guide](/sdk/js/auth/oauth)** - Add social login options * **[API Keys](/sdk/js/auth/api-keys)** - Use API keys for server operations * **[Error Handling](/sdk/js/errors/overview)** - Handle authentication errors # OAuth Authentication Source: https://docs.snackbase.dev/sdk/js/auth/oauth Implement OAuth social login with Google, GitHub, and more OAuth authentication allows users to sign in with their existing social accounts. This guide shows you how to implement OAuth with the SnackBase JavaScript SDK. ## Supported OAuth Providers SnackBase supports the following OAuth providers: * Google * GitHub * Microsoft * Apple ## OAuth Flow ### 1. Generate Authorization URL Create the OAuth authorization URL for the provider: ```ts theme={null} const url = client.auth.getOAuthUrl("google", "my-account"); console.log("Authorize URL:", url); ``` ### 2. Redirect User to OAuth Provider ```ts theme={null} // Redirect the user to the OAuth provider window.location.href = url; ``` ### 3. Handle OAuth Callback After the user approves the authorization, they'll be redirected back to your application with a code in the URL. Exchange this code for an access token: ```ts theme={null} // Get the code from the URL query parameters const params = new URLSearchParams(window.location.search); const code = params.get("code"); // Exchange code for access token const auth = await client.auth.handleOAuthCallback({ provider: "google", code: code!, }); console.log("Logged in as:", auth.user.email); ``` ## Complete OAuth Implementation ### Frontend (Browser) ```tsx theme={null} import { SnackBaseClient } from "@snackbase/sdk"; const client = new SnackBaseClient({ baseUrl: "https://api.example.com", defaultAccount: "my-account", }); // Start OAuth flow function loginWithGoogle() { const url = client.auth.getOAuthUrl("google", "my-account"); window.location.href = url; } // Handle callback on redirect page useEffect(() => { const params = new URLSearchParams(window.location.search); const code = params.get("code"); if (code) { client.auth.handleOAuthCallback({ provider: "google", code, }).then((auth) => { console.log("Logged in:", auth.user.email); // Redirect to app window.location.href = "/dashboard"; }); } }, []); ``` ### With React Router ```tsx theme={null} import { useSearchParams } from "react-router-dom"; import { useAuth } from "@snackbase/sdk/react"; function OAuthCallback() { const [searchParams] = useSearchParams(); const { login } = useAuth(); const [error, setError] = useState(null); useEffect(() => { const code = searchParams.get("code"); const provider = searchParams.get("provider") || "google"; if (code) { client.auth.handleOAuthCallback({ provider, code }) .then((auth) => { // User is now logged in window.location.href = "/dashboard"; }) .catch((err) => { setError("Authentication failed"); console.error(err); }); } }, [searchParams]); if (error) { return
Error: {error}
; } return
Completing authentication...
; } ``` ## OAuth Provider Configuration ### Google OAuth ```ts theme={null} const url = client.auth.getOAuthUrl("google", "my-account", { redirectUri: "https://myapp.com/oauth/callback", }); ``` ### GitHub OAuth ```ts theme={null} const url = client.auth.getOAuthUrl("github", "my-account", { redirectUri: "https://myapp.com/oauth/callback", }); ``` ### Microsoft OAuth ```ts theme={null} const url = client.auth.getOAuthUrl("microsoft", "my-account", { redirectUri: "https://myapp.com/oauth/callback", }); ``` ### Apple OAuth ```ts theme={null} const url = client.auth.getOAuthUrl("apple", "my-account", { redirectUri: "https://myapp.com/oauth/callback", }); ``` Each provider must be configured in your SnackBase admin panel with the appropriate client ID and secret. ## Custom Redirect URI Specify a custom redirect URI: ```ts theme={null} const url = client.auth.getOAuthUrl("google", "my-account", { redirectUri: "https://myapp.com/custom-callback", }); ``` ## State Parameter Include a state parameter for security: ```ts theme={null} const state = "random-state-string"; const url = client.auth.getOAuthUrl("google", "my-account", { state, redirectUri: "https://myapp.com/oauth/callback", }); // Verify state on callback useEffect(() => { const params = new URLSearchParams(window.location.search); const code = params.get("code"); const returnedState = params.get("state"); if (code && returnedState === state) { // Process callback client.auth.handleOAuthCallback({ provider: "google", code }); } }, []); ``` ## Complete Example with Multiple Providers ```tsx theme={null} import { SnackBaseClient } from "@snackbase/sdk"; const client = new SnackBaseClient({ baseUrl: "https://api.example.com", defaultAccount: "my-account", }); function LoginButtons() { const providers = [ { name: "Google", id: "google" as const, icon: "🔵" }, { name: "GitHub", id: "github" as const, icon: "🐙" }, { name: "Microsoft", id: "microsoft" as const, icon: "🟦" }, { name: "Apple", id: "apple" as const, icon: "🍎" }, ]; const handleLogin = (provider: typeof providers[number]["id"]) => { const url = client.auth.getOAuthUrl(provider, "my-account", { redirectUri: `${window.location.origin}/oauth/callback`, }); window.location.href = url; }; return (

Sign in with

{providers.map((provider) => ( ))}
); } function OAuthCallbackPage() { const [searchParams] = useSearchParams(); useEffect(() => { const code = searchParams.get("code"); const provider = searchParams.get("provider") as any; if (code && provider) { client.auth .handleOAuthCallback({ provider, code }) .then((auth) => { console.log("Logged in:", auth.user.email); window.location.href = "/dashboard"; }) .catch(console.error); } }, [searchParams]); return
Completing sign in...
; } ``` ## Server-Side OAuth For Next.js and other server-side frameworks: ```tsx theme={null} // app/login/route.ts import { SnackBaseClient } from "@snackbase/sdk"; const client = new SnackBaseClient({ baseUrl: process.env.SNACKBASE_URL!, }); export async function GET(request: Request) { const url = client.auth.getOAuthUrl("google", "my-account", { redirectUri: `${process.env.APP_URL}/api/oauth/callback`, }); return Response.redirect(url); } // app/api/oauth/callback/route.ts export async function GET(request: Request) { const { searchParams } = new URL(request.url); const code = searchParams.get("code"); if (code) { const auth = await client.auth.handleOAuthCallback({ provider: "google", code, }); // Set session cookie or token // Then redirect } return Response.redirect(new URL("/dashboard", request.url)); } ``` ## Linking OAuth to Existing Accounts Link an OAuth provider to an existing email/password account: ```ts theme={null} // This would typically be done through the backend API // The SDK doesn't currently expose this directly ``` ## Error Handling Handle common OAuth errors: ```ts theme={null} try { const auth = await client.auth.handleOAuthCallback({ provider: "google", code: "invalid-code", }); } catch (error) { if (error instanceof AuthenticationError) { console.error("OAuth failed:", error.message); // Show error to user } else if (error instanceof NetworkError) { console.error("Network error during OAuth"); } else { console.error("Unknown error:", error); } } ``` ## OAuth vs Email/Password Consider these factors when choosing between OAuth and email/password: | Factor | OAuth | Email/Password | | -------------- | ------------------------ | --------------- | | Setup | Requires provider config | Simple | | User Friction | Low (1-2 clicks) | Higher (typing) | | Password Mgmt | None required | Required | | Data Control | Limited by provider | Full control | | Offline Access | No | Yes | ## Next Steps * **[Email/Password Auth](/sdk/js/auth/email-password)** - Traditional authentication * **[SAML Setup](/guides/saml-setup-generic)** - Enterprise SSO configuration * **[API Keys](/sdk/js/auth/api-keys)** - Server authentication # Authentication Overview Source: https://docs.snackbase.dev/sdk/js/auth/overview Understand authentication in the SnackBase JavaScript SDK The SnackBase JavaScript SDK provides multiple authentication methods to secure your applications. This guide explains the authentication model and available options. SDK v0.3.0 introduced token type detection, authentication method detection, and a new API key format. See [What's New in v0.3.0](/sdk/js/auth/v0.3.0-changes) for details. ## Authentication Methods SnackBase supports several authentication methods: | Method | Use Case | Location | | -------------- | ----------------------------------- | --------------- | | Email/Password | Traditional web applications | Client + Server | | OAuth | Social login (Google, GitHub, etc.) | Client | | SAML | Enterprise SSO | Client | | API Keys | Server-to-server communication | Server Only | ## Authentication Flow ### 1. User Authentication The user provides credentials (email/password, OAuth, or SAML): ```ts theme={null} // Email and password const auth = await client.auth.loginWithPassword({ account: "my-account", email: "user@example.com", password: "password", }); // OAuth redirect const url = client.auth.getOAuthUrl("google", "my-account"); window.location.href = url; // SAML redirect const url = await client.auth.getSAMLUrl("okta", "my-account"); window.location.href = url; ``` ### 2. Token Storage After successful authentication, the SDK receives: * **Access Token**: Short-lived token for API requests (default: 15 minutes) * **Refresh Token**: Long-lived token for obtaining new access tokens Tokens are automatically stored based on your `storageBackend` configuration: ```ts theme={null} const client = new SnackBaseClient({ baseUrl: "https://api.example.com", storageBackend: "localStorage", // Tokens persist across sessions }); ``` ### 3. Automatic Token Injection The SDK automatically includes the access token in all API requests via the `Authorization` header: ```ts theme={null} // This request automatically includes the auth header const posts = await client.records.list("posts"); ``` ### 4. Automatic Token Refresh When `enableAutoRefresh` is true (default), the SDK automatically refreshes the access token before it expires: ```ts theme={null} const client = new SnackBaseClient({ baseUrl: "https://api.example.com", enableAutoRefresh: true, refreshBeforeExpiry: 300, // Refresh 5 minutes before expiry }); ``` ## Auth State Management The SDK manages authentication state internally: ```ts theme={null} // Check if authenticated if (client.isAuthenticated) { console.log("Logged in as:", client.user?.email); console.log("Account:", client.account?.name); } // Get the current token const token = client.auth.token; ``` ## Auth Events Subscribe to authentication state changes: ```ts theme={null} // Listen for login events client.on("auth:login", (state) => { console.log("User logged in:", state.user); }); // Listen for logout events client.on("auth:logout", () => { console.log("User logged out"); // Redirect to login page }); // Listen for token refresh client.on("auth:refresh", (state) => { console.log("Token refreshed"); }); // Listen for auth errors client.on("auth:error", (error) => { console.error("Auth error:", error); }); ``` ## Multi-Account Users SnackBase supports users that belong to multiple accounts. After authentication, you can switch accounts: ```ts theme={null} // Get all accounts for the current user const accounts = await client.accounts.list(); // Switch to a different account await client.auth.switchAccount(accountId); ``` ## Session Expiry Access tokens have a limited lifetime. The SDK handles this automatically: 1. **Before expiry**: Token is refreshed automatically (if `enableAutoRefresh` is true) 2. **After expiry**: You'll receive a 401 error, and the SDK will attempt to refresh 3. **Refresh failure**: You'll need to re-authenticate Check token expiry: ```ts theme={null} // The SDK manages this internally // If you need to check manually: const authState = client.internalAuthManager.getState(); if (authState.expiresAt) { const expiryDate = new Date(authState.expiresAt); const now = new Date(); if (expiryDate <= now) { console.log("Token has expired"); } } ``` ## Logout Properly log out the current user: ```ts theme={null} // Logout clears stored tokens and state await client.auth.logout(); // The user is now logged out console.log(client.isAuthenticated); // false ``` Logging out also disconnects any real-time subscriptions and clears the authentication state from storage. ## Choosing an Authentication Method ### Email/Password Use for traditional applications where users create accounts with email and password. **Pros:** * Full control over user data * No external dependencies * Works offline **Cons:** * Requires password management * Users may forget passwords **When to use:** * B2B applications * Internal tools * Apps requiring full user control ### OAuth Use for consumer applications where social login simplifies onboarding. **Pros:** * No password management * Faster signup * Trusted providers **Cons:** * Dependent on external providers * Less control over user data **When to use:** * B2C applications * Social apps * Apps wanting low friction signup ### SAML Use for enterprise customers requiring SSO. **Pros:** * Enterprise standard * Centralized user management * High security **Cons:** * Complex setup * Requires enterprise identity provider **When to use:** * B2B enterprise apps * Apps with security requirements * Integrating with existing enterprise systems ### API Keys Use for server-to-server communication. **Pros:** * No user session required * Long-lived credentials * Easy to revoke **Cons:** * Must be kept secret * Cannot be used on the client side **When to use:** * Backend services * Cron jobs * Webhooks ## Next Steps * **[Email/Password Auth](/sdk/js/auth/email-password)** - Implement traditional authentication * **[OAuth Guide](/sdk/js/auth/oauth)** - Set up social login * **[API Keys](/sdk/js/auth/api-keys)** - Use API keys for server operations # New in v0.3.0 Source: https://docs.snackbase.dev/sdk/js/auth/v0.3.0-changes Authentication enhancements in SDK v0.3.0 SDK v0.3.0 introduces several authentication enhancements including token type detection, authentication method detection, and a new API key format. ## What's New ### Token Type Detection A new `TokenType` enum helps you identify the type of authentication token being used: ```typescript theme={null} import { TokenType } from '@snackbase/sdk'; enum TokenType { ACCESS = 'access', REFRESH = 'refresh', API_KEY = 'api_key', } ``` ### User Token Type Field The `User` interface now includes a `token_type` field that indicates the current authentication method: ```typescript theme={null} interface User { id: string; email: string; // ... other fields token_type?: TokenType; // NEW: Type of token being used } ``` ### Authentication Method Detection New convenience methods on the `AuthService` make it easy to detect the current authentication method: ```typescript theme={null} const auth = client.auth; // Check if current session is a superadmin if (auth.isSuperadmin()) { console.log('Logged in as superadmin'); } // Check if authenticated via API key if (auth.isApiKeySession()) { console.log('Using API key authentication'); } // Check if authenticated via access token if (auth.isAccessTokenSession()) { console.log('Using access token authentication'); } ``` ### New Error Types Two new error types provide better error handling: ```typescript theme={null} import { ApiKeyRestrictedError, EmailVerificationRequiredError } from '@snackbase/sdk'; try { await client.records.create('posts', data); } catch (error) { if (error instanceof ApiKeyRestrictedError) { // API key doesn't have permission for this operation console.error('API key is restricted from this action'); } else if (error instanceof EmailVerificationRequiredError) { // User must verify email before proceeding console.error('Please verify your email first'); } } ``` ### API Key Format Change API keys now use a three-part format for better security: **Old Format (still supported)**: ``` sb_ak_base64payload ``` **New Format (v0.3.0+)**: ``` sb_ak.payload.signature ``` The new format provides: * **Clearer structure** - Three distinct parts separated by dots * **Embedded signature** - Cryptographic signature for verification * **Better security** - Easier to validate and verify keys Old API keys continue to work. The new format is used when creating new API keys in v0.3.0+. ## Migration Guide If you're upgrading from an earlier version, here's what you need to know: ### No Breaking Changes All existing code continues to work. The new features are additive and don't change existing behavior. ### Optional: Use New Detection Methods You can optionally use the new authentication method detection in your code: ```typescript theme={null} // Before (v0.2.x) if (client.user && client.apiKey) { // Has both user and API key } // After (v0.3.0) - More explicit if (client.auth.isApiKeySession()) { // Definitely using API key auth } else if (client.isAuthenticated) { // Using user authentication } ``` ### Handle New Error Types Add handlers for the new error types where appropriate: ```typescript theme={null} try { await client.records.create('posts', data); } catch (error) { if (error instanceof ApiKeyRestrictedError) { // Handle restricted API key alert('Your API key does not have permission to create posts'); } else if (error instanceof EmailVerificationRequiredError) { // Handle unverified email await client.auth.sendVerificationEmail(); alert('Please check your email for a verification link'); } else { // Handle other errors as before throw error; } } ``` ### Check Token Type When Needed If you need to know the token type: ```typescript theme={null} const user = client.user; if (user) { switch (user.token_type) { case TokenType.ACCESS: console.log('Authenticated with access token'); break; case TokenType.REFRESH: console.log('Authenticated with refresh token'); break; case TokenType.API_KEY: console.log('Authenticated with API key'); break; } } ``` ## API Key Creation When creating new API keys with the SDK v0.3.0+, they will use the new format: ```typescript theme={null} const apiKey = await client.apiKeys.create({ name: 'Production API Key', }); console.log(apiKey.key); // sb_ak.payload.signature (new format) ``` ## Examples ### Conditional Logic Based on Auth Type ```typescript theme={null} // Show different UI based on authentication method if (client.auth.isSuperadmin()) { // Show superadmin controls renderSuperAdminPanel(); } else if (client.auth.isApiKeySession()) { // API keys have limited permissions renderApiKeyWarning(); renderLimitedControls(); } else { // Regular user session renderUserPanel(); } ``` ### Email Verification Flow ```typescript theme={null} import { EmailVerificationRequiredError } from '@snackbase/sdk'; async function createPost(data) { try { return await client.records.create('posts', data); } catch (error) { if (error instanceof EmailVerificationRequiredError) { // Send verification email await client.auth.sendVerificationEmail(); // Show message to user showVerificationModal(); return null; } throw error; } } ``` ### API Key Permission Check ```typescript theme={null} import { ApiKeyRestrictedError } from '@snackbase/sdk'; async function adminOperation() { try { return await client.accounts.delete(accountId); } catch (error) { if (error instanceof ApiKeyRestrictedError) { console.error('This operation requires a user session or admin API key'); return null; } throw error; } } ``` ## Summary of Changes | Feature | Type | Description | | -------------------------------- | ------- | ----------------------------------------------- | | `TokenType` enum | New | Enum for token types: ACCESS, REFRESH, API\_KEY | | `User.token_type` field | New | Field indicating current authentication method | | `isSuperadmin()` method | New | Check if superadmin session | | `isApiKeySession()` method | New | Check if API key authentication | | `isAccessTokenSession()` method | New | Check if access token authentication | | `ApiKeyRestrictedError` | New | Error for restricted API key operations | | `EmailVerificationRequiredError` | New | Error for unverified email requirement | | API key format | Changed | New 3-part format: `sb_ak.payload.signature` | ## Next Steps * **[Error Handling](/sdk/js/errors/overview)** - Learn about all error types * **[Authentication Overview](/sdk/js/auth/overview)** - Complete authentication guide # Configuration Source: https://docs.snackbase.dev/sdk/js/configuration Configure the SnackBase JavaScript SDK for your needs The SnackBase JavaScript SDK provides many configuration options to customize its behavior for your application's needs. ## Configuration Options ### Required Options #### `baseUrl` The base URL of your SnackBase instance. ```ts theme={null} const client = new SnackBaseClient({ baseUrl: "https://your-project.snackbase.dev", }); ``` ### Authentication Options #### `apiKey` Optional API key for server-to-server authentication. ```ts theme={null} const client = new SnackBaseClient({ baseUrl: "https://your-project.snackbase.dev", apiKey: process.env.SNACKBASE_API_KEY, }); ``` API keys should only be used on the server side. Never expose API keys in client-side code. #### `defaultAccount` Default account slug for single-tenant applications. ```ts theme={null} const client = new SnackBaseClient({ baseUrl: "https://your-project.snackbase.dev", defaultAccount: "my-account", }); ``` When set, users don't need to specify the account when logging in. ### Request Options #### `timeout` Request timeout in milliseconds. Default: `30000` (30 seconds). ```ts theme={null} const client = new SnackBaseClient({ baseUrl: "https://your-project.snackbase.dev", timeout: 60000, // 60 seconds }); ``` #### `maxRetries` Maximum number of retry attempts for failed requests. Default: `3`. ```ts theme={null} const client = new SnackBaseClient({ baseUrl: "https://your-project.snackbase.dev", maxRetries: 5, }); ``` #### `retryDelay` Delay between retry attempts in milliseconds. Default: `1000`. ```ts theme={null} const client = new SnackBaseClient({ baseUrl: "https://your-project.snackbase.dev", retryDelay: 2000, // 2 seconds }); ``` ### Storage Options #### `storageBackend` Storage backend for authentication tokens. Default: auto-detected based on platform. ```ts theme={null} import { SnackBaseClient } from "@snackbase/sdk"; const client = new SnackBaseClient({ baseUrl: "https://your-project.snackbase.dev", storageBackend: "localStorage", // or "sessionStorage", "memory" }); ``` Available options: | Backend | Platform | Description | | ---------------- | ------------ | ------------------------------ | | `localStorage` | Web | Persists across sessions | | `sessionStorage` | Web | Cleared when tab closes | | `memory` | Any | In-memory only | | `asyncStorage` | React Native | Uses React Native AsyncStorage | ### Token Refresh Options #### `enableAutoRefresh` Enable automatic token refresh before expiry. Default: `true`. ```ts theme={null} const client = new SnackBaseClient({ baseUrl: "https://your-project.snackbase.dev", enableAutoRefresh: true, }); ``` #### `refreshBeforeExpiry` Seconds before token expiry to refresh. Default: `300` (5 minutes). ```ts theme={null} const client = new SnackBaseClient({ baseUrl: "https://your-project.snackbase.dev", refreshBeforeExpiry: 600, // 10 minutes }); ``` ### Real-Time Options #### `maxRealTimeRetries` Maximum reconnection attempts for real-time connections. Default: `10`. ```ts theme={null} const client = new SnackBaseClient({ baseUrl: "https://your-project.snackbase.dev", maxRealTimeRetries: 20, }); ``` #### `realTimeReconnectionDelay` Initial delay for real-time reconnection in milliseconds. Default: `1000`. ```ts theme={null} const client = new SnackBaseClient({ baseUrl: "https://your-project.snackbase.dev", realTimeReconnectionDelay: 2000, }); ``` ### Logging Options #### `enableLogging` Enable request/response logging. Default: `false` in production. ```ts theme={null} const client = new SnackBaseClient({ baseUrl: "https://your-project.snackbase.dev", enableLogging: true, }); ``` #### `logLevel` Logging level. Default: `'error'`. ```ts theme={null} const client = new SnackBaseClient({ baseUrl: "https://your-project.snackbase.dev", enableLogging: true, logLevel: "debug", // "debug" | "info" | "warn" | "error" }); ``` ### Error Callbacks #### `onAuthError` Callback for 401 authentication errors. ```ts theme={null} const client = new SnackBaseClient({ baseUrl: "https://your-project.snackbase.dev", onAuthError: (error) => { console.error("Auth error:", error); // Redirect to login page window.location.href = "/login"; }, }); ``` #### `onNetworkError` Callback for network failures. ```ts theme={null} const client = new SnackBaseClient({ baseUrl: "https://your-project.snackbase.dev", onNetworkError: (error) => { console.error("Network error:", error); // Show offline message }, }); ``` #### `onRateLimitError` Callback for 429 rate limit errors. ```ts theme={null} const client = new SnackBaseClient({ baseUrl: "https://your-project.snackbase.dev", onRateLimitError: (error) => { console.warn("Rate limited:", error.retryAfter); // Show rate limit message }, }); ``` ## Complete Configuration Example ```ts theme={null} import { SnackBaseClient } from "@snackbase/sdk"; const client = new SnackBaseClient({ // Required baseUrl: "https://your-project.snackbase.dev", // Authentication apiKey: process.env.SNACKBASE_API_KEY, defaultAccount: "my-account", // Request settings timeout: 60000, maxRetries: 5, retryDelay: 2000, // Storage storageBackend: "localStorage", // Token refresh enableAutoRefresh: true, refreshBeforeExpiry: 600, // Real-time maxRealTimeRetries: 20, realTimeReconnectionDelay: 2000, // Logging enableLogging: process.env.NODE_ENV === "development", logLevel: "debug", // Error callbacks onAuthError: (error) => { console.error("Auth error:", error); }, onNetworkError: (error) => { console.error("Network error:", error); }, onRateLimitError: (error) => { console.warn("Rate limited:", error.retryAfter); }, }); ``` ## Accessing Configuration You can access the current configuration at runtime: ```ts theme={null} const config = client.getConfig(); console.log("Base URL:", config.baseUrl); console.log("Timeout:", config.timeout); console.log("Max retries:", config.maxRetries); ``` ## Environment-Specific Configuration ### Development ```ts theme={null} const client = new SnackBaseClient({ baseUrl: "http://localhost:8000", enableLogging: true, logLevel: "debug", }); ``` ### Production ```ts theme={null} const client = new SnackBaseClient({ baseUrl: "https://api.production.com", enableLogging: false, timeout: 30000, maxRetries: 3, }); ``` ### With React ```tsx theme={null} import { SnackBaseProvider } from "@snackbase/sdk/react"; function App() { return ( ); } ``` ## Default Configuration These are the default values used by the SDK: ```ts theme={null} { timeout: 30000, enableAutoRefresh: true, refreshBeforeExpiry: 300, maxRetries: 3, retryDelay: 1000, storageBackend: auto-detected, logLevel: "error", enableLogging: false, maxRealTimeRetries: 10, realTimeReconnectionDelay: 1000, } ``` # Error Handling Overview Source: https://docs.snackbase.dev/sdk/js/errors/overview Handle errors in the SnackBase JavaScript SDK The SnackBase SDK provides a comprehensive error handling system with typed error classes for different failure scenarios. ## Overview All SDK errors extend from the base `SnackBaseError` class, allowing you to catch errors generically or handle specific error types: ```ts theme={null} import { SnackBaseError, AuthenticationError, ValidationError, NetworkError, } from "@snackbase/sdk"; try { const post = await client.records.create("posts", data); } catch (error) { if (error instanceof ValidationError) { console.error("Validation failed:", error.fields); } else if (error instanceof AuthenticationError) { console.error("Authentication required"); } else if (error instanceof SnackBaseError) { console.error("SDK error:", error.message); } else { console.error("Unknown error:", error); } } ``` ## Error Hierarchy ``` SnackBaseError (base class) ├── AuthenticationError (401) ├── AuthorizationError (403) ├── NotFoundError (404) ├── ConflictError (409) ├── ValidationError (422) ├── RateLimitError (429) ├── NetworkError ├── TimeoutError └── ServerError (500+) ``` ## Error Properties All errors include the following properties: ```ts theme={null} interface SnackBaseError { message: string; // Human-readable error message code: string; // Error code (e.g., "AUTHENTICATION_ERROR") status?: number; // HTTP status code details?: any; // Additional error details field?: string; // Field name (for validation errors) retryable: boolean; // Whether the request can be retried } ``` ## Error Types ### AuthenticationError (401) Thrown when authentication fails or tokens are invalid: ```ts theme={null} try { await client.auth.loginWithPassword({ email: "user@example.com", password: "wrong-password", }); } catch (error) { if (error instanceof AuthenticationError) { console.error("Invalid credentials"); // Redirect to login } } ``` ### AuthorizationError (403) Thrown when the user lacks permission for an action: ```ts theme={null} try { await client.records.delete("posts", "post-id"); } catch (error) { if (error instanceof AuthorizationError) { console.error("You don't have permission to delete this post"); // Show permission error to user } } ``` ### NotFoundError (404) Thrown when a resource is not found: ```ts theme={null} try { const post = await client.records.get("posts", "non-existent-id"); } catch (error) { if (error instanceof NotFoundError) { console.error("Post not found"); // Show 404 page } } ``` ### ConflictError (409) Thrown when a resource conflict occurs (e.g., duplicate): ```ts theme={null} try { await client.users.create({ email: "existing@example.com", password: "password", }); } catch (error) { if (error instanceof ConflictError) { console.error("User with this email already exists"); // Show error to user } } ``` ### ValidationError (422) Thrown when request validation fails: ```ts theme={null} try { await client.records.create("posts", { title: "", // Required field is empty }); } catch (error) { if (error instanceof ValidationError) { console.error("Validation failed:", error.fields); // { title: ["This field is required"] } // Show field-specific errors } } ``` ### RateLimitError (429) Thrown when rate limit is exceeded: ```ts theme={null} try { await client.records.list("posts"); } catch (error) { if (error instanceof RateLimitError) { console.error("Rate limited. Retry after:", error.retryAfter, "seconds"); // Show rate limit message } } ``` ### NetworkError Thrown when network request fails: ```ts theme={null} try { await client.records.list("posts"); } catch (error) { if (error instanceof NetworkError) { console.error("Network error - please check your connection"); // Show offline message } } ``` ### TimeoutError Thrown when request times out: ```ts theme={null} try { await client.records.list("posts"); } catch (error) { if (error instanceof TimeoutError) { console.error("Request timed out"); // Show timeout message } } ``` ### ServerError (500+) Thrown when server error occurs: ```ts theme={null} try { await client.records.list("posts"); } catch (error) { if (error instanceof ServerError) { console.error("Server error:", error.status); // Show server error message } } ``` ## Global Error Handlers Configure global error callbacks: ```ts theme={null} const client = new SnackBaseClient({ baseUrl: "https://api.example.com", onAuthError: (error) => { console.error("Auth error:", error); // Redirect to login }, onNetworkError: (error) => { console.error("Network error:", error); // Show offline banner }, onRateLimitError: (error) => { console.error("Rate limited:", error.retryAfter); // Show rate limit message }, }); ``` ## React Error Handling ```tsx theme={null} function PostDetail({ postId }: { postId: string }) { const { data: post, loading, error } = useRecord("posts", postId); if (loading) return
Loading...
; if (error) { if (error instanceof NotFoundError) { return
Post not found
; } if (error instanceof ValidationError) { return
Invalid request
; } return
Error loading post
; } return ; } ``` ## Retry Logic Check if error is retryable: ```ts theme={null} async function fetchWithRetry(collection: string, maxRetries = 3) { for (let i = 0; i < maxRetries; i++) { try { return await client.records.list(collection); } catch (error) { if (error instanceof SnackBaseError && error.retryable && i < maxRetries - 1) { const delay = Math.pow(2, i) * 1000; await new Promise(resolve => setTimeout(resolve, delay)); continue; } throw error; } } } ``` ## Custom Error Handler Create a custom error handler: ```ts theme={null} async function handleApiCall( fn: () => Promise ): Promise { try { return await fn(); } catch (error) { if (error instanceof ValidationError) { console.error("Validation error:", error.fields); showFieldErrors(error.fields); } else if (error instanceof AuthenticationError) { console.error("Authentication failed"); redirectToLogin(); } else if (error instanceof AuthorizationError) { console.error("Permission denied"); showPermissionError(); } else if (error instanceof NotFoundError) { console.error("Resource not found"); showNotFoundError(); } else if (error instanceof RateLimitError) { console.error("Rate limited:", error.retryAfter); showRateLimitError(error.retryAfter); } else if (error instanceof NetworkError) { console.error("Network error"); showNetworkError(); } else { console.error("Unknown error:", error); showGenericError(); } return null; } } // Usage const post = await handleApiCall(() => client.records.get("posts", "post-id") ); ``` ## Complete Example ```ts theme={null} import { SnackBaseError, AuthenticationError, ValidationError, NetworkError, } from "@snackbase/sdk"; async function createPost(data: PostCreate) { try { const post = await client.records.create("posts", data); return { success: true, data: post }; } catch (error) { if (error instanceof ValidationError) { return { success: false, error: "Validation failed", fields: error.fields, }; } if (error instanceof AuthenticationError) { return { success: false, error: "Please log in to create posts", }; } if (error instanceof AuthorizationError) { return { success: false, error: "You don't have permission to create posts", }; } if (error instanceof NetworkError) { return { success: false, error: "Network error - please check your connection", }; } return { success: false, error: "An unexpected error occurred", }; } } ``` ## Next Steps * **[Error Types](/sdk/js/errors/types)** - Complete error type reference * **[Authentication](/sdk/js/auth/overview)** - Authentication error handling * **[Configuration](/sdk/js/configuration)** - Error callback configuration # Error Types Reference Source: https://docs.snackbase.dev/sdk/js/errors/types Complete reference for all SDK error types This is a complete reference for all error types in the SnackBase JavaScript SDK. ## Base Error: SnackBaseError All SDK errors extend from the base `SnackBaseError` class. ### Properties ```ts theme={null} class SnackBaseError extends Error { public readonly code: string; public readonly status?: number; public readonly details?: any; public readonly field?: string; public readonly retryable: boolean; constructor( message: string, code: string, status?: number, details?: any, retryable: boolean = false, field?: string ) } ``` ### Properties Reference | Property | Type | Description | | ----------- | --------------------- | ----------------------------------- | | `message` | `string` | Human-readable error message | | `name` | `string` | Error class name | | `code` | `string` | Error code (e.g., "NETWORK\_ERROR") | | `status` | `number \| undefined` | HTTP status code | | `details` | `any \| undefined` | Additional error details | | `field` | `string \| undefined` | Field name (validation) | | `retryable` | `boolean` | Whether request can be retried | ## Error Classes ### AuthenticationError **Status Code:** 401 **Code:** `AUTHENTICATION_ERROR` **Retryable:** No Thrown when authentication fails or tokens are invalid/expired. ```ts theme={null} class AuthenticationError extends SnackBaseError { constructor(message: string = "Authentication failed", details?: any) } ``` **Common Causes:** * Invalid credentials * Expired access token * Missing authorization header * Invalid API key **Example:** ```ts theme={null} try { await client.auth.loginWithPassword({ email: "user@example.com", password: "wrong-password", }); } catch (error) { if (error instanceof AuthenticationError) { console.error("Authentication failed"); } } ``` *** ### AuthorizationError **Status Code:** 403 **Code:** `AUTHORIZATION_ERROR` **Retryable:** No Thrown when the authenticated user lacks permission for an action. ```ts theme={null} class AuthorizationError extends SnackBaseError { constructor(message: string = "Not authorized", details?: any) } ``` **Common Causes:** * Insufficient permissions * Role-based access control violation * Collection rule violation **Example:** ```ts theme={null} try { await client.records.delete("posts", "post-id"); } catch (error) { if (error instanceof AuthorizationError) { console.error("Permission denied"); } } ``` *** ### NotFoundError **Status Code:** 404 **Code:** `NOT_FOUND_ERROR` **Retryable:** No Thrown when a requested resource is not found. ```ts theme={null} class NotFoundError extends SnackBaseError { constructor(message: string = "Resource not found", details?: any) } ``` **Common Causes:** * Invalid record ID * Non-existent collection * Non-existent user/account **Example:** ```ts theme={null} try { const post = await client.records.get("posts", "invalid-id"); } catch (error) { if (error instanceof NotFoundError) { console.error("Post not found"); } } ``` *** ### ConflictError **Status Code:** 409 **Code:** `CONFLICT_ERROR` **Retryable:** No Thrown when a resource conflict occurs. ```ts theme={null} class ConflictError extends SnackBaseError { constructor(message: string = "Resource conflict", details?: any) } ``` **Common Causes:** * Duplicate unique field value * Concurrent modification conflict * Resource already exists **Example:** ```ts theme={null} try { await client.users.create({ email: "existing@example.com", password: "password", }); } catch (error) { if (error instanceof ConflictError) { console.error("User already exists"); } } ``` *** ### ValidationError **Status Code:** 422 **Code:** `VALIDATION_ERROR` **Retryable:** No Thrown when request validation fails. ```ts theme={null} class ValidationError extends SnackBaseError { public readonly fields?: Record; constructor(message: string = "Validation failed", details?: any) } ``` **Common Causes:** * Missing required fields * Invalid field values * Type mismatch * Constraint violation **Example:** ```ts theme={null} try { await client.records.create("posts", { title: "", // Required field }); } catch (error) { if (error instanceof ValidationError) { console.error("Validation errors:", error.fields); // { title: ["This field is required"] } } } ``` *** ### RateLimitError **Status Code:** 429 **Code:** `RATE_LIMIT_ERROR` **Retryable:** Yes Thrown when rate limit is exceeded. ```ts theme={null} class RateLimitError extends SnackBaseError { public readonly retryAfter?: number; constructor( message: string = "Rate limit exceeded", details?: any, retryAfter?: number ) } ``` **Properties:** * `retryAfter`: Seconds to wait before retrying **Example:** ```ts theme={null} try { await client.records.list("posts"); } catch (error) { if (error instanceof RateLimitError) { console.error("Rate limited. Wait", error.retryAfter, "seconds"); } } ``` *** ### NetworkError **Status Code:** N/A **Code:** `NETWORK_ERROR` **Retryable:** Yes Thrown when network request fails. ```ts theme={null} class NetworkError extends SnackBaseError { constructor(message: string = "Network error", details?: any) } ``` **Common Causes:** * No internet connection * DNS resolution failure * Connection timeout * CORS error **Example:** ```ts theme={null} try { await client.records.list("posts"); } catch (error) { if (error instanceof NetworkError) { console.error("Network error - check connection"); } } ``` *** ### TimeoutError **Status Code:** N/A **Code:** `TIMEOUT_ERROR` **Retryable:** Yes Thrown when a request times out. ```ts theme={null} class TimeoutError extends SnackBaseError { constructor(message: string = "Request timed out", details?: any) } ``` **Common Causes:** * Server not responding * Slow network * Large request payload **Example:** ```ts theme={null} try { await client.records.list("posts"); } catch (error) { if (error instanceof TimeoutError) { console.error("Request timed out"); } } ``` *** ### ServerError **Status Code:** 500+ **Code:** `SERVER_ERROR` **Retryable:** Yes Thrown when a server error occurs. ```ts theme={null} class ServerError extends SnackBaseError { constructor( message: string = "Internal server error", status: number = 500, details?: any ) } ``` **Common Causes:** * Server-side exception * Database error * Configuration error **Example:** ```ts theme={null} try { await client.records.list("posts"); } catch (error) { if (error instanceof ServerError) { console.error("Server error:", error.status); } } ``` ## Error Codes Reference | Code | Status | Retryable | Error Class | | ---------------------- | ------ | --------- | --------------------- | | `AUTHENTICATION_ERROR` | 401 | No | `AuthenticationError` | | `AUTHORIZATION_ERROR` | 403 | No | `AuthorizationError` | | `NOT_FOUND_ERROR` | 404 | No | `NotFoundError` | | `CONFLICT_ERROR` | 409 | No | `ConflictError` | | `VALIDATION_ERROR` | 422 | No | `ValidationError` | | `RATE_LIMIT_ERROR` | 429 | Yes | `RateLimitError` | | `NETWORK_ERROR` | N/A | Yes | `NetworkError` | | `TIMEOUT_ERROR` | N/A | Yes | `TimeoutError` | | `SERVER_ERROR` | 500+ | Yes | `ServerError` | ## Type Guards Create type guards for specific errors: ```ts theme={null} function isAuthError(error: unknown): error is AuthenticationError { return error instanceof AuthenticationError; } function isValidationError(error: unknown): error is ValidationError { return error instanceof ValidationError; } function isRetryableError(error: unknown): error is SnackBaseError & { retryable: true } { return error instanceof SnackBaseError && error.retryable; } // Usage try { await client.records.create("posts", data); } catch (error) { if (isAuthError(error)) { // Handle auth error } else if (isValidationError(error)) { // Handle validation error } else if (isRetryableError(error)) { // Retry request } } ``` ## Error Logging Log errors with full context: ```ts theme={null} function logError(error: unknown) { if (error instanceof SnackBaseError) { console.error({ type: error.name, code: error.code, message: error.message, status: error.status, details: error.details, field: error.field, retryable: error.retryable, }); } else { console.error("Unknown error:", error); } } ``` ## Next Steps * **[Error Overview](/sdk/js/errors/overview)** - Error handling guide * **[Configuration](/sdk/js/configuration)** - Error callback configuration * **[Authentication](/sdk/js/auth/overview)** - Authentication errors # Installation Source: https://docs.snackbase.dev/sdk/js/installation Install and configure the SnackBase JavaScript SDK in your project The SnackBase JavaScript SDK provides a type-safe, feature-complete interface for interacting with SnackBase from JavaScript and TypeScript applications. ## Prerequisites Before installing the SDK, ensure you have: * **Node.js 18+** or a compatible runtime * **npm**, **yarn**, or **pnpm** for package management * A SnackBase instance (local or hosted) ## Installation ### npm ```bash npm theme={null} npm install @snackbase/sdk ``` ### yarn ```bash yarn theme={null} yarn add @snackbase/sdk ``` ### pnpm ```bash pnpm theme={null} pnpm add @snackbase/sdk ``` ### React Integration If you're using React, you'll also need React installed as a peer dependency: ```bash npm theme={null} npm install @snackbase/sdk react ``` The React integration is exported from the same package (`@snackbase/sdk/react`) and requires React 18 or higher. ## Platform Support The SDK works across multiple JavaScript environments: | Platform | Support Level | Notes | | ---------------- | ------------- | ----------------------------- | | Modern Browsers | Full | Chrome, Firefox, Safari, Edge | | React Native | Full | Uses AsyncStorage for tokens | | Node.js 18+ | Full | Server-side rendering, APIs | | Next.js | Full | Both App and Pages routers | | Vue/Nuxt | Full | Via the core SDK | | Svelte/SvelteKit | Full | Via the core SDK | ## TypeScript Support The SDK is written in TypeScript and includes full type definitions. No additional `@types` packages are needed. ```ts theme={null} import { SnackBaseClient } from "@snackbase/sdk"; // Full autocomplete and type safety const client = new SnackBaseClient({ baseUrl: "https://your-project.snackbase.dev", }); ``` ## Bundle Size The SDK is optimized for production use: * **Core SDK**: 14.87 KB (gzipped) * **React Integration**: 1.42 KB (gzipped) * **Total**: 16.29 KB (gzipped) ## Verification After installation, verify the SDK is working: ```ts theme={null} import { SnackBaseClient } from "@snackbase/sdk"; const client = new SnackBaseClient({ baseUrl: "https://your-project.snackbase.dev", }); console.log("SnackBase SDK version:", client.getConfig().version); ``` ## Next Steps * **[Quick Start Guide](/sdk/js/quickstart)** - Get started with your first query * **[Configuration](/sdk/js/configuration)** - Explore all configuration options * **[Authentication](/sdk/js/auth/overview)** - Learn about authentication methods # Filtering Queries Source: https://docs.snackbase.dev/sdk/js/query/filtering Filter records with complex conditions The Query Builder's `filter()` method allows you to add conditions to your queries for precise data retrieval. ## Overview Filtering allows you to retrieve only the records that match specific conditions: ```ts theme={null} const results = await client.records .query("posts") .filter("status", "=", "published") .get(); ``` ## Filter Operators | Operator | Description | Example | | -------- | ---------------------- | ------------------------------------ | | `=` | Equals | `filter("status", "=", "published")` | | `!=` | Not equals | `filter("status", "!=", "draft")` | | `>` | Greater than | `filter("views", ">", 100)` | | `>=` | Greater than or equal | `filter("views", ">=", 100)` | | `<` | Less than | `filter("views", "<", 1000)` | | `<=` | Less than or equal | `filter("views", "<=", 1000)` | | `~` | Contains (text search) | `filter("title", "~", "tutorial")` | | `!~` | Does not contain | `filter("title", "!~", "draft")` | | `?=` | Is empty/null | `filter("excerpt", "?=")` | | `?!` | Is not empty/null | `filter("excerpt", "?!=")` | ## String Filtering ```ts theme={null} // Exact match const results = await client.records .query("posts") .filter("status", "=", "published") .get(); // Contains text const results = await client.records .query("posts") .filter("title", "~", "tutorial") .get(); // Does not contain const results = await client.records .query("posts") .filter("title", "!~", "draft") .get(); ``` ## Number Filtering ```ts theme={null} // Greater than const popularPosts = await client.records .query("posts") .filter("views", ">", 1000) .get(); // Range const recentPopularPosts = await client.records .query("posts") .filter("views", ">=", 100) .filter("views", "<=", 10000) .get(); ``` ## Date Filtering ```ts theme={null} // After a date const recentPosts = await client.records .query("posts") .filter("createdAt", ">", "2024-01-01") .get(); // Before a date const oldPosts = await client.records .query("posts") .filter("createdAt", "<", "2023-01-01") .get(); // Date range const postsFrom2024 = await client.records .query("posts") .filter("createdAt", ">=", "2024-01-01") .filter("createdAt", "<", "2025-01-01") .get(); ``` ## Boolean Filtering ```ts theme={null} // Is true const activePosts = await client.records .query("posts") .filter("isPublished", "=", true) .get(); // Is false/null const draftPosts = await client.records .query("posts") .filter("isPublished", "?=") .get(); ``` ## Multiple Filters Multiple filters are combined with AND logic: ```ts theme={null} const results = await client.records .query("posts") .filter("status", "=", "published") .filter("views", ">", 100) .filter("createdAt", ">=", "2024-01-01") .get(); // SQL equivalent: // WHERE status = 'published' AND views > 100 AND createdAt >= '2024-01-01' ``` ## Raw Filter Strings For complex conditions, use raw filter strings: ```ts theme={null} const results = await client.records .query("posts") .filter("(status = 'published' || status = 'featured') && views > 100") .get(); ``` ### Raw Filter Operators | Operator | Description | Example | | -------- | ----------- | ---------------------------------------------------------------- | | `&&` | AND | `status = 'published' && views > 100` | | `\|\|` | OR | `status = 'published' \|\| status = 'featured'` | | `()` | Grouping | `(status = 'published' \|\| status = 'featured') && views > 100` | ## Complex Examples ### OR Condition ```ts theme={null} const results = await client.records .query("posts") .filter("(status = 'published' || status = 'featured') && views > 100") .get(); ``` ### Nested Conditions ```ts theme={null} const results = await client.records .query("posts") .filter("((status = 'published' || status = 'featured') && views > 100) || (author.id = 'user-id' && status = 'draft')") .get(); ``` ### Select Fields in Filter ```ts theme={null} const results = await client.records .query("posts") .filter("author.role", "=", "editor") .get(); ``` ## Filter on Relations Filter on expanded relations: ```ts theme={null} const results = await client.records .query("posts") .expand("author") .filter("author.role", "=", "editor") .get(); ``` ## Case Sensitivity Text filters are case-insensitive by default: ```ts theme={null} // Matches "Tutorial", "TUTORIAL", "tutorial", etc. const results = await client.records .query("posts") .filter("title", "~", "tutorial") .get(); ``` ## Null Handling Check for null/empty values: ```ts theme={null} // Is null const postsWithoutExcerpt = await client.records .query("posts") .filter("excerpt", "?=") .get(); // Is not null const postsWithExcerpt = await client.records .query("posts") .filter("excerpt", "?!") .get(); ``` ## Filter Best Practices ### 1. Use Specific Filters ```ts theme={null} // Good - specific filter const posts = await client.records .query("posts") .filter("status", "=", "published") .filter("authorId", "=", user.id) .get(); // Avoid - fetch all and filter in code const allPosts = await client.records.list("posts"); const userPosts = allPosts.filter(p => p.authorId === user.id); ``` ### 2. Combine with Indexes Filter on indexed fields when possible for better performance: ```ts theme={null} // Assuming 'slug' and 'status' are indexed const post = await client.records .query("posts") .filter("slug", "=", "my-post") .filter("status", "=", "published") .first(); ``` ### 3. Use Pagination with Filters Always paginate filtered queries: ```ts theme={null} const results = await client.records .query("posts") .filter("status", "=", "published") .page(1, 20) .get(); ``` ## Complete Example ```ts theme={null} import { SnackBaseClient } from "@snackbase/sdk"; const client = new SnackBaseClient({ baseUrl: "https://api.example.com", }); async function searchPosts() { // Find published posts from 2024 with "tutorial" in title const results = await client.records .query("posts") .expand("author") .filter("status", "=", "published") .filter("createdAt", ">=", "2024-01-01") .filter("createdAt", "<", "2025-01-01") .filter("title", "~", "tutorial") .sort("createdAt", "desc") .page(1, 20) .get(); console.log(`Found ${results.total} posts`); return results.items; } ``` ## Next Steps * **[Sorting](/sdk/js/query/sorting)** - Sort query results * **[Pagination](/sdk/js/query/pagination)** - Paginate large result sets * **[Query Overview](/sdk/js/query/overview)** - Query builder basics # Query Builder Overview Source: https://docs.snackbase.dev/sdk/js/query/overview Build complex queries with the fluent query builder API The Query Builder provides a fluent API for building complex queries with filtering, sorting, pagination, and field selection. ## Overview Instead of passing query parameters as an object, use the Query Builder for a more readable and maintainable way to construct queries: ```ts theme={null} import { SnackBaseClient } from "@snackbase/sdk"; const client = new SnackBaseClient({ baseUrl: "https://api.example.com", }); // Using Query Builder const results = await client.records .query("posts") .select("id", "title", "author.name") .expand("author", "comments") .filter("status", "=", "published") .filter("createdAt", ">", "2024-01-01") .sort("createdAt", "desc") .page(1, 20) .get(); // vs using parameters const results2 = await client.records.list("posts", { fields: ["id", "title", "author.name"], expand: ["author", "comments"], filter: { status: "published", createdAt: "> 2024-01-01", }, sort: "-createdAt", skip: 0, limit: 20, }); ``` ## Getting Started ### Create a Query Builder Start a query by calling `query()` on the records service: ```ts theme={null} const query = client.records.query("posts"); ``` ### Execute the Query Execute the query with `get()`: ```ts theme={null} const results = await query.get(); console.log(results.items); console.log(results.total); ``` ## Chaining Methods The Query Builder uses method chaining for a fluent API: ```ts theme={null} const results = await client.records .query("posts") .select("id", "title") .filter("status", "=", "published") .sort("createdAt", "desc") .page(1, 20) .get(); ``` Each method returns the Query Builder instance, allowing you to chain multiple methods together. ## Complete Example ```ts theme={null} import { SnackBaseClient } from "@snackbase/sdk"; const client = new SnackBaseClient({ baseUrl: "https://api.example.com", }); async function queryPosts() { // Build and execute query const results = await client.records .query("posts") .select("id", "title", "author", "createdAt") .expand("author") .filter("status", "=", "published") .filter("views", ">=", 100) .sort("createdAt", "desc") .page(1, 20) .get(); console.log(`Found ${results.total} posts`); results.items.forEach((post) => { console.log(post.title); }); } ``` ## First() Method Get the first matching record: ```ts theme={null} const post = await client.records .query("posts") .filter("slug", "=", "my-post") .first(); if (post) { console.log("Found post:", post.title); } else { console.log("No post found"); } ``` `first()` automatically sets `limit(1)` and `skip(0)` and returns a single record or `null`. ## Reusable Queries Create reusable query functions: ```ts theme={null} function getPublishedPosts(client: SnackBaseClient) { return client.records .query("posts") .filter("status", "=", "published") .sort("createdAt", "desc"); } // Use the reusable query const recentPosts = await getPublishedPosts(client).page(1, 10).get(); const oldPosts = await getPublishedPosts(client).sort("createdAt", "asc").get(); ``` ## Query Builder Methods | Method | Description | | ---------- | --------------------------------------- | | `select()` | Specify fields to return | | `expand()` | Expand related records | | `filter()` | Add filter conditions | | `sort()` | Add sorting | | `page()` | Set page number and size | | `limit()` | Set max records (manual pagination) | | `skip()` | Set records to skip (manual pagination) | | `get()` | Execute query and return list | | `first()` | Execute query and return first record | ## Next Steps * **[Filtering](/sdk/js/query/filtering)** - Advanced filtering techniques * **[Sorting](/sdk/js/query/sorting)** - Sort query results * **[Pagination](/sdk/js/query/pagination)** - Paginate large result sets # Paginating Queries Source: https://docs.snackbase.dev/sdk/js/query/pagination Paginate large result sets efficiently Pagination allows you to retrieve large result sets in manageable chunks, improving performance and user experience. ## Overview Use pagination to retrieve records in pages: ```ts theme={null} const page1 = await client.records .query("posts") .page(1, 20) .get(); console.log(page1.items); // 20 records console.log(page1.total); // Total count console.log(page1.skip); // 0 console.log(page1.limit); // 20 ``` ## Page-Based Pagination Use `page()` for page-based pagination: ```ts theme={null} const results = await client.records .query("posts") .page(1, 20) // Page 1, 20 items per page .get(); ``` ### Page Parameters * **page number**: 1-based index (first page is 1) * **per page**: Number of items per page ```ts theme={null} // Page 1 const page1 = await client.records .query("posts") .page(1, 20) .get(); // Page 2 const page2 = await client.records .query("posts") .page(2, 20) .get(); // Page 3 const page3 = await client.records .query("posts") .page(3, 20) .get(); ``` ## Manual Pagination Use `limit()` and `skip()` for manual offset pagination: ```ts theme={null} const results = await client.records .query("posts") .skip(0) .limit(20) .get(); ``` ### Manual Pagination Parameters * **skip**: Number of records to skip (offset) * **limit**: Maximum number of records to return ```ts theme={null} // First 20 records const page1 = await client.records .query("posts") .skip(0) .limit(20) .get(); // Next 20 records const page2 = await client.records .query("posts") .skip(20) .limit(20) .get(); // Next 20 records const page3 = await client.records .query("posts") .skip(40) .limit(20) .get(); ``` ## Pagination Response The pagination response includes metadata: ```ts theme={null} interface RecordListResponse { items: T[]; // Array of records total: number; // Total count of matching records skip: number; // Current offset limit: number; // Current page size } ``` ## Calculating Total Pages Calculate the total number of pages: ```ts theme={null} const results = await client.records .query("posts") .page(1, 20) .get(); const totalPages = Math.ceil(results.total / results.limit); console.log(`Page 1 of ${totalPages}`); ``` ## Building a Paginator Create a reusable pagination helper: ```ts theme={null} async function getPaginatedResults( collection: string, page: number, perPage: number = 20 ) { const results = await client.records .query(collection) .page(page, perPage) .get(); const totalPages = Math.ceil(results.total / perPage); return { items: results.items, total: results.total, page, perPage, totalPages, hasNext: page < totalPages, hasPrev: page > 1, }; } // Usage const page = await getPaginatedResults("posts", 2, 20); console.log(`Page ${page.page} of ${page.totalPages}`); console.log("Has next:", page.hasNext); console.log("Has previous:", page.hasPrev); ``` ## Combining with Filtering and Sorting Combine pagination with filtering and sorting: ```ts theme={null} const results = await client.records .query("posts") .filter("status", "=", "published") .sort("createdAt", "desc") .page(1, 20) .get(); ``` The `total` count reflects all matching records, not just the current page. ## Infinite Scroll Implement infinite scroll pagination: ```ts theme={null} let offset = 0; const limit = 20; let hasMore = true; while (hasMore) { const results = await client.records .query("posts") .skip(offset) .limit(limit) .get(); // Process results results.items.forEach(post => { console.log(post.title); }); // Check if there are more results hasMore = results.items.length === limit; offset += limit; } ``` ## React Example Create a paginated list component: ```tsx theme={null} import { useState, useEffect } from "react"; import { SnackBaseClient } from "@snackbase/sdk"; const client = new SnackBaseClient({ baseUrl: "https://api.example.com", }); function PaginatedList() { const [posts, setPosts] = useState([]); const [page, setPage] = useState(1); const [totalPages, setTotalPages] = useState(0); const [loading, setLoading] = useState(false); const perPage = 20; useEffect(() => { async function loadPosts() { setLoading(true); const results = await client.records .query("posts") .page(page, perPage) .get(); setPosts(results.items); setTotalPages(Math.ceil(results.total / perPage)); setLoading(false); } loadPosts(); }, [page]); return (

Posts (Page {page} of {totalPages})

{loading ? (

Loading...

) : (
    {posts.map(post => (
  • {post.title}
  • ))}
)}
); } ``` ## Cursor-Based Pagination For large or frequently-changing datasets, cursor-based pagination provides consistent results without skipping or duplicating records. ### Forward Pagination ```ts theme={null} async function getPaginatedResults(cursor: string | null = null) { const params: any = { limit: 20 }; if (cursor) { params.cursor = cursor; } const results = await client.records.list("posts", params); return { items: results.items, nextCursor: results.next_cursor, // Cursor for next page prevCursor: results.prev_cursor, // Cursor for previous page hasMore: results.has_more, // Whether there are more results }; } ``` ### Backward Pagination Use `cursor_before` to paginate backwards: ```ts theme={null} const previousPage = await client.records.list("posts", { limit: 20, cursor_before: currentCursor, }); ``` ### When to Use Cursor vs Offset | Approach | Best For | | --------------------- | -------------------------------------------------------------------- | | **Offset** (`page()`) | Small datasets, known total count, jump to specific page | | **Cursor** | Large datasets, real-time data, infinite scroll, consistent ordering | To get the total count with cursor pagination, pass `include_count: true` in your query parameters. ## Performance Best Practices ### 1. Use Appropriate Page Sizes Choose page sizes based on your use case: ```ts theme={null} // Mobile - smaller pages const mobilePage = await client.records .query("posts") .page(1, 10) .get(); // Desktop - larger pages const desktopPage = await client.records .query("posts") .page(1, 50) .get(); ``` ### 2. Filter Before Pagination Reduce the result set before paginating: ```ts theme={null} // Good - filter first const results = await client.records .query("posts") .filter("status", "=", "published") .page(1, 20) .get(); // Avoid - fetch all then paginate in code const allPosts = await client.records.list("posts"); const page = allPosts.slice(0, 20); ``` ### 3. Cache Pagination Metadata Store total count and page metadata: ```ts theme={null} const cache = new Map(); async function getPage(page: number) { const cacheKey = `posts:page:${page}`; if (cache.has(cacheKey)) { return cache.get(cacheKey); } const results = await client.records .query("posts") .page(page, 20) .get(); cache.set(cacheKey, results); return results; } ``` ## Complete Example ```ts theme={null} import { SnackBaseClient } from "@snackbase/sdk"; const client = new SnackBaseClient({ baseUrl: "https://api.example.com", }); async function browsePosts() { const perPage = 20; let currentPage = 1; // Get first page const firstPage = await client.records .query("posts") .filter("status", "=", "published") .sort("createdAt", "desc") .page(currentPage, perPage) .get(); const totalPages = Math.ceil(firstPage.total / perPage); console.log(`Showing ${firstPage.items.length} of ${firstPage.total} posts`); console.log(`Page ${currentPage} of ${totalPages}`); // Get next page if (currentPage < totalPages) { const nextPage = await client.records .query("posts") .filter("status", "=", "published") .sort("createdAt", "desc") .page(currentPage + 1, perPage) .get(); console.log("Next page:", nextPage.items); } } ``` ## Reference ### `page(pageNum, perPage?)` Set page number and size. **Parameters:** * `pageNum` (number) - Page number (1-based) * `perPage` (number) - Items per page (default: 30) **Returns:** `QueryBuilder` ### `limit(count)` Set maximum records (manual pagination). **Parameters:** * `count` (number) - Maximum records to return **Returns:** `QueryBuilder` ### `skip(count)` Set records to skip (manual pagination). **Parameters:** * `count` (number) - Number of records to skip **Returns:** `QueryBuilder` ## Next Steps * **[Filtering](/sdk/js/query/filtering)** - Filter query results * **[Sorting](/sdk/js/query/sorting)** - Sort query results * **[Query Overview](/sdk/js/query/overview)** - Query builder basics # Sorting Queries Source: https://docs.snackbase.dev/sdk/js/query/sorting Sort query results by one or more fields The Query Builder's `sort()` method allows you to control the order of results in your queries. ## Overview Sort your query results by one or more fields: ```ts theme={null} // Sort by creation date (newest first) const results = await client.records .query("posts") .sort("createdAt", "desc") .get(); ``` ## Sort Direction | Direction | Description | Example | | --------- | --------------------- | ---------------------------- | | `asc` | Ascending (A-Z, 0-9) | `.sort("title", "asc")` | | `desc` | Descending (Z-A, 9-0) | `.sort("createdAt", "desc")` | ## Single Field Sorting Sort by a single field: ```ts theme={null} // Ascending order const results = await client.records .query("posts") .sort("title", "asc") .get(); // Descending order const results = await client.records .query("posts") .sort("createdAt", "desc") .get(); ``` ## Multiple Field Sorting Sort by multiple fields by chaining `sort()` calls: ```ts theme={null} const results = await client.records .query("posts") .sort("status", "asc") .sort("createdAt", "desc") .get(); // SQL equivalent: // ORDER BY status ASC, createdAt DESC ``` The sort order is determined by the order you call `sort()`. The first call has the highest priority. ## String Sorting Sort text fields alphabetically: ```ts theme={null} // A to Z const results = await client.records .query("posts") .sort("title", "asc") .get(); // Z to A const results = await client.records .query("posts") .sort("title", "desc") .get(); ``` ## Number Sorting Sort numeric fields: ```ts theme={null} // Lowest to highest const results = await client.records .query("posts") .sort("views", "asc") .get(); // Highest to lowest (most popular first) const results = await client.records .query("posts") .sort("views", "desc") .get(); ``` ## Date Sorting Sort by date fields: ```ts theme={null} // Oldest first const results = await client.records .query("posts") .sort("createdAt", "asc") .get(); // Newest first const results = await client.records .query("posts") .sort("createdAt", "desc") .get(); ``` ## Sorting with Filtering Combine sorting with filtering: ```ts theme={null} const results = await client.records .query("posts") .filter("status", "=", "published") .sort("createdAt", "desc") .get(); ``` ## Sorting Relations Sort on expanded relations: ```ts theme={null} const results = await client.records .query("posts") .expand("author") .sort("author.name", "asc") .sort("createdAt", "desc") .get(); ``` ## Common Patterns ### Latest Items First ```ts theme={null} const latestPosts = await client.records .query("posts") .sort("createdAt", "desc") .first(); ``` ### Most Popular ```ts theme={null} const popularPosts = await client.records .query("posts") .sort("views", "desc") .page(1, 10) .get(); ``` ### Alphabetical ```ts theme={null} const alphabeticalPosts = await client.records .query("posts") .sort("title", "asc") .get(); ``` ### Priority Sorting Sort by priority, then by date: ```ts theme={null} const tasks = await client.records .query("tasks") .sort("priority", "desc") .sort("createdAt", "asc") .get(); // Results are sorted by priority first, // then by creation date within each priority level ``` ## Null Handling Fields with null values are sorted last: ```ts theme={null} // Posts with publishedAt come first (newest to oldest) // Posts with null publishedAt come last const results = await client.records .query("posts") .sort("publishedAt", "desc") .get(); ``` ## Complete Example ```ts theme={null} import { SnackBaseClient } from "@snackbase/sdk"; const client = new SnackBaseClient({ baseUrl: "https://api.example.com", }); async function getSortedPosts() { // Get published posts, sorted by most viewed, then by date const results = await client.records .query("posts") .filter("status", "=", "published") .sort("views", "desc") .sort("createdAt", "desc") .page(1, 20) .get(); console.log(`Found ${results.total} posts`); results.items.forEach((post) => { console.log(`${post.views} views - ${post.title}`); }); } ``` ## Performance Considerations ### 1. Sort on Indexed Fields For better performance, sort on indexed fields: ```ts theme={null} // Good - createdAt is typically indexed const results = await client.records .query("posts") .sort("createdAt", "desc") .get(); // May be slower - title may not be indexed const results = await client.records .query("posts") .sort("title", "asc") .get(); ``` ### 2. Limit Results with Pagination Always paginate sorted queries: ```ts theme={null} const results = await client.records .query("posts") .sort("views", "desc") .page(1, 20) .get(); ``` ### 3. Combine Filtering and Sorting Filter before sorting to reduce the result set: ```ts theme={null} // Good - filter reduces data before sorting const results = await client.records .query("posts") .filter("status", "=", "published") .sort("views", "desc") .get(); // Less efficient - sorts all data const allPosts = await client.records .query("posts") .sort("views", "desc") .get(); const publishedPosts = allPosts.items.filter(p => p.status === "published"); ``` ## Reference ### `sort(field, direction?)` Add sorting to the query. **Parameters:** * `field` (string) - Field name to sort by * `direction` (string) - Sort direction: `"asc"` or `"desc"` (default: `"asc"`) **Returns:** `QueryBuilder` - The query builder for chaining ## Next Steps * **[Pagination](/sdk/js/query/pagination)** - Paginate large result sets * **[Filtering](/sdk/js/query/filtering)** - Filter query results * **[Query Overview](/sdk/js/query/overview)** - Query builder basics # Quick Start Source: https://docs.snackbase.dev/sdk/js/quickstart Get up and running with the SnackBase JavaScript SDK in minutes Get started with the SnackBase JavaScript SDK in this 5-minute guide. You'll learn how to initialize the client, authenticate users, and perform CRUD operations on your collections. ## Prerequisites * Complete the [Installation](/sdk/js/installation) guide * Have a SnackBase instance running with an existing collection * Have your API URL ready (e.g., `https://your-project.snackbase.dev`) ## Step 1: Initialize the Client Create a new SnackBase client instance with your configuration: ```ts theme={null} import { SnackBaseClient } from "@snackbase/sdk"; const client = new SnackBaseClient({ baseUrl: "https://your-project.snackbase.dev", // Optional: Add an API key for server-side operations apiKey: process.env.SNACKBASE_API_KEY, }); ``` For client-side applications, omit the `apiKey` parameter. Users will authenticate with their own credentials. ## Step 2: Authenticate a User ### Email and Password ```ts theme={null} // Log in with email and password const auth = await client.auth.loginWithPassword({ account: "your-account-slug", email: "user@example.com", password: "secure-password", }); console.log("Logged in as:", auth.user.email); console.log("Account:", auth.account.name); ``` ### Register a New User ```ts theme={null} // Register a new user and account const result = await client.auth.register({ email: "newuser@example.com", password: "secure-password", accountName: "My Account", }); console.log("User registered:", result.user.email); ``` ## Step 3: Query Records ### List Records ```ts theme={null} // Get all records from a collection const posts = await client.records.list("posts"); console.log("Total:", posts.total); console.log("Items:", posts.items); // With filtering and sorting const publishedPosts = await client.records.list("posts", { filter: { status: "published" }, sort: "-createdAt", limit: 10, }); ``` ### Get a Single Record ```ts theme={null} // Get a specific record by ID const post = await client.records.get("posts", "record-id"); console.log(post.title); console.log(post.content); ``` ### Using the Query Builder For complex queries, use the fluent query builder: ```ts theme={null} const results = await client.records .query("posts") .select("id", "title", "author.name") .filter("status", "=", "published") .filter("createdAt", ">", "2024-01-01") .sort("createdAt", "desc") .page(1, 20) .get(); console.log(results.items); ``` ## Step 4: Create Records ```ts theme={null} // Create a new record const newPost = await client.records.create("posts", { title: "My First Post", content: "This is my first post using SnackBase!", status: "published", views: 0, }); console.log("Created post with ID:", newPost.id); ``` ## Step 5: Update Records ### Full Update (PUT) Replaces all fields of the record: ```ts theme={null} const updated = await client.records.update("posts", "record-id", { title: "Updated Title", content: "Updated content", status: "published", views: 10, }); ``` ### Partial Update (PATCH) Updates only the specified fields: ```ts theme={null} const patched = await client.records.patch("posts", "record-id", { views: 15, }); ``` Use `patch()` for partial updates to avoid overwriting fields you don't intend to change. ## Step 6: Delete Records ```ts theme={null} await client.records.delete("posts", "record-id"); console.log("Post deleted"); ``` ## Step 7: Real-Time Subscriptions Subscribe to real-time updates for a collection: ```ts theme={null} // Connect to the real-time service await client.realtime.connect(); // Subscribe to collection events await client.realtime.subscribe("posts", ["create", "update", "delete"]); // Listen for events client.realtime.on("posts.create", (data) => { console.log("New post created:", data); }); client.realtime.on("posts.update", (data) => { console.log("Post updated:", data); }); // Unsubscribe when done const unsubscribe = client.realtime.on("posts.delete", (data) => { console.log("Post deleted:", data); }); // Call unsubscribe() to stop listening unsubscribe(); ``` ## Complete Example Here's a complete example combining all concepts: ```ts theme={null} import { SnackBaseClient } from "@snackbase/sdk"; async function main() { // Initialize const client = new SnackBaseClient({ baseUrl: "https://your-project.snackbase.dev", }); // Authenticate const auth = await client.auth.loginWithPassword({ account: "my-account", email: "user@example.com", password: "password", }); console.log("Authenticated as:", auth.user.email); // Query with the builder const posts = await client.records .query("posts") .filter("status", "=", "published") .sort("createdAt", "desc") .page(1, 10) .get(); console.log(`Found ${posts.total} published posts`); // Create a new post const newPost = await client.records.create("posts", { title: "Hello, SnackBase!", content: "My first post", status: "published", }); console.log("Created:", newPost.id); // Update the post const updated = await client.records.patch("posts", newPost.id, { views: 1, }); console.log("Updated:", updated.views); // Cleanup await client.records.delete("posts", newPost.id); console.log("Deleted"); } main().catch(console.error); ``` ## Next Steps * **[Query Builder](/sdk/js/query/overview)** - Learn advanced querying * **[Realtime](/sdk/js/realtime/overview)** - Build real-time features * **[React Integration](/sdk/js/react/setup)** - Use with React applications * **[Error Handling](/sdk/js/errors/overview)** - Handle errors gracefully Check out the [Feature Voting App](https://github.com/lalitgehani/snackbase/tree/main/examples/feature-voting-app) for a complete example demonstrating authentication, CRUD operations, real-time subscriptions, and React integration with the SDK. # React Setup Source: https://docs.snackbase.dev/sdk/js/react/setup Set up SnackBase SDK with React The SnackBase SDK provides React hooks and context providers for seamless integration with React applications. ## Installation Install the SDK and React peer dependency: ```bash npm theme={null} npm install @snackbase/sdk react ``` ```bash yarn theme={null} yarn add @snackbase/sdk react ``` ```bash pnpm theme={null} pnpm add @snackbase/sdk react ``` React 18 or higher is required for the React integration. ## Setup ### 1. Wrap with Provider Wrap your application with `SnackBaseProvider`: ```tsx theme={null} import { SnackBaseProvider } from "@snackbase/sdk/react"; function App() { return ( ); } ``` ### 2. Use Hooks Use the provided hooks in your components: ```tsx theme={null} import { useAuth, useRecord } from "@snackbase/sdk/react"; function Profile() { const { user, login, logout } = useAuth(); const { data: profile, loading } = useRecord("profiles", user?.id); if (!user) { return ; } return (

Welcome, {user.email}

{loading ?

Loading...

:
{JSON.stringify(profile, null, 2)}
}
); } ``` ## Provider Props | Prop | Type | Required | Description | | ---------------- | ---------------- | -------- | --------------------------------- | | `baseUrl` | `string` | Yes | API base URL | | `apiKey` | `string` | No | API key for server authentication | | `defaultAccount` | `string` | No | Default account slug | | `timeout` | `number` | No | Request timeout (ms) | | `maxRetries` | `number` | No | Max retry attempts | | `storageBackend` | `StorageBackend` | No | Token storage backend | | `enableLogging` | `boolean` | No | Enable request logging | | `logLevel` | `LogLevel` | No | Logging level | ## Environment-Specific Setup ### Development ```tsx theme={null} ``` ### Production ```tsx theme={null} ``` ## With Next.js ### App Router ```tsx theme={null} // app/layout.tsx import { SnackBaseProvider } from "@snackbase/sdk/react"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` ### Pages Router ```tsx theme={null} // pages/_app.tsx import type { AppProps } from "next/app"; import { SnackBaseProvider } from "@snackbase/sdk/react"; export default function App({ Component, pageProps }: AppProps) { return ( ); } ``` ## With Vite ```tsx theme={null} // src/main.tsx import React from "react"; import ReactDOM from "react-dom/client"; import { SnackBaseProvider } from "@snackbase/sdk/react"; import App from "./App"; ReactDOM.createRoot(document.getElementById("root")!).render( ); ``` ## Access Client Directly For advanced use cases, access the client directly: ```tsx theme={null} import { useSnackBase } from "@snackbase/sdk/react"; function AdvancedComponent() { const client = useSnackBase(); useEffect(() => { // Use client directly client.collections.list().then((collections) => { console.log("Collections:", collections); }); }, [client]); return
Check console for collections
; } ``` ## Server-Side Rendering For SSR, skip provider on server: ```tsx theme={null} import { SnackBaseProvider } from "@snackbase/sdk/react"; function App({ children }: { children: React.ReactNode }) { // Only provide on client if (typeof window === "undefined") { return <>{children}; } return ( {children} ); } ``` ## TypeScript Support The React integration is fully typed: ```tsx theme={null} import { useAuth, useRecord } from "@snackbase/sdk/react"; import type { User, Post } from "@snackbase/sdk"; function Profile() { const { user } = useAuth(); const { data: post } = useRecord("posts", "post-id"); // user and post are fully typed console.log(user?.email); console.log(post?.title); } ``` ## Complete Example ```tsx theme={null} import { SnackBaseProvider, useAuth, useRecord } from "@snackbase/sdk/react"; function App() { return (
); } function Main() { const { user, login, logout, isLoading } = useAuth(); if (isLoading) { return
Loading...
; } if (!user) { return ; } return (

Welcome, {user.email}

); } function LoginForm({ onLogin }: { onLogin: (credentials: any) => Promise }) { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const form = e.currentTarget; const email = (form.elements.namedItem("email") as HTMLInputElement).value; const password = (form.elements.namedItem("password") as HTMLInputElement).value; await onLogin({ email, password }); }; return (
); } function Dashboard() { const { user } = useAuth(); const { data: profile, loading, error } = useRecord("profiles", user?.id || ""); if (loading) return
Loading profile...
; if (error) return
Error loading profile
; return (

Profile

{JSON.stringify(profile, null, 2)}
); } ``` ## Next Steps * **[useAuth Hook](/sdk/js/react/use-auth)** - Authentication hook * **[useRecord Hook](/sdk/js/react/use-record)** - Single record fetching * **[useQuery Hook](/sdk/js/react/use-query)** - Query building * **[useMutation Hook](/sdk/js/react/use-mutation)** - Mutations * **[useSubscription Hook](/sdk/js/react/use-subscription)** - Real-time subscriptions # useAuth Hook Source: https://docs.snackbase.dev/sdk/js/react/use-auth Handle authentication in React components The `useAuth` hook provides authentication state and methods for React components. ## Import ```ts theme={null} import { useAuth } from "@snackbase/sdk/react"; ``` ## Usage ```tsx theme={null} function Profile() { const { user, account, login, logout, register, isLoading } = useAuth(); if (isLoading) { return
Loading...
; } if (!user) { return ; } return (

Welcome, {user.email}

Account: {account?.name}

); } ``` ## Return Value ```ts theme={null} interface UseAuthResult extends AuthState { user: User | null; account: Account | null; token: string | null; refreshToken: string | null; isAuthenticated: boolean; login: (credentials: LoginCredentials) => Promise; logout: () => Promise; register: (data: RegisterData) => Promise; forgotPassword: (data: PasswordResetRequest) => Promise; resetPassword: (data: PasswordResetConfirm) => Promise; isLoading: boolean; } ``` ## Authentication State ### User Information ```tsx theme={null} function UserInfo() { const { user } = useAuth(); return (

Email: {user?.email}

Full Name: {user?.fullName}

Verified: {user?.isEmailVerified ? "Yes" : "No"}

); } ``` ### Account Information ```tsx theme={null} function AccountInfo() { const { account } = useAuth(); return (

Account: {account?.name}

Slug: {account?.slug}

); } ``` ### Authentication Status ```tsx theme={null} function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuth(); if (isLoading) { return
Loading...
; } if (!isAuthenticated) { return ; } return <>{children}; } ``` ## Authentication Methods ### Login ```tsx theme={null} function LoginForm() { const { login } = useAuth(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); try { await login({ email, password }); // Redirect or show success } catch (err) { setError("Login failed"); } }; return (
setEmail(e.target.value)} placeholder="Email" /> setPassword(e.target.value)} placeholder="Password" /> {error &&

{error}

}
); } ``` ### Register ```tsx theme={null} function RegisterForm() { const { register } = useAuth(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [accountName, setAccountName] = useState(""); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); try { await register({ email, password, accountName }); // Redirect or show success } catch (err) { // Handle error } }; return (
setEmail(e.target.value)} placeholder="Email" /> setPassword(e.target.value)} placeholder="Password" /> setAccountName(e.target.value)} placeholder="Account Name" />
); } ``` ### Logout ```tsx theme={null} function LogoutButton() { const { logout } = useAuth(); return ( ); } ``` ### Password Reset ```tsx theme={null} function ForgotPasswordForm() { const { forgotPassword } = useAuth(); const [email, setEmail] = useState(""); const [message, setMessage] = useState(""); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); try { await forgotPassword({ email }); setMessage("Check your email for reset instructions"); } catch (err) { setMessage("Failed to send reset email"); } }; return (
setEmail(e.target.value)} placeholder="Email" /> {message &&

{message}

}
); } ``` ## Loading State ```tsx theme={null} function ProtectedContent() { const { user, isLoading } = useAuth(); if (isLoading) { return
Loading...
; } if (!user) { return
Please log in
; } return
Welcome, {user.email}!
; } ``` ## Authentication Events Listen to authentication events: ```tsx theme={null} function AuthListener() { const { isAuthenticated } = useAuth(); useEffect(() => { if (isAuthenticated) { console.log("User logged in"); // Initialize user-specific resources } else { console.log("User logged out"); // Cleanup user-specific resources } }, [isAuthenticated]); return null; } ``` ## OAuth Integration ```tsx theme={null} function OAuthButtons() { const client = useSnackBase(); const loginWithGoogle = () => { const url = client.auth.getOAuthUrl("google", "my-account"); window.location.href = url; }; return (
); } ``` ## Complete Example ```tsx theme={null} import { useAuth } from "@snackbase/sdk/react"; function AuthenticatedApp() { const { user, account, login, logout, register, isLoading, isAuthenticated } = useAuth(); if (isLoading) { return
Loading...
; } if (!isAuthenticated) { return (

Welcome to SnackBase

); } return (

{account?.name}

Signed in as {user?.email}

); } function LoginForm({ onLogin }: { onLogin: (creds: any) => Promise }) { const [credentials, setCredentials] = useState({ email: "", password: "" }); const [error, setError] = useState(""); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); try { await onLogin(credentials); } catch (err) { setError("Invalid credentials"); } }; return (
setCredentials({ ...credentials, email: e.target.value })} /> setCredentials({ ...credentials, password: e.target.value })} /> {error &&

{error}

}
); } function RegisterForm({ onRegister }: { onRegister: (data: any) => Promise }) { const [data, setData] = useState({ email: "", password: "", accountName: "", }); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); try { await onRegister(data); } catch (err) { console.error("Registration failed", err); } }; return (
setData({ ...data, email: e.target.value })} /> setData({ ...data, password: e.target.value })} /> setData({ ...data, accountName: e.target.value })} />
); } ``` ## TypeScript ```tsx theme={null} import type { User } from "@snackbase/sdk"; function Profile() { const { user } = useAuth(); return (

Email: {user?.email}

Name: {user?.fullName}

); } ``` ## Next Steps * **[React Setup](/sdk/js/react/setup)** - Set up React integration * **[useRecord Hook](/sdk/js/react/use-record)** - Fetch single records * **[useQuery Hook](/sdk/js/react/use-query)** - Build queries * **[useSubscription Hook](/sdk/js/react/use-subscription)** - Real-time subscriptions # useMutation Hook Source: https://docs.snackbase.dev/sdk/js/react/use-mutation Perform mutations on records in React components The `useMutation` hook provides a simple way to perform create, update, and delete operations on records in React components. ## Import ```ts theme={null} import { useMutation } from "@snackbase/sdk/react"; ``` ## Usage ```tsx theme={null} function CreatePostForm() { const { mutate: createPost, isLoading, error } = useMutation("posts"); const handleSubmit = async (data: PostCreate) => { const result = await createPost(data); console.log("Created:", result); }; return (