# Public / Anonymous Access for Acequia WebDAV

> **Completed Project** — implemented. Now serves as platform reference.

**Status:** Implemented (Approach 1 — Sidecar Files + Dotfile Filtering)
**Date:** 2026-03-05 (updated 2026-03-06)
**Scope:** Allowing unauthenticated read access to selected directories

---

## Implementation Summary

**Approach chosen:** Sidecar Files (Approach 1) + Dotfile Filtering (standalone prerequisite from the analysis below).

The recommendation to start with Central Config (Approach 2) was reconsidered — sidecar files were implemented first because they work for both localWebDAV and BrowserDAV, and are delegatable via chain tokens.

### What was built

**Sidecar file format:** `.acequia-access.json` (note: `.json` extension added vs the original `.acequia-access` proposal for clarity and editor support).

```json
{
  "read": "anonymous",
  "recursive": true,
  "denyPatterns": [".env", "*.key", ".*"]
}
```

**localWebDAV (Nephele) — `src/auth/jwtAuth.mjs`:**
- `checkSidecarAccess(subdomain, requestPath)` — walks directory ancestors, nearest sidecar wins
- `_readSidecar(fsRoot, dirPath)` — reads and caches with 60s TTL
- `_scanChildPublicPaths(fsRoot, dirPath)` — scans child directories for public sidecars, enabling filtered ancestor navigation
- `_matchesDenyPattern(filename, patterns)` — exact match, `*.ext`, `.*` glob support
- Anonymous users get `User({ username: 'nobody', paths, writePaths: [], role: 'viewer', isAnonymous: true })`

**BrowserDAV — `acequia/{sub}/BrowserDAV/main.js`:**
- Parallel `checkSidecarAccess(data)` implementation using File System Access API handles
- Same walk-up-ancestors logic, same JSON format, same nearest-wins semantics

**Dotfile filtering — both servers:**
- `ScopedFileSystemAdapter.isAuthorized()` blocks dotfiles for users without write access
- `DirectoryJsonPlugin` wraps `getInternalMembers` to filter dotfiles from listings
- BrowserDAV `handleWebDAVRequest` pre-check before WebDAV dispatch
- `DOTFILE_READER_ALLOWLIST = ['.well-known', '.ai']`

**Design decisions that held from the analysis:**
- Nearest-wins inheritance (Option 1 from "Override vs Merge")
- Anyone with write scope can create/modify sidecars (Option B from "Who Can Create")
- TTL-based caching (30-60s) for PM2 cluster mode
- Write-gated dotfile visibility
- `nobody` user gets `paths` from sidecar, `writePaths: []`, `token: { paths }` for `canAccess()` compatibility

