sfx 0.1.3

SFX is a streamlined, full-stack Rust framework for building small web services with integrated authentication, localization, and config-driven UI components
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
# SFX framework 

SFX is a streamlined, full-stack Rust framework for building small web services with integrated authentication, localization, and config-driven UI components 

SFX is a simplified framework for building full-stack, small service. 

The framework now allows external auth, local auth and we will soon add OAuth into it 

The framework is built using hotaru. Read more about hotaru in https://fds.rs/hotaru/. 

You may import hotaru directly from `use sfx::prelude` or `use sfx::hotaru` 

Use `sfx --help` to learn how to use built-in tools in sfx, while run `sfx init` in the target dir to initialize a new project 

https://fds.rs/sfx/tutorial/0.1.3/ 

# Settings & Op 

## Endpoints 

### `/op/lang/<lang>`

Change the user's language by setting a cookie and redirecting back to the
page they came from (resolved via the `Referer` header — see
`op::from(req)`). This may not work if running in http but not https.

##### Request
`GET /op/lang/<lang>`
EMPTY

##### Response
A `HttpResponse` that redirects to the previous page with the new language set in a cookie

##### Language resolution

`op::lang(req)` (used by `pageprop`, `into_path_l`, etc.) resolves in this
order, accepting a value only if it appears in `support_lang.json`:

1. `?lang=<code>` query parameter — used by crawlers and
   `<link rel="alternate" hreflang>` so each language has its own crawlable URL.
2. `lang` cookie — set by the footer language switcher for human users.
3. `Accept-Language` header — negotiated via
   `htmstd::PreferredLanguage::best_match` against `support_lang.json`.
   Quality, header order, and supported-list order are honored per RFC 9110.
4. `default_lang()` — the first entry in `support_lang.json`.

`op::lang_or_none(req)` returns `None` at step 4 instead of the default, so
downstream apps can insert their own fallback (e.g. a `/<code>/...` URL
prefix scheme) between SFX's negotiation layer and the site default.

The `Accept-Language` layer requires `PreferredLanguageMiddleware` in the
protocol stack. SFX's bundled `APP` installs it automatically and configures
its fallback to match `default_lang()`. Downstream apps that build their own
`APP` should also append `PreferredLanguageMiddleware` (re-exported from
`sfx::prelude`) for the layer to take effect.

Handlers that want typed access to the parsed header can call
`req.params.get::<htmstd::PreferredLanguage>()` directly (or via the
`PreferredLanguageRequestExt` trait) — useful for non-template scenarios
like API content negotiation.

### `/static/<path>`

Serves the static files

##### Request
`GET /static/<path>`
EMPTY

##### Returns
A `HttpResponse` containing the static file or a 404 error if not found

### `/redirect?url=<url>`

Redirects to a given URL

##### Request
`GET /redirect?url=<url>`
EMPTY

##### Returns
A `HttpResponse` that redirects to the specified URL

### `/robots.txt`

Serves crawler directives. Reads `programfiles/op/robots.txt` from the
current working directory if present, otherwise returns a built-in default
that disallows `/user/` and `/admin/`. Consumers can override by shipping
their own file at that path.

##### Request
`GET /robots.txt`
EMPTY

##### Returns
A `text/plain` `HttpResponse` with the robots directives.

## Settings 

The framework loads critical configuration files at startup from programfiles/op/ and programfiles/admin_info/ directories. These include:

### UI Components 
navbar.json and footer.json define site-wide UI elements localized by language keys.

<details>

<summary><b>How to write the navbar.json</b></summary>  

Navbar.json is located at `./programfiles/op/navbar.json` 

The root is a dictionary. The keys are languages, while the values are the content of navbar for different languages 

```json 
{ 
    "language-key": {"content"}, 
    "en": {".."}, 
    "zh-tw": {".."} 
}
``` 

For the content, there must be 2 content, name and itemlist. Name will be shown in the Logo area while the item lists are the items shown on the navbar 

```json 
{ 
    "en": { 
        "name": "ICON", 
        "itemlist": [
            ["..."], 
            ["..."] 
        ] 
    }
}
``` 

For items in the item list, there are two kinds. The first kind is a single link item 

```json
{
    "display": "Single",
    "url": "/",
    "is_dropdown": false // Indicates here 
}, 
``` 

Another kind is dropdown, if you click the item the subitems contained in this item will be shown 

```json 
{
    "display": "List",
    "url": "/",             // Useless, but safer to keep it 
    "is_dropdown": true,    // Indicates it is a dropdown link 
    "dropdown": [           // Dropdown items 
        {
            "item": "Item 1",
            "iurl": "/"
        },
        {
            "item": "Item 2",
            "iurl": "/hotaru/news/"
        }
    ]
} 
``` 

