Design 2 · Gating published projects, htaccess-style

Per-directory HTTP Basic Auth over TLS · one optional read_access_password per project · works for browsers, curl, and agents alike

A project directory can carry one read password. If set, every URL under /<project>/ answers 401 with WWW-Authenticate: Basic until credentials arrive — exactly the consumer experience of dropping an .htaccess/.htpasswd pair into a folder: the browser pops its native login box once and remembers; an agent adds one header. There is no Apache in this stack (kamal-proxy → gunicorn, content served from sqlite), so Django plays Apache's part in views.py — about 40 lines.

The credential model in one line Writing is always the space credential — OAuth over MCP (Design 1) or the space secret at /space_admin/. Reading the published site is public unless a project sets read_access_password. The read password gates only the public HTTP surface: MCP read_file is untouched — the space's own agent, holding the space credential, always reads its own content.
Browser or agent human · curl · fetch() Django · views.py /notes/… (protected) 1 · GET /notes/ — no credentials 2 · 401 · WWW-Authenticate: Basic realm="notes", charset="UTF-8" 3 · browser shows its native login box — an agent just sets the header itself 4 · GET /notes/ · Authorization: Basic base64(anything:password) 5 · check_password() vs Directory.read_access_password (verdict cached — no PBKDF2 per request) 6 · 200 OK — served as usual (analytics injection included) the client re-sends the header on every /notes/* request automatically — one password unlocks the whole project, like a folder under .htaccess
The standard RFC 7617 dance. Wrong password → back to step 2 (and a per-IP failure counter).

Model change (migration 0004)

class Directory(models.Model):
    ...
    # '' = publicly readable (default, today's behavior). Otherwise a Django
    # password hash (make_password) — never plaintext, never readable over MCP.
    # Gates READING the published site only; writes are always the space
    # credential over MCP.
    read_access_password = models.CharField(max_length=128, blank=True, default='')

    @property
    def is_read_protected(self):
        return bool(self.read_access_password)

Semantics kept deliberately htaccess-simple: one shared read password per project, any username accepted (some clients insist on sending one — it is ignored). The realm is the directory name. Protection is orthogonal to visible_in_index/available_online; an offline project stays 404, a read-protected one is 401.

Enforcement in views.py

def _check_read_access(request, d):
    """None if allowed; otherwise the 401 challenge response."""
    if not d.read_access_password:
        return None
    header = request.headers.get('Authorization', '')
    if header.startswith('Basic '):
        try:
            decoded = base64.b64decode(header[6:]).decode('utf-8')
            _user, _, password = decoded.partition(':')
        except (ValueError, UnicodeDecodeError):
            password = ''
        if password and _password_ok(d, password):   # cached check_password
            return None
        _note_failure(request)                       # per-IP rate limit
    resp = HttpResponse('Authentication required', status=401,
                        content_type='text/plain; charset=utf-8')
    resp['WWW-Authenticate'] = f'Basic realm="{d.name}", charset="UTF-8"'
    return resp

# project_index / project_file grow two lines each:
def project_index(request, dirname):
    d = _get_online_directory(dirname)
    denied = _check_read_access(request, d)
    if denied: return denied
    ...
Perf note Basic Auth resends credentials on every request, and Django's check_password (PBKDF2, ~100 ms) would be paid per asset. _password_ok therefore memoizes the verdict in a small in-process dict keyed by sha256(d.read_access_password + password) — including the stored hash in the key means changing the password invalidates the cache automatically, with no TTL bookkeeping.

Who sets the read password

OptionVerdictReasoning
A · MCP tool and space admin panelrecommendedFits the MCP-first platform: the agent can publish something gated in one conversation ("put this up, password it, tell me the password"). Protection is additive and reversible — unlike deletion, a wrong move locks nothing permanently, and the human can always override at /space_admin/.
B · Human-only, like available_onlinefallbackChoose this only if you want "the agent can never change who reads what" as an invariant. Cost: every gated publish needs a trip to the panel.

Under option A, one new MCP tool (keeping the platform's no-single-file-write spirit of few, explicit tools):

set_read_access_password(directory, password)   # 8–128 chars → protect
set_read_access_password(directory, null)       # remove the gate

_dir_meta gains "read_protected": true|false. The password itself is never returned by any tool — the agent that set it is expected to relay it to its human; a later session can only see that a gate exists. /space_admin/ gets a matching set/clear form and a 🔒 column.

Stopping the side-channel leaks

How agents and humans get in

# human: open https://says.hermione.online/notes/ → browser prompt → done for the session

# curl (any username works):
curl -u guest:s3cret https://says.hermione.online/notes/

# fetch / any HTTP client:
fetch(url, { headers: { Authorization: 'Basic ' + btoa('guest:s3cret') } })

# URL form many tools accept (incl. simple fetch-a-page MCP tools):
https://guest:s3cret@says.hermione.online/notes/

Considered and rejected

AlternativeVerdictWhy
Real .htaccess at the proxynokamal-proxy has no per-path auth, and content isn't on disk anyway — there is no folder for the file to live in. Django is the web server here.
Cookie + password form pagelater, maybePrettier than the native prompt and friendlier on iOS home-screen apps, but agents and curl handle it far worse. Could be layered on top later (accept either cookie or Basic header) without changing the model.
Signed URLs (?key=…)noRe-creates the original sin: credentials in URLs — logs, referrers, copy-paste leaks.
Per-file gatingnohtaccess semantics are per-folder and that matches the mental model of "a project"; per-file rules add UI and schema for a need that hasn't appeared.

Optional extensions (explicitly out of v1)

Honest limits

Implementation checklist

FileChange
main/models.py+ Directory.read_access_password, is_read_protected; migration 0004
main/views.py_check_read_access + cached _password_ok; two-line hook in project_index/project_file; 🔒 handling in space_index; Cache-Control on gated responses
main/mcp.py+ set_read_access_password tool; read_protected in _dir_meta; one line in instructions_for
templatesspace_admin_dashboard.html: set/clear read-password form per project; 🔒 badge in space_index.html
main/tests.pyunprotected unchanged · 401 + correct header · right/wrong password · username ignored · nested files gated · index leak suppressed · MCP set/clear + never echoes password · MCP read_file unaffected by gate · cache invalidation on change

← Design 1 — MCP OAuth 2.1 · Overview