Design 2 · Gating published projects, htaccess-style
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.
/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.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
...
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
| Option | Verdict | Reasoning |
|---|---|---|
| A · MCP tool and space admin panel | recommended | Fits 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_online | fallback | Choose 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
- Space index pages (
/<space id>/) currently parse each listed project'sindex.htmlfor title and description. For a read-protected project that is a content leak — so a listed-but-protected project renders as its directory name plus a 🔒 badge, and_parse_index_htmlis skipped entirely. - Crawlers: 401 responses keep search engines out by construction; no
robots.txtcoordination needed. - Caches: gated responses get
Cache-Control: private, no-storeso nothing sticks in shared caches.
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
| Alternative | Verdict | Why |
|---|---|---|
Real .htaccess at the proxy | no | kamal-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 page | later, maybe | Prettier 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=…) | no | Re-creates the original sin: credentials in URLs — logs, referrers, copy-paste leaks. |
| Per-file gating | no | htaccess 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)
- Space-wide gate: same field on
Space, checked for every project in it plus the space index page — turns a whole space into a private area with one read password. - Multiple users: an htpasswd-style
{user: hash}JSON field if per-person credentials are ever wanted; the 401 dance is identical. - Auth for the fetch-through-MCP tool: the
Pass Web Through MCPfetcher can pass Basic credentials via the URL form above already; a dedicated header argument would be cleaner.
Honest limits
- One shared read password: anyone who has it can share it. That is the htaccess trade-off, accepted knowingly.
- Basic sends credentials on every request — safe only because TLS is mandatory end-to-end (kamal-proxy terminates Let's Encrypt certs; both domains covered).
- Browsers cache Basic credentials until the window closes; there is no logout button. Fine for the use case.
- Not for genuinely sensitive data — this gates "drafts, family pages, client previews", not secrets.
Implementation checklist
| File | Change |
|---|---|
| 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 |
| templates | space_admin_dashboard.html: set/clear read-password form per project; 🔒 badge in space_index.html |
| main/tests.py | unprotected 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 |