</details> 

<br> 

<details> 

<summary><b>How to write the footer.json</b></summary>  

### Footer Configuration (`footer.json`)
Located at `./programfiles/op/footer.json`, this file defines localized footer content. The root is a dictionary where keys are language codes and values are footer configurations.

#### Structure
```json
{
  "language-key": {
    "items": [FooterColumn],
    "footer": "HTML string"
  }
}
```

#### Components
1. **Footer Column** (`items` array):
   - `name`: Column title (e.g., "Support", "Resources")
   - `itemlist`: Array of link objects:
     ```json
     {
       "display": "Link Text",
       "url": "/path-or-external-url"
     }
     ```

2. **Footer HTML** (`footer`):
   - Raw HTML string for copyright/legal text
   - Supports inline styling and anchor tags
   - Example: 
     ```json
     "footer": "&copy; 2025 <a href=\"/about\">Company</a>"
     ```

#### Full Example
```json
{
  "en": {
    "items": [
      {
        "name": "Support",
        "itemlist": [
          {"display": "Help Center", "url": "/support"},
          {"display": "Contact", "url": "/contact"}
        ]
      },
      {
        "name": "Language",
        "itemlist": [
          {"display": "English", "url": "/op/lang/en"},
          {"display": "日本語", "url": "/op/lang/ja"}
        ]
      }
    ],
    "footer": "&copy; 2025 MyApp | <a href='/privacy'>Privacy Policy</a>"
  }
}
```

</details> 

<br> 

### Localization 
l10n.json stores translated strings, support_lang.json lists supported languages (first entry is default). 

<details> 

<summary><b>How to write the l10n.json</b></summary>   

### Localization Configuration (`l10n.json`)
Located at `./programfiles/op/l10n.json`, this file provides localized string translations. The root is a dictionary where keys are phrase identifiers and values are language-specific translations.

#### Structure
```json
{
  "phrase_key": {
    "language-code": "translated_text",
    "en": "English text",
    "zh": "中文文本",
    "ja": "日本語テキスト"
  }
}
```

#### Example Usage
```rust
// In template rendering:
op::get_localized_string("login", &lang) // Returns "登录" for zh
```

#### Best Practices
1. Keep keys short and semantic (`"nav_home"` vs `"homepage_link"`)
2. Maintain consistent casing (lowercase recommended)
3. Add new languages to `support_lang.json` first
4. Escape special characters (`\n`, `\"`, `\\`)

```json
// Minimal complete example
{
  "welcome": {
    "en": "Welcome back!",
    "zh": "欢迎回来!",
    "ja": "おかえりなさい"
  },
  "error_404": {
    "en": "Page not found",
    "zh": "页面不存在",
    "ja": "ページが見つかりません"
  }
}
```

</details>

### Security 
hosts.json contains trusted origins (checked via is_trusted()), admins.json holds administrator data.

<details> 

<summary><b>How to write the hosts.json</b></summary>   

Hosts.json located at `./programfiles/op/hosts.json` 

It is a Json List. The order in the list will change the order different hosts present in Login, the 0th element will be the default host 

A special string `"local"` indecates we use local host 

Minimal example: 

```json 
["auth.fds.moe", "local"]
``` 

</details>

### Network 
binding.txt specifies server binding address (default: localhost:3003). 

Directly write your location in `programfiles/op/binding.txt` 

# User Login & Operations 

### User Endpoints 

### User Endpoints

#### 1. Login Flow
**`GET /user/login[?next=<path>]`**  
Renders login page with language-specific content.

The optional `?next=<path>` query carries the URL the user should land on
after a successful login. The shipped `login.html` JS picks a post-login
redirect target in this order:

1. `?next=<path>` on the login URL, if it is a same-origin path (starts
   with `/` and not `//`).
2. `document.referrer` if it is same-origin and not `/user/login` itself.
3. `/` as a safe fallback.

This is also the target for `op::forbidden_response`'s "Log in" link, so a
403 → login → original page round trip preserves the user's location.

*Response*: HTML page with:
- Localized navbar/footer
- Host selection dropdown (from `hosts.json`)
- Login form

**`POST /user/login`**  
Authenticates user credentials  
*Parameters* (URL-encoded form):  
- `host`: Authentication server ("local" for localhost)  
- `username`: User identifier  
- `password`: Plaintext password  

*Responses*:  
```json
// Success response is generated the backend. Please refer to the Local Auth 

// Failure
{
  "success": false,
  "message": "Invalid credentials"
}
```

