QIAOPANG.COM · SELECTED ESSAYS (2013–2026) · 260 ESSAYSBrowse Chronologically (2013 → 2026) ↗
QiaoPang · Programmers LifeSelected Essays · Systems, Craft & Agentic Era
Timeline ↗

The One-Person Company: Designing and Implementing Invitation-Based Access Control

The One-Person Company: Designing and Implementing Invitation-Based Access Control

I do not open the way for one who is not already straining toward it; I do not shape the words for one who cannot voice them. If I hold up one corner and he cannot return with the other three, I say no more. — Confucius, The Analects

Author’s note: This is the sixth post in the “One-Person Tech Company” series. The OPC (One Person Company) idea is having a moment, so I’m taking it for a spin — to see what actually comes of spending my weekends in earnest on the kind of work that normally takes a whole tech company to pull off. This does not mean I’m quitting to found a startup. I think of myself as a perpetual entrepreneur already on the road; nine-to-five has never been my cage. What I want out of this process is to understand the boundaries of AI — and my own.

The series chronicles the whole journey of building a complete technical system from scratch — from infrastructure, to networking and runtime, to the core framework, and on to application services. This is not a tutorial; it is a working notebook, with the thinking left in.

Over the first five posts we walked a long road: building out the security infrastructure, choosing a cloud architecture, forging a serverless framework, standing up a private package registry, and finally shipping our first product application, Slides. A complete technical system is now up and running. By rights this post should be the finale — but there is one question I have been deliberately dodging: the product exists, so who gets to use it? Let me postpone the retrospective to one more post and keep talking shop here (my apologies — I broke my word; I had promised five posts, neat and tidy as the Five Tiger Generals, but the writing kept gathering steam and the thing mutated into seven kids on a single vine).

Slides is currently wide open: anyone with a Google account can walk in and use it. That’s fine for a personal prototype, but if you’re serious about the product — even for a small group of seed users — you need a gate.

Not a paywall (that comes later), but something more basic: control over who gets in.

That’s what we’re talking about today: invitation-based access control. A system that sounds simple and is, in practice, full of potholes.

1. Why Invitation-Only?

You might think: a one-person company, a product that just launched, users you can count on two hands — why bother with invitations?

It’s precisely the opposite. The earlier the stage, the more valuable invitations become. Three reasons:

First, quality matters a hundred times more than quantity. What does an early-stage product fear most? Not the absence of users, but the arrival of the wrong users. They don’t grasp where your product is headed, they flood you with noisy feedback, and they consume energy you can barely spare. Invitations let you hand-pick your seed users, so that everyone who walks in is someone you genuinely want to serve.

Second, cost control. Every image Slides generates calls an AI service, at roughly 13 cents a pop. Wide open to the world, a curious passerby who idly generates a few dozen images can burn a cup of coffee’s worth of your money. A hundred such passersby and the end-of-month bill stops being funny. Invitations are the crudest, most effective cost firewall there is.

Third — and this is the deepest reason — the invitation system is itself the product. Scarcity breeds anticipation; those who are invited treasure a product more than those who simply sign themselves up. This isn’t a marketing trick; it’s human nature. Gmail grew on invitations, Clubhouse detonated on invitations — the playbook works, over and over.

But then the problem: today I have exactly one app, Slides. What about tomorrow? If every app rolls its own invitation logic, stuffing a pile of invite-code validation into the auth system, how is that any different from hardcoding database connection strings into business logic?

So, reasoning from first principles, the right move is: extract invitations into a standalone service.

2. Ligand: A Standalone Invitation Service

I named the service Ligand, continuing the biological naming tradition. In molecular biology, a ligand is a molecule that binds to a receptor and thereby triggers signal transduction. In our context, the invite code is the ligand: it binds to the user, triggers the activation signal, and opens the door to the app.

Design Principles

Ligand’s core design philosophy boils down to one word: decoupling.

First, decoupled from applications. Ligand doesn’t know what Slides is, and doesn’t care. It only knows that “there is an app called slides; it has a batch of invite codes and a group of users.” When I build my second product tomorrow, I register a new app in Ligand and immediately inherit the same invitation system.

Second, decoupled from authentication. Ligand does not handle logins. It exposes an API for the application’s auth flow to call. When a user logs in, the app’s auth callback adds one line, ligand.register_user(email); on activation, it calls ligand.activate_user(email, code). That’s all.

Third, decoupled from the frontend. Ligand is a pure REST API; the frontend logic (rendering the invite-code field, handling the activation response) belongs to each application. But we ship ready-made helpers in operon’s TypeScript client SDK, so frontend integration is a few lines of code too.

User Lifecycle

Ligand manages user state as a clean state machine:

The One-Person Company: Designing and Implementing Invitation-Based Access Control — architecture diagram

Registered → A user is auto-registered after OIDC login, but cannot yet use the app’s core functionality. In this state the user sees their profile page and an invite-code input field.

Active → The user enters a valid invite code and becomes active. This is the normal working state; every protected API endpoint lets them through.

Waitlisted → Users without a code can opt into the waitlist. Administrators can promote users from the waitlist in bulk.

Suspended → Administrators can suspend a user. A suspended user receives an explicit 403 response, and the frontend uses that to render the suspended page.

3. Designing the Invite Code: A Deceptively Simple Problem

What should an invite code look like? ABCD-EFGH? invite_123456? This seemingly trivial decision carries a surprising amount of thought.

Format Design

Invite codes use a configurable format template, defaulting to XXXX-XXXX, where each X is replaced by a random character drawn from a carefully chosen character set:

/// A 32-character unambiguous alphabet
/// Every easily confused character is stripped out: 0/O, 1/I/L, 8/B, 5/S, 2/Z
const ALPHABET: &[u8] = b"3479ACDEFGHJKMNPQRTUVWXY";

Why bother with a dedicated “unambiguous alphabet”? Because invite codes get passed around verbally, shared as screenshots, and typed by hand. If a code contains both 0 and O, the odds of a mistyped entry go through the roof, and customer-support pressure (even if the support desk is you) redlines instantly.

The format is configurable per application. Want shorter codes? XXXX. Want longer, stronger codes? XXXX-XXXX-XXXX. That flexibility comes free.

Generation Strategy

Code generation follows a deterministic hashing path: taking the app ID, batch ID, and index as input, hashing with SHA-256 to produce pseudo-random bytes, then mapping them onto the unambiguous alphabet:

fn generate_code(app_id: &str, batch_id: &str, index: u32, format: &str) -> String {
    // 1. Build the deterministic seed
    let seed = format!("{app_id}:{batch_id}:{index}");
    // 2. SHA-256 produces pseudo-random bytes
    let hash = sha2::Sha256::digest(seed.as_bytes());
    // 3. Map onto the unambiguous alphabet
    format.chars().enumerate().map(|(i, c)| {
        if c == 'X' {
            ALPHABET[hash[i % hash.len()] as usize % ALPHABET.len()] as char
        } else {
            c // preserve separators such as '-'
        }
    }).collect()
}

This design has a subtle virtue: the same index within the same batch always yields the same code. That makes generation idempotent — if something goes wrong midway through the database write, a retry won’t mint a different code.

MORE IN AI & Autonomous Agents

Further Reading in This Column

View all essays in “AI & Autonomous Agents” →