**Design decisions that diverged:**
- File named `.acequia-access.json` not `.acequia-access` (JSON extension for tooling)
- No `allowMethods` field — anonymous access is read-only by default, enforced structurally via `writePaths: []`
- Sidecar files ARE served in directory listings to writers (they're dotfiles, so write-gated visibility handles it naturally)

---

## Problem Statement

All WebDAV content currently requires a valid JWT. This blocks several use cases:

1. **Data sharing** — An owner wants to publish datasets or files that anyone can download without registration or tokens.
2. **App hosting** — Serving HTML pages that reference CSS, JS, and images via relative URLs. A token on the HTML request doesn't propagate to sub-resource loads (`<link href="style.css">`, `<script src="app.js">`). The page loads but its assets 401.
3. **Bootstrapping** — New users need to access onboarding content before they have credentials.
4. **Link sharing** — Sharing a URL with a colleague. Today this requires embedding a token in the URL or the recipient having their own token.

### The Sub-Resource Problem (Detail)

When a browser loads `https://sub.acequia.live/app/index.html?token=xxx`, the token authenticates that one request. But the HTML references `style.css` and `app.js` via relative URLs — those fetches carry no token. The server's cookie-based auth (`auth_token` cookie) solves this for *registered* users whose cookie is set, but not for:
- Unregistered recipients of a shared link
- First-time visitors
- Programmatic consumers (APIs, wget, curl without setup)

---

## Approaches

### 1. Sidecar Access Files (`.acequia-access`)

Place a JSON file in any WebDAV directory to declare access rules for that directory (and optionally its descendants).

```
/public-data/.acequia-access
/app/.acequia-access
```

**Example `.acequia-access`:**
```json
{
  "read": "anonymous",
  "recursive": true,
  "allowMethods": ["GET", "HEAD", "OPTIONS", "PROPFIND"],
  "denyPatterns": ["*.env", ".acequia-access"]
}
```

**How it works:**
- The authenticator checks for `.acequia-access` in the requested directory (and ancestors if `recursive`) before requiring a token.
- If the file grants anonymous read, the request proceeds with a synthetic `nobody` user (similar to the existing `/config.local.js` bypass).
- Write operations always require a token — the sidecar only governs reads.
- The `.acequia-access` file itself is writable only by authenticated users with write scope, and is excluded from anonymous listings.

**Pros:**
- Familiar mental model (`.htaccess`, `.npmrc`, etc.)
- Granular: per-directory, per-subdomain, no server restart
- Self-documenting: the access rules live alongside the content
- Owner-controlled: owners manage via WebDAV PUT (dashboard, BrowserDAV, or any client)
- Composable with delegation chains: a chain token scoped to `/public-data/*` can manage that directory's access file
- Works for both Nephele server and BrowserDAV (both can check sidecar files)

**Cons:**
- **Performance**: Every unauthenticated request requires a filesystem read (the sidecar file) before serving. Needs caching with invalidation.
- **Complexity**: Walking up directory ancestors for inherited rules adds path traversal on every request.
- **Security surface**: A misconfigured sidecar could expose sensitive content. Must have strong defaults (deny-all) and validation.
- **Race conditions**: Sidecar file could be modified between the access check and the content serve (minor — same as .htaccess).
- **Discovery**: No central view of what's public. An admin would need to search the filesystem for all `.acequia-access` files.

---

### 2. Subdomain-Level Public Paths Configuration

Add a `publicPaths` array to `subdomain.json` (or a dedicated `access.json`), managed through the dashboard API.

```json
// auth/{subdomain}/subdomain.json
{
  "subdomain": "stigmergic",
  "authMode": "user",
  "publicPaths": [
    "/public/*",
    "/apps/my-app/*"
  ]
}
```

**How it works:**
- On server start (and on config change), public paths are loaded into memory.
- The authenticator checks the request path against `publicPaths` before requiring a token. Uses the existing `matchesPath()` glob logic.
- Matching requests proceed with a synthetic `nobody` user.
- Dashboard gets a new section: "Public Directories" — add/remove path patterns.
- API endpoint: `PUT /auth/subdomain/public-paths` (owner-only).

**Pros:**
- **Central management**: All public paths visible in one place (dashboard, config file).
- **Performance**: In-memory path matching — no filesystem reads per request.
- **Simplicity**: Reuses existing `matchesPath()` infrastructure.
- **API-friendly**: Programmatic management via REST endpoint.
- **Auditability**: Single config file to review for security audits.

**Cons:**
- **Requires API access**: Can't be managed by WebDAV clients alone; needs dashboard or API call.
- **Server-coupled**: BrowserDAV (browser-based WebDAV) can't easily read/enforce this config.
- **Coarse**: Path patterns only — no per-directory overrides for allowed methods, deny patterns, etc.
- **Not delegatable**: Can't scope a chain token to manage public paths for a subdirectory without giving it access to the subdomain config.

---

### 3. Hybrid: Config + Sidecar Override

Combine approaches 1 and 2. The subdomain config declares base public paths; sidecar files provide overrides.

```json
// subdomain.json — declares defaults
{ "publicPaths": ["/public/*"] }
```

```json
// /public/private-subdir/.acequia-access — override
{ "read": "authenticated" }  // Revokes anonymous access for this subtree
```

**Resolution order:** Sidecar file wins over config. Absence of sidecar → fall back to config → fall back to deny-all.

**Pros:**
- Best of both: central overview + granular per-directory control.
- Sidecar overrides allow both widening (new public dir) and narrowing (private subdir within public tree).

**Cons:**
- Two sources of truth: harder to reason about what's public.
- Increased complexity in the auth path.
- Both caching strategies needed (in-memory for config, FS-cached for sidecars).

---

### 4. Public Capability Tokens (Long-Lived Bearer URLs)

Instead of making paths anonymous, generate a long-lived, read-only chain token with broad scope and embed it as a cookie or in the URL.

```
https://stigmergic.acequia.live/app/?token=<long-lived-public-token>
```

The server (or a reverse proxy) injects a cookie on first visit, so sub-resource loads work.

**How it works:**
- Owner creates a delegation chain token: `{ paths: ['/public/*'], writePaths: [], exp: <far future> }`.
- Share the URL with the token as a query parameter.
- On first request, server sets `auth_token` cookie with the token value → sub-resources work.
- Could automate: a "public link" button on the dashboard generates the URL.

**Pros:**
- **No auth changes needed**: The existing system already supports this. It's a usage pattern, not a feature.
- **Revocable**: Add to revocation list to kill access.
- **Scope-restricted**: The token carries its own path constraints.
- **Cookie propagation**: Once the cookie is set, sub-resources load without per-URL tokens.

**Cons:**
- **Token in URL**: Visible in logs, referer headers, browser history. Security concern.
- **Cookie domain**: Cookie only works for the subdomain's domain. Cross-origin sub-resources still fail.
- **Token management**: Long-lived tokens need monitoring and rotation.
- **Not truly anonymous**: It's "shared secret" access, not open access. Anyone with the URL has the same access.
- **Cookie must be set**: The first request needs the `?token=` parameter. If someone shares just the path (without token), it fails. Need a mechanism to auto-set the cookie (redirect via a landing page, or server-side injection).

---

### 5. Service Worker Token Injection

Extend the existing service worker to intercept sub-resource requests and inject the auth token from the parent page's context.

**How it works:**
- When an HTML page loads with a `?token=` parameter, the page's JS passes the token to the SW via `postMessage` or stores it in IndexedDB.
- The SW intercepts all fetch requests to the same origin and adds `Authorization: Bearer <token>` or appends `?token=` to the URL.
- No server changes needed for the sub-resource problem.

**Pros:**
- **Solves sub-resource loading** without any server-side changes.
- **Already have a SW**: `public/sw.js` already intercepts requests and handles auth.
- **Client-side only**: No new server endpoints or config.

**Cons:**
- **SW must be active**: First page load (before SW installs) still fails. Requires a bootstrap page or reload.
- **Only works in browsers**: CLI tools, wget, APIs don't have a service worker.
- **Scope limitations**: SW scope is tied to its registration path. Cross-origin resources are out of scope.
- **Doesn't solve anonymous access**: Still requires a token — just propagates it better.
- **Fragile**: SW lifecycle (install, activate, update) adds failure modes.

---

### 6. Cookie-Setting Landing Page

A lightweight approach: serve a small HTML page at a well-known URL that sets the auth cookie and redirects.

```
https://stigmergic.acequia.live/auth/public-session?redirect=/app/index.html
```

**How it works:**
- Owner configures public paths (via approach 1 or 2).
- The landing page endpoint sets `auth_token` cookie with a special public-access token and redirects to the target URL.
- The public-access token is server-generated, short-lived, auto-refreshed on each visit.
- Sub-resources load because the cookie is present.

**Pros:**
- Solves sub-resource problem for browser users.
- Works with any of the path-based approaches (1, 2, or 3).
- No token visible in shared URLs.

**Cons:**
- Extra redirect on first visit.
- Cookie-dependent — doesn't help programmatic consumers.
- Requires the public paths to be configured separately (this is a companion to approaches 1-3, not standalone).

---

### 7. Nephele Authenticator Bypass with Allowlist

Modify the JWTAuthenticator to return the `nobody` user for requests matching an allowlist, without requiring a token.

**How it works:**
- The authenticator receives a list of public path patterns at construction time.
- In `authenticate()`, before checking for a token, check if the request path matches a public pattern.
- If it matches, return `new User({ username: 'nobody' })` immediately.
- The `nobody` user gets read-only access (ScopedFileSystemAdapter can enforce this).
- The allowlist is loaded from subdomain config (approach 2) or from a startup scan of sidecar files (approach 1).

This is essentially the implementation mechanism for approaches 1-3, not a separate approach.

---

## Comparison Matrix

| Criterion | Sidecar Files | Central Config | Hybrid | Capability URLs | SW Injection | Landing Page |
|-----------|:---:|:---:|:---:|:---:|:---:|:---:|
| Solves anonymous access | Yes | Yes | Yes | Partially | No | Partially |
| Solves sub-resource loading | Yes | Yes | Yes | With cookie | Yes | Yes |
| No server code changes | No | No | No | **Yes** | **Yes** | No |
| Works for CLI/API | Yes | Yes | Yes | Yes | No | No |
| Per-directory granularity | **Yes** | No | **Yes** | Yes | N/A | N/A |
| Central visibility | No | **Yes** | Partial | No | N/A | N/A |
| Owner self-service (WebDAV) | **Yes** | No | Partial | No | N/A | N/A |
| Performance impact | Medium | **Low** | Medium | **Low** | **Low** | **Low** |
| Implementation complexity | Medium | Low | High | **None** | Medium | Low |
| BrowserDAV compatible | **Yes** | No | Partial | Yes | Yes | No |

---

## Recommendation (original) → What was built

**Original recommendation:** Central Config (Approach 2) first, Sidecar Files (Approach 1) later.

**What actually happened:** Sidecar Files (Approach 1) were implemented first, skipping Central Config entirely. Rationale:
- Sidecar files work for both localWebDAV and BrowserDAV — central config doesn't work for BrowserDAV
- Sidecars are delegatable via chain tokens (a write-access holder can manage their subtree)
- The `<acequia-file-browser>` component provides a GUI for editing sidecars, removing the "hard to manage" objection
- Dotfile filtering shipped as a prerequisite, exactly as recommended in the analysis

**Still applicable for future:**
- Central Config (Approach 2) could complement sidecars for subdomain-wide policies
- Cookie-Setting Landing Page (Approach 6) remains relevant for the sub-resource loading problem
- Capability URLs (Approach 4) for sharing specific files/directories

---

## Implementation Sketch (Approach 2 — Central Config)

### 1. Data Model

```json
// auth/{subdomain}/subdomain.json
{
  "subdomain": "stigmergic",
  "authMode": "user",
  "publicPaths": ["/public/*", "/apps/shared-app/*"]
}
```

### 2. Auth Changes (`jwtAuth.mjs`)

```javascript
// In authenticate(), before token check:
if (!req.token) {
  const publicPaths = await this.getPublicPaths(req.subdomain)
  if (publicPaths.length > 0 && matchesPath(requestPath, publicPaths)) {
    return new User({ username: 'nobody', paths: publicPaths, writePaths: [] })
  }
  throw new UnauthorizedError('No auth token found')
}
```

### 3. Write Protection

The `ScopedFileSystemAdapter.isAuthorized()` already blocks writes for users with empty `writePaths`. The `nobody` user gets `writePaths: []`, so all writes are denied.

### 4. API Endpoint

```
PUT /auth/subdomain/public-paths
Authorization: Bearer <owner-token>
Content-Type: application/json

{ "publicPaths": ["/public/*"] }
```

### 5. Dashboard UI

Add a "Public Directories" card to the owner dashboard with:
- List of current public path patterns
- Add/remove controls
- Warning about security implications

---

## Implementation Sketch (Approach 1 — Sidecar Files)

### 1. File Format

```json
// /some-directory/.acequia-access
{
  "read": "anonymous",          // "anonymous" | "authenticated" (default)
  "recursive": true,            // Apply to subdirectories (default: false)
  "allowMethods": ["GET", "HEAD", "OPTIONS", "PROPFIND"],
  "denyPatterns": ["*.env", ".*"]  // Glob patterns to exclude from public access
}
```

### 2. Caching Strategy

```javascript
// In-memory cache with filesystem watcher
const accessCache = new Map()  // path → parsed .acequia-access

// Cache invalidation via fs.watch or TTL (e.g., 30s)
// On cache miss: read .acequia-access, parse, cache
// Walk up directory tree for inherited rules
```

### 3. Auth Check Flow

```
Request for /public/data/file.csv (no token)
  → Check cache for /public/data/.acequia-access → found, read=anonymous
  → Synthetic nobody user, allow GET
  → Serve file

Request for /public/data/secrets.env (no token)
  → Check cache → read=anonymous BUT denyPatterns matches *.env
  → 401 Unauthorized

Request for /private/file.txt (no token)
  → Check cache → no .acequia-access found, walk up to / → no rules
  → 401 Unauthorized
```

### 4. Security Constraints

- `.acequia-access` files are **never served** to anonymous users
- Only users with write scope to the directory can create/modify `.acequia-access`
- Maximum inheritance depth (e.g., 10 levels) to prevent pathological traversal
- `read: "anonymous"` only grants read methods — never PUT, DELETE, MKCOL
- Server validates the JSON schema on write (via Nephele `beforePut` hook or WebDAV PROPPATCH)

---

## Design Tradeoffs: Deep Analysis

### Sidecar Files (`.acequia-access`)

#### Who Can Create/Modify Sidecar Files?

This is the fundamental authorization question. Options:

**A. Only the subdomain owner.** Simplest and safest. The owner is the only party who can grant public access. But this defeats a key advantage of sidecars — that delegated users could manage access for their subtree.

**B. Anyone with write scope to the directory.** An editor scoped to `/ants/*` could create `/ants/.acequia-access`. This is the natural capability-chain answer: write access implies control over access rules. But it means a delegated editor could make content public that the owner intended to stay private. The editor already has write access to the data itself, so arguably they can already exfiltrate it — making it public is just a more visible form.

**C. New permission: `accessControl` flag in scope.** Add an explicit `accessControl: true` flag to chain token scope. Only tokens with this flag can create/modify `.acequia-access` files. Gives owners fine-grained control over who can change access rules vs who can just edit content. Adds complexity to the scope model.

**Recommendation: Start with B, document the trust model.** If you delegate write access, you're trusting that user with the content. Making it public is within that trust boundary. Add C later if needed.

#### Inheritance Semantics: Override vs Merge

When nested `.acequia-access` files exist, how do they compose?

```
/data/.acequia-access           → { "read": "anonymous", "recursive": true }
/data/private/.acequia-access   → { "read": "authenticated" }
/data/private/except/.acequia-access → { "read": "anonymous" }
```

**Option 1: Nearest wins (override).** `/data/private/file.txt` → authenticated (nearest sidecar says so). Simple, predictable, matches `.htaccess` behavior.

**Option 2: Most restrictive wins (merge).** If any ancestor says `authenticated`, access is denied. Safer, but makes it impossible to carve out a public subdirectory within a private tree. Also contradicts the first example's `recursive: true`.

**Option 3: Explicit inheritance control.** The sidecar declares whether it overrides or inherits: `{ "inherit": false }` means "ignore all ancestors." More expressive, more complex.

**Recommendation: Option 1 (nearest wins).** It's what people expect from `.htaccess`. The `recursive: true` flag on a parent only applies to directories that *don't* have their own sidecar.

#### Practical Concern: Sidecar Files in the Directory Listing

If `/app/` is public and contains `.acequia-access`, that file appears in PROPFIND and GET directory listings. Problems:

- Reveals access configuration to anonymous users (information leakage)
- Downloading the sidecar might confuse users expecting only app content
- Modifying it is a privilege escalation vector

**Mitigations:** The DirectoryJsonPlugin and IndexPlugin should filter `.acequia-access` from listings for anonymous users (similar to how `.*` files are often hidden). The file should return 403 for anonymous GET requests even within a public directory. This requires special handling — the sidecar declares public access for the directory but is not itself public.

#### Filesystem Root Traversal Risk

The auth check walks up directory ancestors looking for sidecar files. With a deeply nested path like `/a/b/c/d/e/f/g/file.txt`, this means up to 7 `readFile` attempts (each directory checked for `.acequia-access`). On every unauthenticated request.

**Mitigations:**
- **Negative caching:** Cache "no sidecar here" results with TTL. The common case (most directories are private) resolves quickly via cache.
- **Depth limit:** Cap ancestor traversal at N levels (e.g., 10). Deeper nesting without a sidecar → deny.
- **Eager scan:** On server start (and periodically), scan the filesystem for all `.acequia-access` files and build an in-memory index. Avoids per-request filesystem reads entirely. Trade: startup cost + memory, but requests are fast.
- **fs.watch invalidation:** Watch the WebDAV root for `.acequia-access` file changes and update the in-memory index. Works well on macOS/Linux, flaky on some network filesystems.

#### PM2 Cluster Mode

localWebDAV runs under PM2 with `-i max` (multiple worker processes). If worker 1 handles a PUT to `.acequia-access`, workers 2-N have stale caches.

**Options:**
- **TTL-based cache (30s):** All workers eventually see the change. Simple. 30s window of inconsistency.
- **IPC broadcast:** PM2 supports `process.send()` for inter-worker messaging. Worker 1 broadcasts "invalidate cache for /app/.acequia-access" to all workers. More complex, consistent.
- **External cache (Redis):** Overkill for this use case.

**Recommendation:** TTL cache (30-60s). The access-change operation is rare; a brief inconsistency window is acceptable. Document it.

#### Interaction With Scoped Token Filtering

The existing `ScopedFileSystemAdapter.isAuthorized()` checks `user.paths`. For anonymous users, what does `user.paths` contain?

**If sidecar-derived:** The authenticator would need to read the sidecar, determine which paths are public, and set `user.paths` accordingly. But the sidecar is per-directory, and `user.paths` is per-request — they're at different granularities.

**Simpler model:** For anonymous users, don't use `ScopedFileSystemAdapter` path filtering at all. Instead, the authenticator sets `user.paths = ['*']` (or the specific public path pattern), and a separate middleware or the adapter's `isAuthorized()` checks the sidecar. The risk: `paths = ['*']` means the adapter won't filter directory listings, so the DirectoryJsonPlugin/IndexPlugin must filter instead.

**Or:** Set `user.paths` to the specific public pattern from the sidecar (e.g., `['/app/*']`). Then the existing scoped filtering works naturally. Problem: if the sidecar is `recursive: true`, the path is a wildcard covering the subtree. If not recursive, it's an exact directory match. The `matchesPath()` function handles both.

**Recommendation:** Set `user.paths` from the sidecar's effective scope. Reuse existing filtering.

---

### Central Config (`publicPaths` in subdomain.json)

#### Hot Reload vs Restart

If the owner adds a public path via the dashboard API, when do running workers pick it up?

**Option 1: Read config on every unauthenticated request.** Simple, always fresh. But adds a `readFile` + `JSON.parse` on every 401-candidate request. Similar cost to sidecar approach.

**Option 2: Cache with TTL.** Read config once, cache in memory, expire after N seconds. Same pattern as sidecar caching. Same PM2 staleness issue.

**Option 3: Reload on API write.** The `PUT /auth/subdomain/public-paths` handler updates the file and the in-memory cache of the current worker. Other workers pick up via TTL or IPC.

**Recommendation:** Option 3 (reload on write) + TTL fallback. The API endpoint lives in the same process as the authenticator, so it can update the cache directly. Other workers get a TTL refresh.

#### Dangerous Patterns

What if the owner sets `publicPaths: ['/*']` (everything public) or `publicPaths: ['*']` (wildcard)?

**Options:**
- **Allow it:** The owner is the owner. They can make their whole site public if they want.
- **Warn but allow:** Dashboard shows a red warning for overly broad patterns. API returns a `warning` field.
- **Block dangerous patterns:** Reject `/*` and `*`. Force at least one directory level of specificity.

**Recommendation:** Warn but allow. The owner might legitimately want a fully public site. But the dashboard should make the implications clear with a confirmation dialog.

#### Per-Subdomain Isolation

Each subdomain has its own `subdomain.json`, so public paths are naturally isolated. But the authenticator processes requests for all subdomains. If subdomain A has `publicPaths: ['/data/*']` and subdomain B doesn't, the authenticator must load the right config for each request.

This is already handled: `req.subdomain` is set by middleware before the authenticator runs. The authenticator can call `this.getPublicPaths(req.subdomain)` to load the right config. No cross-subdomain leakage.

#### Interaction With sessionTimeoutHandler

Currently, unauthenticated requests to non-API URLs get redirected to the login page via `sessionTimeoutHandler`. For public paths, this redirect must be suppressed.

**Two options:**
- **Authenticator returns early:** If the path is public, `authenticate()` returns the `nobody` user before throwing `UnauthorizedError`. The session timeout handler never fires because there's no 401.
- **Session handler checks public paths:** The handler skips redirect for public paths.

**Recommendation:** Authenticator returns early. This is the cleaner boundary — the authenticator is the single decision point for "does this request need auth?"

#### The `nobody` User Object

The existing `/config.local.js` bypass returns `new User({ username: 'nobody' })` — but this user has no `paths`, `writePaths`, `role`, or `token` properties. The downstream `canAccess()` and `canWrite()` methods check `user.token` and return `false` if missing.

For public access, we need the `nobody` user to pass `canAccess()` for public paths and fail `canWrite()` for everything. Options:

- **Set `user.paths` and `user.writePaths`:** `{ username: 'nobody', paths: publicPaths, writePaths: [], role: 'viewer', token: {}, isAnonymous: true }`. The `token: {}` satisfies the `if (!user.token) return false` check in `canAccess()`.
- **Bypass `canAccess()` entirely:** Add an early return for anonymous users in `canAccess()`. Check `user.isAnonymous` and `matchesPath(path, user.paths)` directly.
- **Add a new method `canAccessPublic()`:** Separate code path for anonymous access checks. Avoids polluting the chain/role-based auth logic with anonymous edge cases.

**Recommendation:** Set properties on the user object (`paths`, `writePaths: []`, `role: 'viewer'`). Add a synthetic `token: { paths: publicPaths }` so existing `canAccess()` works without modification. Add `isAnonymous: true` flag for downstream code that needs to distinguish (e.g., audit logging).

This might look fragile (fabricating a token object), but it's pragmatic: the existing `canAccess()` code paths for viewers already do exactly the right thing — check `user.paths`, deny writes.

#### Interaction With BrowserDAV

BrowserDAV runs in the browser and has its own auth checking in `checkRequestAuth()`. It calls `acequia.chains.resolveAndVerify()` to validate chain tokens. It has no knowledge of the server's `publicPaths` config.

**Options:**
- **BrowserDAV ignores public paths:** It enforces auth for all requests regardless. If someone accesses BrowserDAV-served content, they need a token. Server-served content can be public. Inconsistent but avoids complexity.
- **BrowserDAV queries the server:** On startup, BrowserDAV fetches `GET /auth/subdomain/public-paths` and caches the list. Uses it in `checkRequestAuth()` to allow anonymous access to matching paths.
- **Don't run BrowserDAV on public content:** BrowserDAV is for sharing a local filesystem via the browser. If content should be public, put it on the server's WebDAV directly. Pragmatic separation.

**Recommendation:** Start with "BrowserDAV ignores public paths." The primary use case for public access is server-hosted content (data sets, apps, shared pages). BrowserDAV is a personal/ephemeral tool. Add query support later if needed.

---

### Cross-Cutting Tradeoffs

#### Mixed Directories: Public and Private Files Side by Side

A directory contains both public app files and a private config:

```
/app/
  index.html      ← public
  style.css        ← public
  app.js           ← public
  .env             ← private
  admin.html       ← private
```

**Central config can't handle this** — it operates at the path-pattern level. `/app/*` makes everything public.

**Sidecar files handle it** via `denyPatterns`:
```json
{ "read": "anonymous", "recursive": true, "denyPatterns": [".env", "admin.*"] }
```

**Alternative:** Don't mix. Put public content in `/app/public/` and private content in `/app/private/`. This is the simplest solution and probably the right default advice.

**Recommendation:** If implementing central config first, document the "don't mix" convention. When adding sidecar files later, `denyPatterns` covers the edge case.

#### Authenticated Users Visiting Public Paths

If a user has a valid token and visits a public path, which identity applies?

**Option 1: Token wins.** If the request has a valid token, use it. The user gets their full identity (role, paths, write access). The public path config is irrelevant.

**Option 2: Merge.** The user gets their normal identity + public path access. No practical difference for reads (they can already read), but matters for logging — do you log "editor alice accessed /public/data.csv" or "anonymous accessed /public/data.csv"?

**Option 3: Anonymous for public paths.** Even with a token, public path requests are treated as anonymous. This is weird and confusing.

**Recommendation: Option 1 (token wins).** The public path logic only activates when `!req.token`. If a token is present, normal auth applies. This matches the existing code structure in `authenticate()` — the "no token" path is checked first, and the public path check goes there.

#### PROPFIND and Directory Listings for Anonymous Users

If `/app/` is public, should anonymous users be able to PROPFIND it and see the file listing?

**Arguments for allowing PROPFIND:**
- WebDAV clients (macOS Finder, Windows Explorer, Cyberduck) rely on PROPFIND to browse directories
- The IndexPlugin HTML listing uses GET, not PROPFIND (it's a `beforeGet` hook)
- Denying PROPFIND while allowing GET is inconsistent for WebDAV clients

**Arguments against:**
- Directory listings reveal filenames, sizes, and modification times — potentially sensitive metadata
- Users who just want to serve an app don't need WebDAV browsability
- More surface area for enumeration attacks

**Middle ground:** Allow PROPFIND by default (sidecar `allowMethods` includes it), but let the sidecar restrict it: `"allowMethods": ["GET", "HEAD"]` would serve files but not directory listings.

For central config, add a boolean: `"publicListings": true` (default: true, since the primary use case is browsable data/apps).

#### The Service Worker and Public Paths

The existing service worker (`public/sw.js`) checks credentials for VFS-mounted requests. When a public path is accessed through a VFS mount, the SW might inject a token from IndexedDB. This is fine — the token wins per the rule above.

But if the SW doesn't have a token for the mount (anonymous user, first visit), the SW currently fails the request. The SW would need to know that certain paths are public and skip auth injection.

**Options:**
- **SW queries the server** for public paths on startup. Adds a network dependency.
- **SW passes through token-less requests.** The server decides auth. The SW stops being a gatekeeper. This is the cleanest solution — the SW's auth check is redundant with the server's.
- **SW caching layer.** For public paths, the SW could cache responses for offline/performance. Nice-to-have, not required.

**Recommendation:** For the initial implementation, the SW pass-through is simplest. Long term, SW caching of public content could improve performance.

---

### Naming the Sidecar File

| Option | Pros | Cons |
|--------|------|------|
| `.acequia-access` | Branded, no conflicts | Long, unfamiliar |
| `.access.json` | Short, descriptive, JSON extension | Could conflict with other tools |
| `_access.json` | Follows `_` convention for metadata | Less familiar than `.` prefix |
| `.htaccess` | Universally known | Implies Apache semantics, confusing |
| `.well-known/access.json` | Standards-y | `.well-known/` is a directory, extra nesting |

**Recommendation:** `.acequia-access` — clearly scoped to this system, no collision risk, dotfile convention hides it from casual listing.

---

## Dotfile Exposure: A Pre-Existing Gap

**Nephele serves all dotfiles by default.** There is no built-in option to hide them. The `FileSystemAdapter.getInternalMembers()` filters only `.nephelemeta` files (Nephele's own metadata). Everything else — `.env`, `.git/`, `.DS_Store`, `.ssh/`, `.acequia-access` — is served and listed.

This is a problem *independent* of public access, but public access makes it critical: today a dotfile is only visible to authenticated users (who are presumably trusted). With anonymous access, dotfiles are exposed to the internet.

### Approach 8: Central Visibility/Access UI (Dashboard-Managed File Rules)

Instead of (or alongside) sidecar files, manage file visibility and access rules entirely through the dashboard. This extends the central config approach to cover dotfile filtering, public access, and per-path rules in one unified system.

**Data model:**
```json
// auth/{subdomain}/access-rules.json
{
  "publicPaths": ["/public/*", "/apps/shared-app/*"],
  "publicListings": true,
  "hiddenPatterns": [".*", "*.env", "*.key", "*.pem", "node_modules"],
  "rules": [
    {
      "path": "/data/reports/*",
      "read": "anonymous",
      "hiddenPatterns": ["draft-*"]
    },
    {
      "path": "/app/*",
      "read": "anonymous",
      "deny": [".env", "admin.html", "config.json"]
    }
  ]
}
```

**How it works:**
- `hiddenPatterns` is a global list — these files are never served or listed (even for authenticated users, unless they're the owner).
- `rules` provides per-path overrides with their own visibility and access settings.
- The `ScopedFileSystemAdapter` checks patterns in `isAuthorized()` and filters `getInternalMembers()`.
- All managed via dashboard UI and REST API.

**Pros:**
- **Solves three problems at once:** dotfile hiding, public access, and per-directory rules.
- **Central visibility:** Owner sees all rules in one place. No hunting for sidecar files across the filesystem.
- **No sidecar bootstrapping problem:** Sidecar files are themselves served by the system they're trying to configure. A central config avoids this circularity.
- **Performance:** Rules loaded into memory once, no per-request filesystem reads.
- **Consistent with existing patterns:** `subdomain.json` already carries config. `access-rules.json` is the same pattern.

**Cons:**
- **Not delegatable:** A chain token holder can't manage rules for their subtree without API access. The sidecar approach's biggest advantage is lost.
- **Requires dashboard/API:** Can't set rules via a WebDAV client (drag-and-drop a config file).
- **Schema complexity:** The rules array needs careful design to avoid becoming a mini-DSL.
- **All-or-nothing for non-owners:** An editor can't make their own content public without owner involvement.

**Interaction with dotfile filtering:**

The `hiddenPatterns` field solves the dotfile gap regardless of public access. Even without enabling any public paths, adding `"hiddenPatterns": [".*"]` would hide all dotfiles from all non-owner users. This is arguably a separate feature that should ship first.

### Dotfile Filtering: Standalone Regardless of Approach

Whichever public access approach is chosen, dotfile filtering should be implemented first as a prerequisite.

#### Decision: Dotfiles visible only to users with write access

Dotfiles (names starting with `.`) are hidden from anonymous users and read-only users. Only users with write scope to the specific directory see dotfiles in that directory.

**Implementation in `ScopedFileSystemAdapter.isAuthorized()`:**

```javascript
const basename = path.basename(resourcePath)
if (basename.startsWith('.')) {
  // Dotfiles only visible to users who can write to this path
  if (!matchesPath(resourcePath, user.writePaths || [])) return false
}
```

**Who sees what:**

| User type | Dotfiles in `/ants/` | Dotfiles in `/docs/` |
|-----------|:---:|:---:|
| Owner (global write) | Visible | Visible |
| Editor scoped to `/ants/*` | Visible | Hidden |
| Viewer (any scope) | Hidden | Hidden |
| Anonymous | Hidden | Hidden |

**Why write-gated visibility makes sense:**
- Dotfiles are typically configuration/infrastructure (`.env`, `.git/`, `.htaccess`, `.acequia-access`). If you can't modify them, you don't need to see them.
- Solves the sidecar paradox: `.acequia-access` is visible to exactly the people who can modify it. No special exemption needed.
- Defense in depth: even if an anonymous user guesses a dotfile name, `isAuthorized()` blocks the GET.
- Reuses existing `matchesPath()` + `writePaths` infrastructure. No new config, no new concepts.

**The unintuitive coupling:**
This creates a novel relationship: **visibility tied to write permission** rather than read permission. A user might wonder "why can't I see `.gitignore`?" and the answer is "because you don't have write access" — which breaks the normal mental model where read = can see. This is the main tradeoff.

Mitigations for the intuition gap:
- Document it clearly: "Dotfiles are management files — visible only to users who can manage the directory."
- If the IndexPlugin HTML listing is shown to read-only users, show a subtle note: "Some files are hidden based on your permissions."
- If a specific dotfile needs to be readable by non-writers (unusual but possible), the owner can rename it to not start with `.`, or a future `visibleDotfiles` config could override.

**Edge cases:**

1. **`.well-known/` directory** (ACME/Let's Encrypt challenges): This is a dotfile directory that legitimately needs to be publicly accessible. Decision: maintain a hardcoded allowlist of dotfile names exempt from write-gating. Initial list: `['.well-known']`. The allowlist can grow if other standard dotfiles need reader visibility, but the bar is high — it should be genuinely standards-mandated, not convenience.

   ```javascript
   const DOTFILE_READER_ALLOWLIST = ['.well-known']
   const basename = path.basename(resourcePath)
   if (basename.startsWith('.') && !DOTFILE_READER_ALLOWLIST.includes(basename)) {
     if (!matchesPath(resourcePath, user.writePaths || [])) return false
   }
   ```

2. **Owner creating dotfiles via WebDAV client**: The owner has global write, so they see and can create dotfiles normally. No issue.

3. **PROPFIND depth:1 on a directory**: `isAuthorized()` is called per-child during PROPFIND. Dotfile children return 401 → hidden from the listing. This works with the existing Nephele flow.

4. **Direct GET of a known dotfile path**: Even if a user knows the exact path (`GET /.env`), `isAuthorized()` blocks it. The file is both hidden from listings and inaccessible by direct request.

5. **Nephele's own `.nephelemeta` files**: Already filtered by `FileSystemAdapter.getInternalMembers()` before `isAuthorized()` runs. No conflict.

**This can ship independently of public access** — it's a security hardening change that improves the status quo regardless. Estimated effort: ~5 lines in `ScopedFileSystemAdapter.isAuthorized()`.

---

## Security Considerations

1. **Accidental exposure**: Any public access mechanism risks unintended exposure. Mitigations:
   - Explicit opt-in only (no default public paths)
   - Dashboard warnings and confirmation dialogs
   - Audit log of public path changes
   - `denyPatterns` for sensitive file extensions (`.env`, `.key`, `.pem`)

2. **Directory traversal**: Public path matching must use the same `matchesPath()` that the auth system uses — no separate path parsing that might have different edge cases.

3. **Write protection**: Anonymous users must never get write access regardless of misconfiguration. Defense in depth: check at authenticator level, adapter level, and plugin level.

4. **Information leakage**: PROPFIND on public directories reveals filenames and metadata. Consider whether directory listings should be public or only direct file access.

5. **Abuse**: Public endpoints are susceptible to scraping, hotlinking, and bandwidth abuse. Consider rate limiting and/or referrer checks (future work, not in initial implementation).

---

## Open Questions

1. **~~Should PROPFIND (directory listings) be public, or only direct file GET?~~** *Resolved: Yes — PROPFIND is allowed for anonymous users on public paths. Both HTML listings (IndexPlugin) and JSON listings (DirectoryJsonPlugin) are filtered by dotfile + scope rules.*

2. **~~Should public access apply to the IndexPlugin HTML listing?~~** *Resolved: Yes — anonymous visitors see filtered directory listings via both HTML and JSON modes.*

3. **~~How should BrowserDAV handle public paths?~~** *Resolved: BrowserDAV reads `.acequia-access.json` from its own File System Access API handles. Same format, same walk-up-ancestors logic, independent implementation.*

4. **Should public tokens be rate-limited differently from authenticated tokens?** Open — not yet implemented.

5. **~~Should the `.acequia-access` approach support `write: "anonymous"`?~~** *Resolved: No. Anonymous users always get `writePaths: []`. Enforced structurally.*

6. **~~Should dotfile filtering be a hard default or opt-in?~~** *Resolved: Hard default. Dotfiles hidden from all non-writers. `DOTFILE_READER_ALLOWLIST = ['.well-known', '.ai']` for exceptions.*

7. **~~Should the sidecar file be a dotfile?~~** *Resolved: Yes. Named `.acequia-access.json`. Write-gated visibility handles it naturally.*

8. **~~Should the central UI approach replace or complement sidecar files?~~** *Resolved for now: Sidecar files implemented first. The `<acequia-file-browser>` component's sidecar editor provides a GUI, addressing the central-visibility concern. Central config may complement later for subdomain-wide policies.*