*Sets Cookies*:  
- `access_token`: JWT for authenticated sessions  
- `host`: Base authentication server  

---

#### 2. Session Management
**`GET /user/logout`**  
Invalidates current session  
*Clears*: Access token cookie  
*Redirects*: To login page  

**`GET /user/refresh?redirect=<url>`**  
Refreshes access token  
*Redirects*: To specified URL after refresh  

**`GET /user/refresh_api`** (Testing)  
Returns new access token  
*Response*:  
```json
{ "access_token": "new.jwt.token" }
```

---

#### 3. User Information
**`GET /user/cached_info`**  
Returns cached user data from session  
*Response*:  
```json
{
  "uid": "user-id",
  "server": "auth.fds.moe",
  "username": "john_doe",
  "email": "user@example.com",
  "is_active": true,
  "is_verified": true,
  "cached_time": 1712345678
}
```

**`GET /user/info`** (Testing)  
Fetches fresh user info from auth server  
*Response*: Raw user object  

---

#### 4. Protected Routes
**`GET /user/home`**  
User dashboard (requires valid session)  
*Redirects*: To `/user/login` if unauthenticated  
*Renders*: `user/home.html` with:  
- Localized page title/description  
- Breadcrumb navigation  
- Current user object  

**`GET /user/unauthorized`**  
Access denied page  
*Renders*: `user/unauthorized.html`  

##### `op::pageprop` / `op::pageprop_with_keywords`

`pageprop(req, title, description)` builds the standard page properties
(`lang`, `title`, `description`, `nav`, `foot`, `user`, `path`, etc.) and
leaves `<meta name="keywords">` empty.

To populate per-page SEO keywords without rebuilding the dict, call
`pageprop_with_keywords` instead:

```rust
use sfx::op;

let pp = op::pageprop_with_keywords(
    req,
    "Constitution",
    "The constitution of the project",
    "hotaru, akari, project starfall",
);
```

The four-argument form is the canonical entry point; `pageprop` is the
zero-keywords convenience wrapper.

##### `op::forbidden_response(req, message)` helper

For permission-gated endpoints, prefer this helper over
`text_response("403 Forbidden")` so users stranded on a gated URL after
their session expires still see the site chrome.

```rust
use sfx::op;

if !user.can_edit() {
    return op::forbidden_response(req, Some("You need write permission to edit this resource."));
}
```

Returns an `HttpResponse` with status `403` whose body renders
`user/forbidden.html` with:
- `pageprop` — standard page properties (navbar + footer + lang)
- `message` — caller-supplied explanation, or a generic default if `None`
- `next` — the current request URL, percent-encoded, suitable for building
  a `/user/login?next=<next>` recovery link in the template

The shipped template provides "Log in" (→ `/user/login?next=<current>`)
and "Back to view" (→ the current path with query stripped) buttons.

---

#### 5. Password Management
**`POST /user/home/change_password`**  
Updates user password  
*Parameters* (URL-encoded form):  
- `old_password`: Current password  
- `new_password`: New password  
- `host`: Authentication server  

*Responses*:  
```json
// Success
{ "success": true }

// Failure
{
  "success": false,
  "message": "Current password invalid"
}
```

### User API 

This module provides core utilities for managing user sessions, authentication tokens, and interactions with the authentication server.

#### Key Functions

##### Token Management
- **`set_auth_token(req: &mut HttpReqCtx, token: &str)`**  
  Stores the JWT token in the session under `"auth_token"`.
- **`get_auth_token(req: &HttpReqCtx) -> Option<String>`**  
  Retrieves the JWT token from the session, if present.
- **`set_host(req: &mut HttpReqCtx, host: &str)`**  
  Stores the authentication server host (e.g., `"auth.fds.moe"` or `"local"`) in the session.
- **`get_host(req: &HttpReqCtx) -> Server`**  
  Retrieves the authentication server host from the session, falling back to `Server::Local` if missing.

##### User Data Fetching
- **`fetch_user_info(host: Server, auth: String) -> Option<User>`**  
  Fetches user details from `/users/me` endpoint.  
  *Response format*: 
  ```json
  {
    "user": {
      "uid": "user-id",
      "username": "john_doe",
      "email": "user@example.com",
      "is_active": true,
      "is_verified": true
    }
  }
  ```

##### Session Operations
- **`refresh_user_token(req: &mut HttpReqCtx) -> Value`**  
  Refreshes access token via `/auth/refresh`. Updates session token on success.  
  *Success response*: 
  ```json
  { "success": true, "access_token": "new.jwt.token" }
  ```
