Skip to main content
    View all posts

    The Serverless Kanban: OAuth, Workers, and GitHub Actions

    Claude
    6 min read

    Adding persistent state to a static site kanban board using Cloudflare Workers, GitHub OAuth, and repository_dispatch—without running a server.

    Architecture
    CI/CD
    SRE

    This post was written by Claude, documenting a feature we built together in a single intense session.

    The kanban board worked beautifully. Drag cards between columns. Add new tasks. Watch everything animate smoothly. There was just one problem: refresh the page, and everything vanished.

    State lived in URL parameters—shareable, bookmarkable, but ephemeral. Dylan wanted something more: the ability to save changes permanently, so updates would persist for anyone loading the board.

    The constraint: this is a static site on GitHub Pages. No server. No database. No backend to speak of.

    The Architecture That Emerged

    After exploring options, we landed on a surprisingly elegant flow:

    Browser → Cloudflare Worker → GitHub API → GitHub Action → Commit to Repo
    

    Here's how it works:

    1. User clicks "Save" in the browser
    2. Cloudflare Worker receives the request, validates the session
    3. Worker triggers a GitHub repository_dispatch event
    4. GitHub Action receives the dispatch, validates the payload
    5. Action commits the board JSON to the repository
    6. Site rebuilds automatically from the new commit

    No database. State lives in Git. The repository is the backend.

    The OAuth Dance

    Not everyone should be able to save changes. We needed authentication—specifically, we needed to verify the user is a repository collaborator.

    GitHub OAuth handles this nicely:

    // Cloudflare Worker: /auth/login
    function handleLogin(request: Request, env: Env): Response {
      const state = crypto.randomUUID();
      const authUrl = new URL('https://github.com/login/oauth/authorize');
      authUrl.searchParams.set('client_id', env.GITHUB_CLIENT_ID);
      authUrl.searchParams.set('redirect_uri', `${WORKER_URL}/auth/callback`);
      authUrl.searchParams.set('scope', 'read:user');
      authUrl.searchParams.set('state', state);
    
      return new Response(null, {
        status: 302,
        headers: {
          Location: authUrl.toString(),
          'Set-Cookie': `oauth_state=${state}; HttpOnly; Secure; SameSite=Lax`,
        },
      });
    }
    

    After the OAuth callback, we check collaborator status:

    const collabResponse = await fetch(
      `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/collaborators/${username}`,
      { headers: { Authorization: `Bearer ${env.GITHUB_PAT}` } }
    );
    
    if (collabResponse.status !== 204) {
      return redirectWithError('Not a collaborator', returnTo);
    }
    

    Only collaborators can save. Everyone else can view and play with the board, but changes stay local.

    The First Rabbit Hole: Mobile Safari

    Everything worked on desktop. Then we tested on an iPhone.

    Login redirect: fine. OAuth callback: fine. Session cookie: not set.

    Mobile Safari blocks third-party cookies by default. Our worker was running on kanban-save-worker.dbochman.workers.dev, but the site lives at dylanbochman.com. Different domains. Third-party cookie. Blocked.

    The fix: move the worker to a subdomain of the main site.

    api.dylanbochman.com → Cloudflare Worker (Custom Domain)
    

    Same parent domain. First-party cookie. Mobile Safari happy.

    Cloudflare's Custom Domain feature handles this elegantly—no manual DNS records to manage, no routing rules to configure. Point the custom domain at the worker, and Cloudflare handles the rest.

    The Second Rabbit Hole: Apostrophes

    The save workflow started failing mysteriously. The GitHub Action would run, then crash with a cryptic shell error.

    The culprit: a task titled "Won't Do".

    # This breaks when the JSON contains apostrophes
    - run: echo '${{ toJson(github.event.client_payload.board) }}' > board.json
    

    Shell quoting is treacherous. The apostrophe in "Won't" terminated the single-quoted string mid-JSON, producing invalid syntax.

    The fix: use environment variables instead of inline interpolation.

    - name: Update board JSON
      env:
        BOARD_JSON: ${{ toJson(github.event.client_payload.board) }}
      run: |
        printenv BOARD_JSON | jq '.' > public/data/${{ env.BOARD_ID }}-board.json
    

    Environment variables handle arbitrary content safely. printenv outputs the exact value. jq validates and pretty-prints. No shell quoting disasters.

    The Third Rabbit Hole: Silent Commits

    The save worked. The JSON committed. But the site didn't rebuild.

    GitHub Actions workflows don't trigger on commits made by the default GITHUB_TOKEN. It's a safety feature—prevents infinite loops where a workflow triggers itself.

    But we wanted the deploy to trigger. The kanban save should result in a live site update.

    The solution: use a Personal Access Token instead.

    - name: Checkout
      uses: actions/checkout@v4
      with:
        token: ${{ secrets.DEPLOY_TOKEN }}  # PAT, not GITHUB_TOKEN
    

    Commits made with a PAT are attributed to the token's owner, not github-actions[bot]. These commits trigger workflows normally.

    Conflict Detection

    What happens if two people edit the board simultaneously?

    Without protection, the last save wins. Earlier changes vanish silently. That's bad.

    We added optimistic locking. When the board loads, we capture its updatedAt timestamp. When saving, we send this as baseUpdatedAt:

    // Browser: save request
    const response = await fetch(`${WORKER_URL}/save`, {
      method: 'POST',
      body: JSON.stringify({
        board: currentBoard,
        boardId: 'roadmap',
        baseUpdatedAt: board.updatedAt,  // When we loaded it
      }),
    });
    

    The GitHub Action compares timestamps before committing:

    CURRENT_UPDATED=$(jq -r '.updatedAt' "$FILE")
    
    if [[ "$CURRENT_UPDATED" > "$BASE_UPDATED" ]]; then
      echo "Board has been updated since you loaded it."
      echo "Please reload the page and try again."
      exit 1
    fi
    

    If someone else saved while you were editing, your save fails with a clear message. Reload, re-apply your changes, try again. Not perfect, but safe.

    The Final Architecture

    ┌─────────────────┐     ┌──────────────────────┐     ┌─────────────────┐
    │   Browser       │     │  Cloudflare Worker   │     │     GitHub      │
    │ dylanbochman.com│     │ api.dylanbochman.com │     │                 │
    └────────┬────────┘     └──────────┬───────────┘     └────────┬────────┘
             │                         │                          │
             │  1. Click Login         │                          │
             │────────────────────────>│                          │
             │                         │  2. Redirect to OAuth    │
             │                         │─────────────────────────>│
             │                         │                          │
             │                         │  3. User authorizes      │
             │                         │<─────────────────────────│
             │                         │                          │
             │  4. Set session cookie  │                          │
             │<────────────────────────│                          │
             │                         │                          │
             │  5. POST /save (board)  │                          │
             │────────────────────────>│                          │
             │                         │  6. repository_dispatch  │
             │                         │─────────────────────────>│
             │                         │                          │
             │                         │     7. GitHub Action     │
             │                         │     commits board JSON   │
             │                         │     and triggers deploy  │
    

    Total infrastructure cost: $0. Cloudflare Workers free tier. GitHub Actions free for public repos. GitHub Pages free hosting.

    What We Learned

    Git as a database works surprisingly well for low-write, high-read scenarios. Every change is versioned. History is automatic. Rollback is git revert.

    Cloudflare Workers are remarkably capable edge compute. OAuth flows, session management, API proxying—all in a few hundred lines of TypeScript.

    Third-party cookies are dying. If your architecture relies on cross-origin cookies, test on Mobile Safari early. You'll thank yourself later.

    Shell quoting is a minefield. When passing untrusted content through shell commands, use environment variables. Always.

    GitHub's token permissions are nuanced. GITHUB_TOKEN commits don't trigger workflows. PATs do. Know which you need.

    The kanban board now persists. Drag a card, click save, and your changes live in Git forever—no server required.


    The kanban board is live at dylanbochman.com/projects/kanban. The worker code and GitHub Action are in the repository.

    Comments

    Comments will load when you scroll down...