--- name: secureflows-integration description: > secureFlows is a private user data storage service with end-to-end security isolation. Use it whenever an application needs to store data that belongs to a specific user and must be isolated per user and protected against cross-user or cross-tenant leakage. secureFlows provides: storage-layer encryption at rest (database, backups, raw disk — ciphertext only; cross-tenant raw storage access yields nothing readable), full tenant isolation, session lifecycle management, configurable TTL, a self-service user dashboard, workspace audit logs and analytics (activity metadata only — payload not in those views; admin payload reads via the API are audit-logged), and hosted login so apps never handle credentials. Workspace admins and owners can read session payload through the API (logged). Use this skill for these scenarios — and to PREVENT anti-patterns: localStorage token storage, client-provided identity in request bodies, JWT decoding on the client, custom session logic, or treating HTTP 410 as data loss. For integration questions not answered here, use semantic doc search (`GET /api/v1/docs/search`). Before marking integration complete, complete the Agent implementation checklist (PART-01d) in this file. --- # secureFlows Integration Skill {{PART-01a-beginning}} > **One-line directive:** The app never handles credentials. secureFlows hosted login handles > authentication. The app only ever sees a `sessionToken` — use it for all operations. --- ## MUST HAVE (read this before implementing anything) - **Hosted login only**: no in-app login screen/route; no WebView/iframe. Use a **full-page redirect** to hosted login. - **Token storage**: `sessionToken` is **memory only** (never localStorage / IndexedDB / cookies). - **410 handling**: **`410 ≠ data loss`**. It means token expired; session data is intact. Redirect to hosted login and include `session_token=` to renew. - **404 key reads**: `404` from `GET /api/v1/sessions/get/` means “not written yet” → return a default, do not throw. - **Logout for browsers**: for web apps (especially localhost/cross-site), use **redirect logout** (top-level navigation), not XHR: - `GET /api/v1/auth/logout?session_token=...&redirect_uri=...` - **Revoke is destructive**: never call `POST /api/v1/sessions/revoke` on normal sign out. - **redirect_uri**: hardcoded allowlist match + encoded login URL query (see **Agent implementation checklist** in SKILL.md — `{{PART-01d-agent-checklist}}`). - **401 / 410**: full-page hosted login with `session_token=` to renew; never retry the dead token (checklist: **Expired session token** in `{{PART-01d-agent-checklist}}`). - **Documentation questions**: search before guessing — `GET /api/v1/docs/search?q=...` (see SKILL.md **Documentation search**). ### If the canonical SKILL.md is truncated in your AI tool Fetch the small index (it contains this MUST HAVE block and links to the shards): - `https://www.secure-flows.com/ai/secureflows-integration/SKILL.index.md` ## Do this if this file is truncated If this file is truncated by your AI tool, you MUST fetch it part by part. Use index: - `https://www.secure-flows.com/ai/secureflows-integration/SKILL.index.md` Authoritative OpenAPI specs: - Session API (ai-safe): `/docs/openapi/session/secure-flows-session-api.yaml` - Management API (setup): `/docs/openapi/user/secure-flows-user-api.yaml` - Integration/Auth: `/docs/openapi/integration-auth.yaml` - Documentation search (ai-safe): `/docs/openapi/docs/secure-flows-docs-api.yaml` ## Documentation search (agents) When SKILL.md or the OpenAPI specs above do not answer your question, **search public docs** before guessing. ```bash curl -sS "https://www.secure-flows.com/api/v1/docs/search?q=&limit=8" ``` - **No authentication.** Use the JSON API — do **not** scrape `/docs/search/` HTML. - **Workflow:** read `hits[]` → open each hit's **`url`** for full text (often `/docs/read/?path=...`). - **On `503`:** wait **5 seconds**, retry (up to **2 retries**, **3 attempts total**), then fall back to SKILL shards + OpenAPI YAML URLs. - **Do not** loop search requests; cache answers in your context for the task. **Optional — CI-validated end-to-end examples (Postman):** You do **not** need this to integrate an app. Use it only when you want concrete request sequences and folder structure beyond the OpenAPI specs. **Reference only** — running the collection requires your own Firebase Web API key and test account credentials (not published by secureFlows). - Guide: `https://www.secure-flows.com/docs/examples/index.html` - Collection download: `https://www.secure-flows.com/docs/examples/secureFlows-sanity.postman_collection.json` --- ## What is secureFlows? secureFlows is a private user data storage service with end-to-end security isolation built in by design. Its primary purpose is to let applications store per-user data in a way that is private, encrypted, and fully isolated — with no risk of cross-user or cross-tenant leakage, and no ability for anyone other than the user themselves to read that data. **Core guarantees:** - **True data isolation** — every session is bound to exactly one user and one workspace, enforced server-side. Cross-user and cross-tenant data access is architecturally impossible, not just policy-restricted. - **End-to-end encryption** — session payload is encrypted at rest with workspace-level keys. The encrypted data is never readable by other tenants. The database and backups store only ciphertext — raw storage access yields nothing readable. Workspace admins and owners access payload through the API, where role-based access control and audit logging apply. - **User data sovereignty** — session payload is encrypted at rest — the cryptographic guarantee applies to the storage layer (database, backups, raw disk). Workspace admins and owners can read payload content through the API; all such access is logged. Cross-tenant access remains architecturally impossible regardless of role. - **Full session lifecycle management** — create, read, write, and delete individual payload keys; revoke sessions on demand. Session data is never extended or renewed — once created, its lifetime is governed by the TTL policy set at the workspace level, not by the application. - **Short-lived tokens, long-lived sessions** — session tokens expire quickly, bounding the blast radius of any token compromise. The underlying session and its data survive token expiry intact; re-authentication issues a fresh token for the same session. Token renewal is a credential operation only — it does not extend session data lifetime. - **Configurable TTL and revocation** — workspace admins set session TTL policy per application. Users can revoke any of their own sessions at any time via the self-service dashboard. - **Self-service user dashboard** — users have full visibility into all their active sessions, can inspect their own data, and can revoke any session they believe may be compromised. No developer code required for this feature. - **Workspace audit log and analytics** — workspace managers have access to session activity logs and analytics: session counts, creation and expiry patterns, per-app breakdowns, and revocation events. Payload content is not included in session activity logs or analytics views, but admin reads of session payload via the API are logged in the audit log. - **Hosted login** — authentication is handled entirely by secureFlows through a proprietary hosted UI. The underlying identity mechanism is internal and not exposed to developers or users. ## What secureFlows Is Good For Use secureFlows any time the app needs to store private, per-user data: - **User profiles** — name, avatar, preferences - **App settings** — theme, language, notification preferences - **Form state** — persist a multi-step form across sessions - **Sensitive user-generated content** — notes, documents, personal records - **Any data that must be isolated per user** — isolation is enforced server-side; it cannot leak between users by design Do NOT use secureFlows for shared or public data (e.g. a product catalogue, a public leaderboard). It is a per-user private storage layer, not a general database. --- {{PART-01b-hosted-login}} ## The Two Phases — Read Before Generating Any Code secureFlows has two completely separate phases. Mixing them up is the most common mistake. ``` ┌──────────────────────────────────────────────────────────────────┐ │ PHASE 1 — One-time admin setup (Workspace Dashboard) │ │ │ │ Who: workspace owner, once, before any app code │ │ Output: SECUREFLOWS_WORKSPACE + SECUREFLOWS_APP_ID │ │ ⚠️ DO NOT generate Phase 1 API calls in application logic │ └────────────────────────────┬─────────────────────────────────────┘ │ produces config constants ▼ ┌──────────────────────────────────────────────────────────────────┐ │ PHASE 2 — Runtime session usage (Session API) │ │ │ │ Who: your app + end users, every session │ │ Input: SECUREFLOWS_WORKSPACE + SECUREFLOWS_APP_ID │ │ ✅ THIS is what you implement │ └──────────────────────────────────────────────────────────────────┘ ``` > If a developer asks you to implement workspace creation or app registration in code, > stop and tell them: **this is a one-time admin step done in the Workspace Dashboard**. > Direct them to: **https://www.secure-flows.com/app/workspaces** > Then ask for their `SECUREFLOWS_WORKSPACE` and `SECUREFLOWS_APP_ID` values. --- ## Phase 1 — Admin Setup (No Code Required) Done once by the workspace owner at **https://www.secure-flows.com/app/workspaces**: 1. Create a **workspace** — the container for all your app's users and their private data. Every user session belongs to exactly one workspace. Users in one workspace can never access data from another. 2. Register an **application** → produces an `appId` and a `redirectUris` allowlist. Any redirect URI not on this list is rejected at runtime (open-redirect protection). Store the output as **hardcoded constants** in your application. These values **never come from client input** and are never read from environment variables at runtime: ``` SECUREFLOWS_ORIGIN https://www.secure-flows.com ← always this exact value SECUREFLOWS_WORKSPACE ← hardcode the actual value SECUREFLOWS_APP_ID ← hardcode the actual value ``` All three are constants. None of them are environment variables. If the prompt does not supply `SECUREFLOWS_WORKSPACE` and `SECUREFLOWS_APP_ID`, do not guess or use placeholders — stop and tell the developer to obtain them from the dashboard first (see the callout above). --- ## Phase 2 — Runtime Integration (What You Implement) ### Constants and Config All three values are hardcoded constants. None of them are environment variables. The values for `SECUREFLOWS_APP_ID` and `SECUREFLOWS_WORKSPACE` come from the dashboard (Phase 1) and are supplied by the developer in the prompt. If they are not in the prompt, stop and ask for them before generating any code. Direct the developer to: **https://www.secure-flows.com/app/workspaces** to create a workspace and register an application, then come back with the resulting `SECUREFLOWS_WORKSPACE` and `SECUREFLOWS_APP_ID` values. **JavaScript / React:** ```javascript // All three are hardcoded constants — none are environment variables. const SECUREFLOWS_ORIGIN = "https://www.secure-flows.com"; // always this exact value const SECUREFLOWS_APP_ID = "your-app-id"; // value from Phase 1 dashboard const SECUREFLOWS_WORKSPACE = "your-workspace-name"; // value from Phase 1 dashboard ``` **Flutter:** ```dart // All three are hardcoded constants — none are environment variables or dart-defines. const secureFlowsOrigin = "https://www.secure-flows.com"; // always this exact value const secureFlowsAppId = "your-app-id"; // value from Phase 1 dashboard const secureFlowsWorkspace = "your-workspace-name"; // value from Phase 1 dashboard ``` --- ### Token Model There is exactly **one token type** in application code: `sessionToken`. Do not reference Firebase tokens, USER JWTs, or any other internal token — they are implementation details that are never exposed to application developers. | Concept | Lifetime | On expiry | |---|---|---| | **Session** (the data) | Long-lived | Survives token expiry — nothing is lost | | **sessionToken** (the credential) | Short-lived | HTTP 410 → re-login for a fresh token | **Key rule: `410 ≠ data loss`.** The session and its encrypted payload survive token expiry. Re-authenticate to get a fresh token that resumes the same session. **Exception:** A `410` that immediately follows an explicit `POST /api/v1/sessions/revoke` means the session was intentionally destroyed. Clear local token state and redirect to login. --- ### Step 0 — App Load On app load, check whether a `sessionToken` exists in memory (never in localStorage). - **No token** → immediately redirect to the secureFlows hosted login URL (see Step 1). Do NOT render any screen first. Do NOT call any session API first. - **Token exists** → go directly to the main screen and load session data (Step 2). There is no in-app login screen. The app has exactly two states: authenticated (token in memory) and unauthenticated (no token → redirect). Never attempt a session API call without a token. There is no guest or anonymous mode. **React — token in Context (safe pattern):** ⚠️ **React Router warning (prevents infinite hosted-login loops):** Do **not** put “redirect if no token” logic inside a Provider that wraps ``. The Provider’s `useEffect` runs before the Router has rendered and before the `/callback` route has a chance to extract and store the token — causing a redirect race and an infinite login loop. Use a “dumb” Provider (state only), and put redirect logic in a guard component rendered **inside** the Router. The `/callback` route must be completely unguarded. ⚠️ **React Rules of Hooks:** all hooks (`useEffect`, `useState`, etc.) must run in the same order on every render. Do not place an early `return` before a hook; instead compute a boolean like `isCallback` and gate redirect logic *inside* the effect. ```jsx // SessionProvider.jsx — state only (no redirects, no pathname checks) import * as React from "react"; const SessionContext = React.createContext(null); export function SessionProvider({ children }) { const [sessionToken, setSessionToken] = React.useState(null); return ( {children} ); } export function useSession() { return React.useContext(SessionContext); } ``` **Flutter:** ```dart // In initState or app load — check token before rendering any screen @override void initState() { super.initState(); if (sessionToken == null) { WidgetsBinding.instance.addPostFrameCallback((_) { redirectToHostedLogin(); }); } } ``` --- ### Step 1 — Hosted Authentication When no token is present, redirect immediately — no intermediate screen, no button to press. **JavaScript / React:** ```javascript function redirectToHostedLogin({ expiredToken } = {}) { const params = new URLSearchParams({ app_id: SECUREFLOWS_APP_ID, workspace_name: SECUREFLOWS_WORKSPACE, redirect_uri: "https://myapp.com/callback", }); // If renewing an expired token, pass it — do NOT also pass payload if (expiredToken) { params.set("session_token", expiredToken); } // Full-page redirect — never use fetch/XHR here window.location.href = `${SECUREFLOWS_ORIGIN}/app/sessions/login?${params}`; } ``` **Flutter:** ```dart Future redirectToHostedLogin({String? expiredToken}) async { final params = { 'app_id': secureFlowsAppId, 'workspace_name': secureFlowsWorkspace, 'redirect_uri': 'https://myapp.com/callback', }; if (expiredToken != null) { params['session_token'] = expiredToken; } final uri = Uri.parse('$secureFlowsOrigin/app/sessions/login') .replace(queryParameters: params); await launchUrl(uri, webOnlyWindowName: '_self'); } ``` **Do not:** - Build any login UI in the app — not a form, not a "Sign In" button screen, nothing - Use a WebView or iframe to embed the hosted login page — it must be a full browser redirect - Navigate to an in-app route first, then redirect from there — the redirect fires directly from app load or wherever the 401/410 is caught - Show any intermediate "redirecting..." screen before the redirect fires #### What happens after sign-in secureFlows handles the sign-in UI entirely. On success it redirects to your callback: ``` https://myapp.com/callback?sessionToken=eyJhbGci...SESSION ``` Your app reads `sessionToken` from the query parameter, stores it **in memory only**, then navigates to the main screen. The login flow is complete. --- {{PART-01c-callback}} ## Hosted login callback (SPA routing requirements) Hosted login returns users to your app via a **callback URL path** (commonly `/callback`) with a `sessionToken` in the query string. Requirements: - **Callback route must be resolvable on first load**: configure your hosting and router so `/callback` serves the app shell even on a hard refresh or direct navigation. - **Do not strip query params**: preserve `?sessionToken=...` exactly when routing or rewriting. - **Do not include a hash fragment in `redirect_uri`**: ensure the `redirect_uri` you register in the dashboard and send to hosted login has an empty fragment (no `#...`). **React — callback handler (React Router example):** ```jsx // /callback route component import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { useSession } from "./SessionProvider"; export function CallbackPage() { const { setSessionToken } = useSession(); const navigate = useNavigate(); useEffect(() => { const params = new URLSearchParams(window.location.search); const token = params.get("sessionToken"); if (token) { setSessionToken(token); // store in memory via Context — never localStorage navigate("/", { replace: true }); } else { // No token — treat as unauthenticated, go back to login redirectToHostedLogin(); } }, []); return null; // render nothing while processing } ``` **React Router guard (put redirect logic inside ``):** ```jsx import { useEffect } from "react"; import { useLocation } from "react-router-dom"; import { useSession } from "./SessionProvider"; export function RequireAuth({ children }) { const { sessionToken } = useSession(); const location = useLocation(); const isCallback = location.pathname === "/callback"; useEffect(() => { // Never guard the callback route. // React Rules of Hooks: do not put an early return before hooks. if (!isCallback && !sessionToken) { redirectToHostedLogin(); } }, [isCallback, sessionToken]); if (isCallback) return children; return sessionToken ? children : null; } ``` **Quick verification**: add a one-time log in the callback handler that prints the full URL and confirms `sessionToken` is present before navigating to the main screen: ```javascript console.log("Callback URL:", window.location.href); console.log("Token present:", !!params.get("sessionToken")); ``` **Symptom of misconfiguration**: the browser shows `/callback?sessionToken=...`, but the app behaves as if it loaded `/` (token never captured) and immediately redirects back to hosted login again (infinite loop). **Redirect URI must match**: the exact callback URL (including scheme/host/port/path) must be allowlisted in the workspace dashboard. Mismatches often present as callback/loop issues. --- ### Flutter Web callback implementation Flutter Web commonly uses **hash-based routing** so the callback may arrive as `http://localhost:5173/#/callback?sessionToken=...` instead of `http://localhost:5173/callback?sessionToken=...`. In that case: - `Uri.base.queryParameters['sessionToken']` may be empty - The token must be parsed from the URL fragment (everything after `#`) - Your callback handler must accept both shapes to avoid infinite login loops **Flutter Web callback checklist:** - **Detect callback navigation** on first load even when hash routing is enabled: - Treat `path == '/callback'` as callback - Also treat `fragment == '/callback'` or `fragment.startsWith('/callback?')` as callback - **Extract `sessionToken` from either location**: - `Uri.base.queryParameters['sessionToken']` (path routing) - Parse the fragment query when it looks like `/callback?...` (hash routing) - **Build `redirect_uri` with an empty fragment** (no `#`): - In Flutter: `Uri.base.replace(path: '/callback', queryParameters: {}, fragment: '')` #### Token renewal (expired token) When a `sessionToken` expires (HTTP 410), session data is still intact on the server. Redirect to hosted login and include the **expired** token as `session_token`. The callback returns a fresh token pointing to the same session and data. **JavaScript / React:** ```javascript // In your 410 handler — pass the expired token to hosted login if (res.status === 410 || res.status === 401) { redirectToHostedLogin({ expiredToken: sessionToken }); } ``` **Flutter:** ```dart if (response.statusCode == 410 || response.statusCode == 401) { await redirectToHostedLogin(expiredToken: sessionToken); } ``` --- {{PART-01d-agent-checklist}} ## Agent implementation checklist (MUST follow) Before marking integration complete, verify **all** of the following in generated code. Applies to **Flutter Web, React, and browser JS** unless a bullet says otherwise. ### Documentation lookup (agents) - [ ] Used **`GET /api/v1/docs/search`** for integration details, error semantics, or examples **not** explicit in SKILL.md (then opened hit `url` for full text). On `503`, waited 5s and retried up to 2 times before falling back to OpenAPI YAML. Do **not** infer `redirect_uri` from `Uri.base`, `window.location.origin`, or runtime origin when the prompt specifies host/port/path (see also **Preview/iframe environments** under Sign Out). ### redirect_uri (most common failure — all web clients) - [ ] `redirect_uri` is a **hardcoded constant** that exactly matches a dashboard allowlist entry (e.g. `http://localhost:8084/callback`). Do **not** derive it from `Uri.base` / `window.location` unless you normalize and assert equality with the allowlisted value. - [ ] Login URL is built with **encoded** query parameters. The browser address bar for the **hosted login** URL must show `redirect_uri=http%3A%2F%2F...`, **not** raw `redirect_uri=http://...` in the outer query string. - [ ] **Red flag:** hosted login URL ends with `callback?` or `callback?#` — unencoded `redirect_uri` or broken query parsing. Stop and fix encoding before testing sign-in. - [ ] **React / JS:** build params with `URLSearchParams` (or encode each value with `encodeURIComponent`); navigate with `window.location.assign(url)` using one encoded query string. Do not hand-concatenate `?redirect_uri=http://...` into another URL’s query. - [ ] **Flutter Web:** build the login `Uri` with `queryParameters` and use `uri.toString()` for navigation — do not concatenate raw `http://` values into query strings. - [ ] `localhost` and `127.0.0.1` are different hosts — if the prompt says `localhost`, both the allowlist and the constant must use `localhost`. ### React / JavaScript Web navigation - [ ] Callback route (e.g. `/callback`) is **unguarded** — no “redirect if no token” runs before the handler reads `sessionToken`. - [ ] Auth redirect guards run **inside** the router (``), not in a Provider that wraps `` (prevents callback race / infinite login loop). - [ ] Callback reads `sessionToken` from `window.location.search` before any redirect-to-login logic. - [ ] After storing the token, clean the URL (`navigate("/", { replace: true })` or `history.replaceState`). ### Flutter Web navigation - [ ] Call `usePathUrlStrategy()` before `runApp()` so `/callback?sessionToken=...` works on hard refresh and direct navigation. - [ ] On web, redirect to hosted login with `window.location.assign(url)` (`package:web`) or equivalent. Do **not** rely on `url_launcher` for the **initial** hosted-login redirect — it can produce malformed outer URLs on some platforms. - [ ] Callback handler reads `sessionToken` from **both** path query (`/callback?...`) and hash (`#/callback?...`) before any “redirect if no token” logic runs. - [ ] After capturing the token, strip query params from the URL (`history.replaceState` via `web`). ### Expired session token (`401` / `410` — all clients) When any Session API call returns **`401`** or **`410`**, the credential is dead — **re-login via hosted login** (full-page redirect). Session **data is not lost** (`410 ≠ data loss`). - [ ] A **central handler** (fetch wrapper / HTTP interceptor / shared API helper) catches `401` and `410` on session endpoints — not only one screen. - [ ] Handler calls the same **`redirectToHostedLogin`** used on cold start (no in-app login route, no `fetch` to hosted login, no retry with the same token). - [ ] Redirect includes the **expired** in-memory token as `session_token` so hosted login renews the same session (see **Token renewal** above). Do **not** send `payload` on that redirect. - [ ] After callback, the app stores the new `sessionToken` **in memory only** and resumes the main flow (re-fetch data if needed). - [ ] Handler does **not** clear user payload / local app state as if data were deleted (unless the `410` immediately follows an explicit `POST /api/v1/sessions/revoke`). ### Do not blame the dashboard first If sign-in fails with “Could not complete sign-in redirect”, assume an **implementation bug** until proven otherwise when: - allowlisted `redirect_uri` is documented in the prompt, **and** - `app_id` / `workspace_name` match the prompt constants. Verify the generated login URL encoding and callback route **before** asking the user to change dashboard settings. --- {{PART-02-session-api}} ### Step 2 — Session CRUD All calls use `Authorization: Bearer `. **Never include `userId`, `workspaceId`, or any identity fields in request bodies.** Identity is always resolved server-side from the token. --- #### Read the full session payload ```http GET /api/v1/sessions Authorization: Bearer ``` Response `200`: ```json { "note:shopping": "Milk, eggs, bread", "theme": "dark" } ``` > The response is a flat JSON object — keys are payload keys, values are payload values. > No wrapper. An empty session returns `{}`. Iterate `Object.entries(response)` to access > all keys. --- #### Read a single key ```http GET /api/v1/sessions/get/note:shopping Authorization: Bearer ``` Response `200`: ```json { "key": "note:shopping", "value": "Milk, eggs, bread" } ``` > Parse as `response.value`. A `404` means the key has never been written — return a > default value, do not throw. --- #### Write a key ```http POST /api/v1/sessions/set/note:shopping Authorization: Bearer Content-Type: application/json { "value": "Milk, eggs, bread, butter" } ``` Response `200` — returns the full updated payload (same flat shape as GET): ```json { "note:shopping": "Milk, eggs, bread, butter", "theme": "dark" } ``` > Always wrap the value in `{ "value": ... }`. The value can be any JSON type: string, > number, boolean, array, or object. Response is the same flat object as GET /sessions. --- #### Delete a key ```http DELETE /api/v1/sessions/delete/note:shopping Authorization: Bearer ``` Response `200`: ```json true ``` > Returns `true` if the key existed and was deleted, `false` if the key was not present. > Response is a raw boolean, not wrapped in an object. --- #### Revoke the session ```http POST /api/v1/sessions/revoke Authorization: Bearer ``` Response `200`: ```json { "revoked": true, "sessionId": 123 } ``` > After revoke: clear the token from memory and redirect to hosted login. > Do NOT reuse the token — any subsequent request with it will return `410`. --- #### Create or reuse a session (`POST /sessions` vs `get-or-create`) `POST /api/v1/sessions` is **not idempotent** — it always creates a new session row, even when an active session already exists for the same user and app. To **retrieve an existing session** instead of creating a duplicate, use `POST /api/v1/sessions/get-or-create` with the **same request body** as `POST /api/v1/sessions`. Authorization is the same: `Bearer `. Request body fields: - `workspaceName` (required) - `app_id` (optional) - `payload` (optional) — arbitrary JSON object; **omit** or send `{}` for an empty session. When omitted, the server treats it as `{}`. ```http POST /api/v1/sessions/get-or-create Authorization: Bearer Content-Type: application/json { "workspaceName": "demo-workspace", "app_id": "my-app-id" } ``` > Same body shape for `POST /api/v1/sessions` when you intentionally create a new session. - If an **active** session already exists for this Firebase UID + `app_id` + workspace, the server returns the **existing** `sessionToken` unchanged. - If **no** active session exists, a new one is created. **Default on login:** use `get-or-create`. Use plain `POST /api/v1/sessions` only when you explicitly need a **new** session regardless of existing ones. > Browser apps using **hosted login** normally receive `sessionToken` on the callback URL; the > hosted flow already reuses active sessions server-side. This section applies when your code calls > the Session API directly with a Firebase ID token (automation, custom backends, tests). --- ### Step 3 — Handle Errors Correctly **JavaScript / React:** ```javascript async function getSessionKey(key, sessionToken) { const res = await fetch( `${SECUREFLOWS_ORIGIN}/api/v1/sessions/get/${key}`, { headers: { Authorization: `Bearer ${sessionToken}` } } ); if (res.status === 404) { // Key does not exist yet — normal on first use. Return null or a default value. return null; } if (res.status === 410 || res.status === 401) { // Token expired or invalid — session data is NOT lost. // Redirect directly to hosted login. Do not navigate to an in-app login route first. redirectToHostedLogin({ expiredToken: sessionToken }); return; } return res.json(); } ``` **Flutter:** ```dart Future getSessionKey(String key, String sessionToken) async { final res = await http.get( Uri.parse('$secureFlowsOrigin/api/v1/sessions/get/$key'), headers: {'Authorization': 'Bearer $sessionToken'}, ); if (res.statusCode == 404) return null; if (res.statusCode == 410 || res.statusCode == 401) { await redirectToHostedLogin(expiredToken: sessionToken); return; } return jsonDecode(res.body); } ``` **404 on a key read = key not written yet.** Return a sensible default (null, empty string, empty array) and continue normally. Never throw or surface this as a user-visible error. --- ### JS SDK (Preferred for Browser Apps) ```bash npm install secureflows-js ``` ```javascript import { SecureFlows } from "secureflows-js"; // All three are hardcoded constants — none are environment variables. const SECUREFLOWS_ORIGIN = "https://www.secure-flows.com"; const SECUREFLOWS_APP_ID = "your-app-id"; // value from Phase 1 dashboard const SECUREFLOWS_WORKSPACE = "your-workspace-name"; // value from Phase 1 dashboard const sf = new SecureFlows({ origin: SECUREFLOWS_ORIGIN, appId: SECUREFLOWS_APP_ID, workspace: SECUREFLOWS_WORKSPACE, }); // First call: redirects to hosted login (Promise never resolves — it's a full redirect). // On return from hosted login: resolves with the session. const session = await sf.login(); const sessionToken = sf.getToken(); ``` > Ensure your callback URL matches a `redirectUri` registered in the dashboard (Phase 1). --- ## Choosing Session Keys Store one logical value per key. The value can be any JSON type. ``` user_profile → { "name": "Ada", "avatar": "...", "lang": "en" } theme → "dark" onboarded → true ``` When an item has a natural unique identifier (a name, a slug, a note title), use it directly as the payload key. Read on load, write on change. --- {{PART-03-logout}} ## Sign Out Sign out has two distinct operations. Do not confuse them: | Operation | What it does | When to use | |---|---|---| | **Logout** (redirect) `GET /api/v1/auth/logout?session_token=...&redirect_uri=...` | Invalidates the `sessionToken`, revokes Firebase refresh tokens, clears the hosted-login cookie via `Clear-Site-Data` in a **first-party context**, then redirects back to your app | **Always use this for browser apps** (React, Flutter Web, plain JS). The only reliable logout for cross-origin apps | | **Logout** (programmatic) `POST /api/v1/auth/logout` | Same invalidation, but via XHR/fetch — `Clear-Site-Data` is ignored by browsers in cross-site requests, so the hosted-login cookie may survive | Use only for non-browser clients (server-side, CLI, native mobile) | | **Revoke** `POST /api/v1/sessions/revoke` | Permanently destroys the session and all its data | Only on explicit user request: account deletion, full data wipe | **Never call revoke on sign out. Revoke destroys data permanently.** ### Why `fetch`/XHR logout silently fails in browser apps When your app runs on a different origin from `secure-flows.com` (e.g. `http://localhost:5173`, `https://myapp.com`), calling `POST /api/v1/auth/logout` via `fetch` or `http.post` is a **cross-site request**. The server responds `200` and sets `Clear-Site-Data: "cookies"`, but browsers **silently ignore** that header on cross-site responses. The hosted-login cookie on `secure-flows.com` survives, so the next redirect to hosted login silently re-authenticates the user — no credentials prompt, appears "still logged in." **Fix:** use the **redirect logout** — a top-level browser navigation to `GET /api/v1/auth/logout?session_token=...&redirect_uri=...`. This runs entirely on `secure-flows.com` in a **first-party context**, so `Clear-Site-Data` fires correctly, then the server redirects the browser back to your app. ### Logout flow (recommended for all browser apps) **Browser apps (including AI-generated / low-code apps):** do a **top-level navigation** to the redirect logout URL. Do **not** call `POST /api/v1/auth/logout` via fetch/XHR—cookies won’t reliably clear cross-site and the user will silently re-authenticate. ⚠️ **If you use a `RequireAuth`-style guard that redirects when `sessionToken` is null:** do **not** clear `sessionToken` *before* the logout navigation. Clearing first can re-render the guard, which redirects to hosted login before the logout navigation commits. The hosted-login cookie is still valid → silent re-auth → “sign out doesn’t work.” **JavaScript (plain JS or any framework):** ```javascript // SECUREFLOWS_ORIGIN is always a hardcoded constant — never an env variable. const SECUREFLOWS_ORIGIN = "https://www.secure-flows.com"; function logout(sessionToken) { // 1. Build the redirect logout URL. const url = new URL("/api/v1/auth/logout", SECUREFLOWS_ORIGIN); url.searchParams.set("session_token", sessionToken); url.searchParams.set("redirect_uri", "https://myapp.com/"); // ↑ must be allowlisted in the dashboard // IMPORTANT: do NOT send users to /callback after logout (see below). // 2. Top-level navigation — this is what makes cookie clearing work. // NEVER use fetch() or XMLHttpRequest here. window.location.assign(url.toString()); } ``` **React (with Context):** ```jsx // In your SessionProvider — the logout function lives where the token lives export function SessionProvider({ children }) { const [sessionToken, setSessionToken] = React.useState(null); function logout() { const token = sessionToken; // 1. Redirect logout — top-level navigation, not fetch. // Navigate FIRST; the page is unloading and nothing else should race this. const url = new URL("/api/v1/auth/logout", SECUREFLOWS_ORIGIN); url.searchParams.set("session_token", token); url.searchParams.set("redirect_uri", "https://myapp.com/"); window.location.assign(url.toString()); } return ( {children} ); } // In any component — consume logout from Context function Header() { const { logout } = useSession(); return ; } ``` **Flutter Web:** ```dart Future logout(String sessionToken) async { // 1. Build redirect logout URL final url = Uri.parse('$secureFlowsOrigin/api/v1/auth/logout').replace( queryParameters: { 'session_token': sessionToken, 'redirect_uri': 'https://myapp.com/', // ↑ must be allowlisted in the dashboard // IMPORTANT: do NOT send users to /callback after logout (see below). }, ); // 2. Top-level navigation — NOT http.post await launchUrl(url, webOnlyWindowName: '_self'); } ``` ### What the app sees after redirect logout returns After the logout redirect, `secure-flows.com` sends the browser back to your `redirect_uri`. At this point your app loads fresh — there is no `sessionToken` in the URL (unlike the login callback). The app must treat this as an unauthenticated state. **Important:** The post-logout `redirect_uri` should be a **non-callback route** (commonly `/`). If you redirect to `/callback` without a `sessionToken`, many SPA callback handlers will treat it as a failed login return and immediately call `redirectToHostedLogin()` again, causing confusing loops. **Critical: the `redirect_uri` after logout must NOT contain `session_token`.** If it does, hosted login silently renews the old session — the user is never prompted for credentials, defeating the purpose of sign-out. ```javascript // ✅ Correct — redirect_uri has no session_token url.searchParams.set("redirect_uri", "https://myapp.com/"); // ❌ Wrong — silently renews, user is never prompted for credentials url.searchParams.set("redirect_uri", "https://myapp.com/callback?session_token=" + oldToken); ``` ### Preview/iframe environments (Base44, embedded previews) ⚠️ Do **not** compute `redirect_uri` from `window.location.origin` at runtime in environments where your app may be loaded inside an iframe or on a preview domain. In those cases `window.location.origin` may not match your published app URL, and secureFlows will reject the `redirect_uri` unless it exactly matches the allowlist. **Rule:** treat the app origin as a **canonical hardcoded constant** (the published URL you registered in the dashboard), and build both: - login `redirect_uri`: `https://YOUR_APP_ORIGIN/callback` - logout `redirect_uri`: `https://YOUR_APP_ORIGIN/` ### Logout flow (programmatic — non-browser clients only) Use this only when your client is not a browser (server-side, CLI, native mobile without a WebView), or when running first-party on `secure-flows.com`. **JavaScript:** ```javascript async function logoutProgrammatic(sessionToken) { await fetch(`${SECUREFLOWS_ORIGIN}/api/v1/auth/logout`, { method: "POST", headers: { Authorization: `Bearer ${sessionToken}` }, }); // Clear token from memory — then handle redirect manually if needed clearSessionToken(); } ``` **Flutter (native mobile — not Flutter Web):** ```dart Future logoutProgrammatic(String sessionToken) async { await http.post( Uri.parse('$secureFlowsOrigin/api/v1/auth/logout'), headers: {'Authorization': 'Bearer $sessionToken'}, ); this.sessionToken = null; } ``` After a successful programmatic logout, do NOT include `session_token` in the post-logout redirect URL to hosted login. Including it tells hosted login to silently renew the session — no credentials prompt, defeating sign-out. ### What logout does NOT do - Does not delete the session or any user data - Does not affect other active sessions for the same user - Session data is fully intact — the user resumes it on next login To permanently destroy a session and its data, use `POST /api/v1/sessions/revoke` (see Step 2). Only do this if the user explicitly requests account deletion or a full data wipe. --- ## User Self-Service Dashboard End users can view and manage their own sessions at: **https://www.secure-flows.com/app/self** Users can see all their active sessions, view session data, and revoke sessions themselves. This is a built-in feature — no code required. Workspace owners and admins can see session metadata (created at, expires at, which app) and session payload content. Payload data is encrypted at rest — the encryption protects against raw storage exposure, not against authorized API access. All admin payload reads are logged. --- ## Backend Pattern — Every Protected Endpoint When the app has a server-side layer, apply this to every protected route: 1. Extract `sessionToken` from `Authorization: Bearer ` 2. Call `GET /api/v1/sessions` — identity is resolved server-side from the token 3. Enforce access control using the resolved identity 4. Execute the operation scoped to that user and workspace 5. **Never read identity from the request body or query parameters** > For React and JS **client** apps there is typically no backend — session calls go > directly from the client to the secureFlows Session API using the `sessionToken` held > in memory (Context state). The same applies to Flutter client apps. --- {{PART-04-errors}} ## Error Reference | Status | Meaning | Correct action | |---|---|---| | `401` | Missing or invalid token | Redirect directly to hosted login | | `404` on a key read | Key not written yet | Return default value — not an error | | `404` on session | Session not found | Treat as logged out, redirect to hosted login | | `410` | Token expired | Redirect directly to hosted login — session data intact | | `410` after `/revoke` | Session intentionally destroyed | Clear token, redirect to hosted login | | `400` on callback | Redirect URI not allowlisted | Config error — check dashboard registration | | `401` on `/auth/logout` | Token already invalid | Clear token from memory, redirect to hosted login | **"Redirect to hosted login" always means a full-page redirect to the hosted login URL (Step 1). Never means navigate to an in-app `/login` route.** --- {{PART-05-anti-patterns}} ## Anti-patterns — Never Generate These ```javascript // ❌ Any of the three constants read from environment variables const origin = process.env.SECUREFLOWS_ORIGIN; // WRONG — always hardcode it const appId = process.env.SECUREFLOWS_APP_ID; // WRONG — hardcode the dashboard value const workspace = process.env.SECUREFLOWS_WORKSPACE; // WRONG — hardcode the dashboard value // ❌ Token in localStorage or any persistent client storage localStorage.setItem("sessionToken", token); // WRONG — memory only // ❌ Any in-app login screen, login route, or "Sign In" button // WRONG — there is no login UI in the app; redirect to hosted login immediately when no token renderLoginScreen(); // skip this entirely // ❌ WebView or iframe for hosted login // WRONG — use window.location.href / launchUrl for a full browser redirect