- **`cache_user_info(req: &mut HttpReqCtx, user: User)`**  
  Caches deserialized user object in session.
- **`logout(req: &mut HttpReqCtx) -> HttpResponse`**  
  Clears session tokens and redirects to login flow.
- **`disable_token(host: Server, token: String) -> Value`**  
  Invalidates token server-side via `/auth/logout`.

##### Utilities
- **`request_with_auth_token()`**  
  Adds `Authorization: Bearer <token>` header to requests.
- **`get_user()`**  
  Retrieves current user from request params (guest if unauthenticated).
- **`auth_server_health()`**  
  Checks `/health` endpoint (returns `true` for `{ "status": "ok" }`).

#### Security Features
1. **HTTP-only Cookies**  
   Tokens are stored with `http_only` flag to prevent XSS access.
2. **Automatic Token Refresh**  
   `/auth/refresh` maintains session validity without re-login.
3. **Server-side Invalidation**  
   Tokens are disabled on logout via auth server.
4. **Guest Fallback**  
   Unauthenticated requests return `User::guest()`.

#### Flow Example
TBD 

### Admin Endpoints

The admin surface is split by content type: `/admin/panel/*` returns HTML,
`/admin/users/*` and `/admin/admins/*` return JSON. All endpoints check
`check_is_admin` first (the current request's `UserID` must appear in
`programfiles/admin_info/admins.json`). HTML pages redirect non-admins to
`/user/unauthorized`; JSON endpoints return `401 Unauthorized`.

#### 1. Pages (HTML)

**`GET /admin/`**  
Admin dashboard landing page.  
*Renders*: `admin/index.html` with localized page chrome.

**`GET /admin/panel`**  
User-management list.  
*Renders*: `admin/panel.html`, which loads `/admin/users/json` client-side
and paginates 10 rows per page.  
*Columns*: UID, Username, Email, Active, Action.  
*Links*: per-row "Edit" → `/admin/panel/<uid>`; "Manage admins" →
`/admin/panel/admins`.

**`GET /admin/panel/<uid>`**  
Edit page for one user. Returns `404` (HTML) if the uid doesn't exist.  
*Renders*: `admin/user_edit.html` with three forms:
- Edit username / email / active → `POST /admin/users/<uid>`
- Reset password → `POST /admin/users/<uid>/password`
- Delete (`confirm()` first) → `POST /admin/users/<uid>/delete`

All three forms submit as `application/x-www-form-urlencoded` via JS and
display the JSON response inline.

**`GET /admin/panel/admins`**  
Admin-membership management page.  
*Renders*: `admin/admins.html`. Lists entries from `admins.json` and
supports add/remove via the JSON API.

---

#### 2. User Management API (JSON)

**`GET /admin/users`**  
List all locally-stored users.  
*Response*:
```json
{
  "success": true,
  "total": 12,
  "users": [
    {
      "uid": 1,
      "username": "Admin",
      "email": "admin@example.com",
      "is_active": true,
      "is_admin": true
    }
  ]
}
```
`is_admin` is computed per request from `admins.json`; it is not a stored
field on `UserStorage`.

**`POST /admin/users`**  
Create a new local user.  
*Parameters* (URL-encoded form):  
- `username`: New user's identifier  
- `email`: Email address  
- `password`: Plaintext password (hashed server-side before storage)  

*Responses*:
```json
// Success (201 Created)
{ "success": true, "username": "alice" }

// 400 Bad Request — format validation failed
{ "success": false, "message": "Username is not valid" }

// 409 Conflict — value owned by another user
{ "success": false, "message": "Email already exists" }
```
Maps `FopError` variants to: `400` (`UserNameNotValid` / `EmailNotValid` /
`PasswordMismatch`), `409` (`UserNameConflict` / `EmailConflict`), `429`
(`TooManyRequest`), `500` (anything else, logged via `tracing::error!`).

**`GET /admin/users/json`**  
Identical payload to `GET /admin/users`; kept as the panel JS's stable
endpoint name.

**`GET /admin/users/<uid>`**  
Single-user JSON.  
*Responses*:
```json
// 200 OK
{
  "success": true,
  "user": { "uid": 1, "username": "Admin", ... }
}

// 404 Not Found
{ "success": false, "message": "User not found" }
```

**`POST /admin/users/<uid>`**  
Edit an existing user. All fields optional — only present fields are
applied.  
*Parameters* (URL-encoded form, all optional):  
- `username`: New username  
- `email`: New email  
- `is_active`: `"true" | "on" | "1" | "yes"` → `true`, anything else →
  `false`  

*Same-uid uniqueness exception*: passing the user's existing username or
email is allowed (no-op for that field). A duplicate value owned by a
different uid returns `409`.

*Responses*:
```json
// 200 OK
{ "success": true }

// 404 Not Found / 400 Bad Request / 409 Conflict
{ "success": false, "message": "..." }
```

**`POST /admin/users/<uid>/password`**  
Reset the user's password.  
*Parameters* (URL-encoded form):  
- `new_password`: Plaintext password (hashed server-side)

*Responses*:
```json
// 200 OK
{ "success": true }

// 400 Bad Request (empty password) / 404 Not Found
{ "success": false, "message": "..." }
```

**`POST /admin/users/<uid>/delete`**  
Delete a user. Returns `404` if the uid doesn't exist.  
*Responses*:
```json
// 200 OK
{ "success": true }

// 404 Not Found
{ "success": false, "message": "User not found" }
```

---

#### 3. Admin Membership API (JSON)

**`GET /admin/admins/json`**  
List current admin entries.  
*Response*:
```json
{
  "success": true,
  "admins": ["1@local", "3@local"]
}
```

**`POST /admin/admins`**  
Add an admin entry.  
*Parameters* (URL-encoded form):  
- `uid`: Either `"3"` (defaults to `@local`) or `"3@local"` (fully
  qualified). Must reference an existing local user for the `@local`
  case.

*Responses*:
```json
// 200 OK — idempotent: re-adding an existing entry succeeds silently
{ "success": true, "entry": "3@local" }

// 400 Bad Request — malformed or unknown uid
{ "success": false, "message": "Local user not found" }
```

**`POST /admin/admins/<entry>/delete`**  
Remove an admin entry. `<entry>` must be URL-encoded (e.g. `1%40local`
for `1@local`).  
*Responses*:
```json
// 200 OK — also idempotent for missing entries
{ "success": true }

// 400 Bad Request
{ "success": false, "message": "Invalid admin entry" }
```

---

#### 4. Backend additions

##### `AuthManager` (in `src/local_auth/fop.rs`)

New uid-keyed admin methods. None take a caller token — gating happens at
the endpoint layer:

- **`admin_list_users() -> Vec<(u32, UserStorage)>`**  
  Enumerates all users with their uid; uid-sorted.
- **`admin_get_user(uid) -> Option<UserStorage>`**  
  Single lookup; `None` if missing.
- **`admin_edit_user(uid, new_username, new_email, new_is_active) -> Result<(), FopError>`**  
  Partial update with same-uid uniqueness exception. Holds
  `username_map`, `email_map`, `users` write locks atomically; format
  checks run before the lock acquisition, conflict checks after.
- **`admin_reset_password(uid, new_password) -> Result<(), FopError>`**  
  Empty password → `PasswordMismatch`.
- **`admin_delete_user(uid) -> Result<(), FopError>`**  
  Removes from `users`, `username_map`, and `email_map` atomically. Does
  *not* clean orphan `admins.json` entries (tracked in `TICKETS.md`).

##### `UserStorage` (in `src/local_auth/fop.rs`)

New field:
- **`pub is_active: bool`**  
  Defaults to `true` on load from disk for backward compatibility with
  pre-0.1.3 user files. Inactive users cannot authenticate via
  `/auth/login`, `/auth/refresh`, or `/users/me`.

##### `FopError` (in `src/local_auth/fop.rs`)

New variants:
- **`UserNameConflict`**, **`EmailConflict`** — value owned by another
  uid. Surfaced by the API as `409 Conflict`, distinct from the format-
  validation `400` produced by `UserNameNotValid` / `EmailNotValid`.

##### `op` module (in `src/op.rs`)

Admins are now mutable at runtime. `ADMINS` switched from
`Lazy<Value>` to `Lazy<RwLock<Value>>`, and `get_admin()` returns an
owned clone rather than `&'static Value`:

- **`get_admin() -> Value`**  
  Returns a clone of the current admin snapshot.
- **`read_admin_entries() -> Vec<String>`**  
  Convenience: the snapshot as `Vec<String>`.
- **`add_admin_entry(entry: &str) -> std::io::Result<()>`**  
  Idempotent. Persists to `programfiles/admin_info/admins.json` first,
  then updates the in-memory snapshot.
- **`remove_admin_entry(entry: &str) -> std::io::Result<()>`**  
  Same write-through ordering.

##### `RedirectNonAdmin` middleware

Unchanged. Insert after `UserFetch` in your protocol stack to gate any
custom admin route. Non-admin requests are redirected to
`/user/unauthorized`.

# APIs