<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Phase 3.75 &mdash; Authentication Controls and Password Policy
&mdash; Arc Plan</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 760px;
    margin: 1.5em auto; padding: 0 1em; line-height: 1.5; color: #1a1a1a; }
h1 { font-size: 1.4em; }
h2 { font-size: 1.1em; margin-top: 1.4em; }
ol { margin: 0.3em 0; } li { margin: 0.25em 0; }
ol.items { list-style: none; padding-left: 0; }
.check { color: #1a7f37; font-weight: bold; }
.next { color: #b35900; font-weight: bold; }
.q { color: #8250df; font-weight: bold; }
code { background: #f2f2f2; padding: 0 0.25em; border-radius: 3px; }
em { color: #555; }
</style>
</head>
<body>
<h1>Phase 3.75 &mdash; Authentication Controls and Password Policy</h1>
<p style="margin:.8rem 2rem;padding:.7rem 1rem;background:#eef2f7">
<strong>Arc Post-Mortem Summary.</strong>
A short follow-on to Phase 3 settling how an
account authenticates and what makes a password acceptable. The Security
activity's Recovery fieldset became an Authentication fieldset: pick the
sign-in method, set the password rules, configure LDAP in the panel instead
of LocalConfig.php, and choose recovery (forced off under LDAP). Other work
was folded in as it surfaced.</p>
<p><em>Arc started 2026-06-25, a short follow-on to Phase 3. Phase 3
settled how an account recovers a password; this arc settles how an
account authenticates and what makes a password acceptable. The Security
activity's &ldquo;Recovery&rdquo; fieldset becomes an
&ldquo;Authentication&rdquo; fieldset: pick the sign-in method, set the
password rules, configure LDAP in the panel instead of
<code>LocalConfig.php</code>, and choose recovery (forced off under LDAP).
Marker legend: <span class="check">&#10003;</span> done,
<span class="next">&#9654;</span> next, <span class="q">?</span> decision
to make, unmarked = planned.</em></p>

<h2>Plan</h2>
<ol>
  <li><span class="check">&#10003;</span> <strong>Rename the
    &ldquo;Recovery&rdquo; fieldset to &ldquo;Authentication&rdquo; and add
    a sign-in-method dropdown.</strong> In <code>SecurityElement</code> (with
    options from <code>SystemComponent</code>) the fieldset's first control
    chooses <strong>Yioop authentication</strong> or <strong>LDAP</strong>.
    The rest of the fieldset shows or hides on that choice the way the Mail
    Services controls already do: Yioop &rarr; the password-rule controls
    (item 2); LDAP &rarr; the directory settings (item 4).
    <br><em>Built:</em> the fieldset legend is now Authentication; a
    Locally&nbsp;Stored&nbsp;Passwords / LDAP sign-in-method dropdown
    persists as a new <code>AUTH_METHOD</code> profile setting (constants
    <code>LOCAL_AUTHENTICATION</code> / <code>LDAP_AUTHENTICATION</code>,
    defaulting to local); the recovery dropdown sits under an Account
    Recovery label and is hidden when LDAP is chosen: the dropdown's
    <code>onchange</code> calls the existing <code>toggleClass</code> helper
    to add or remove the <code>none</code> class, and the section's initial
    hidden state is a conditional class rendered in PHP, so there is no
    inline style or script. Sign-in behaviour is unchanged for now &mdash;
    <code>AUTH_METHOD</code> is read by the sign-in path in items 4-5.
    <br><em>Follow-up (pending commit):</em> registered
    <code>AUTH_METHOD</code> in <code>ProfileModel::profile_fields</code>
    and <code>profile_defaults</code> (without this the setting never wrote
    to <code>Profile.php</code> and a saved choice was lost on reload);
    renamed the constant and label from Yioop authentication to the
    layperson term Locally Stored Passwords; and added colons to the
    Sign-in Method and Account Recovery labels.</li>

  <li><span class="check">&#10003;</span> <strong>Locally Stored Passwords: configurable password rules.</strong>
    When the method is Locally Stored Passwords, the fieldset offers
    checkboxes for the requirements a password must meet (lower-case,
    upper-case, digit, symbol) and a field for a minimum length, stored in
    the admin Profile beside <code>RECOVERY_MODE</code>.
    <br><em>Built (pending commit):</em> five profile settings
    (<code>PASSWORD_MIN_LEN</code> and four
    <code>PASSWORD_REQUIRE_*</code> booleans) with Config defaults,
    registered in <code>ProfileModel</code>, saved by the Security
    activity, and rendered as four checkboxes plus a minimum-length field.
    The password rules and the Account Recovery control now share one
    <code>local-auth-section</code> div that the method dropdown shows for
    Locally Stored Passwords and hides for LDAP (item 4 adds the opposite
    LDAP section). The minimum length is clamped server-side to
    1&ndash;<code>LONG_NAME_LEN</code>. <em>Shipped defaults:</em> minimum
    length 8 and no character-class requirement checked, so upgrading does
    not make any existing member's password suddenly invalid; an operator
    opts into stricter rules. The controls only store policy here &mdash;
    item 3 enforces it in the validator and on the server.</li>

  <li><span class="check">&#10003;</span> <strong>Shared, improved password-strength check on every password
    entry point.</strong> Today only Create Account runs
    <code>register_validator.js</code> and its unit test. Make the check
    shared, enforce the item-2 rules on both sides (the JS and a single
    server-side check so the two never disagree and JS-off cannot store a
    weak password), and wire it into Create Account, Manage Account &rarr;
    Change Password, the Change Recovery Question confirm field, both
    Recover-Account new-password screens, and the admin Manage-Users
    add/edit password. The JS is updated and improved while staying plain
    self-contained JS (no outside library).
    <br><em>Done.</em> The single shared check is
    <code>L\passwordPolicyViolations($password)</code> in
    <code>Utility.php</code> &mdash; it reads the item-2 profile rules plus
    a new <code>PASSWORD_FORBIDDEN_CHARS</code> constant (the single and
    double quote by default, since an unescaped quote can break a form value
    or a query) and returns a list of unmet-rule tokens, with a unit test
    over the shipped defaults. It is enforced server-side on every path that
    sets a password: the three member self-service paths (Create Account,
    Manage Account &rarr; Change Password, Recover-Account) and now both
    admin Manage-Users paths (add-user, and edit-user when an administrator
    actually changes the password &mdash; that path overloads one error flag
    for "passwords do not match", so the rule check gets its own message).
    On the client the same check now lives in the long-standing
    <code>register_validator.js</code> (its own hard-coded strength regular
    expressions are gone), generalised so its create-account-only field
    wiring is guarded and the script is safe to load on every password form.
    A new <code>PasswordrequirementsHelper</code> draws, next to each
    password box, a plain-English sentence listing exactly the rules in
    force (smallest length, whichever character classes the operator turned
    on, and the forbidden characters), and carries those rules &mdash; plus
    the per-rule wording &mdash; to the script as plain data attributes.
    When JavaScript is running the script replaces that sentence with live
    colored feedback: green and a short "looks good" once the password meets
    every rule, or red naming just the requirements still missing; it also
    blocks submitting an unacceptable password. When JavaScript is off the
    sentence simply stays visible so the rules are still known. The same
    feedback now runs on all the password forms (Create Account, Change
    Password, Recover-Account, and the admin Manage-Users add/edit boxes),
    which load the shared script. The two server flash messages were
    shortened to "Password requirements not met." since the on-screen hint
    now spells the rules out.</li>

  <li><span class="check">&#10003;</span> <strong>LDAP settings move from
    <code>LocalConfig.php</code> into the
    panel.</strong> When the method is LDAP, the fieldset offers fields for
    <code>LDAP_ACCOUNT_SUFFIX</code>, <code>LDAP_BASE_DN</code>, and the
    server host (today <code>LDAP_CONTROLLERS</code>). The
    <code>LDAP_LOCAL_USER</code> callback becomes a real
    <code>SigninModel</code> method: given an email, return the Yioop
    username that owns it, or false. Because LDAP keys members by email,
    two accounts may not share an email once LDAP is in use; add that
    constraint and a pre-switch check.
    Done. The three settings are now profile fields with
    <code>Config</code> defaults, registered in <code>ProfileModel</code>,
    saved by the Security activity, and shown in an <code>ldap-section</code>
    the sign-in-method dropdown reveals for LDAP the same way it hides the
    local-password section. The sign-in path now gates on
    <code>AUTH_METHOD</code> rather than the old <code>USE_LDAP</code>
    constant, reads the three profile settings, and maps the email the
    directory returns through the new
    <code>SigninModel::localUserForLdapEmail()</code>, which looks the
    email up in the accounts table and returns the owning username, or
    false when no account or more than one has it. Email uniqueness is kept
    while LDAP is on: <code>changeEmail</code> refuses an email another
    account already holds, and a pre-switch scan
    (<code>SigninModel::emailConflicts()</code>) blocks turning LDAP on
    while any email is shared. The three decisions were settled as:
    the host becomes a panel field, added and removed with the same list
    control as <code>SECURE_DOMAINS</code> in Server Settings (a reusable
    <code>initManagedCsvList</code> in <code>basic.js</code> keeps a hidden
    comma-separated value in step); the <code>LDAP_USER_LIST</code> map is
    dropped in favour of the accounts-table lookup; and a blocked switch
    flashes that accounts share an email and offers a link in the panel
    that downloads a plain-text file listing each shared email and the
    usernames that share it.
    Follow-up (pending commit): the directory-server list now matches the
    Server Settings control exactly &mdash; an "&#43;" add button rather
    than the word, no placeholder text, and the shared list styling &mdash;
    and its wiring is run from <code>window.onload</code> so the add button
    works (it had been called before the page scripts loaded, leaving the
    button dead).
    Superseded by item 5: the original blocking behaviour here &mdash;
    refusing the switch and reverting to local when emails clashed &mdash;
    was replaced by the readiness gate. The switch now always saves; a
    shared email is just one of the things that keeps LDAP from becoming
    active, reported in the panel's persistent problem list (still with the
    downloadable clash file, whose first row is now an
    <code>Email</code>/<code>Usernames</code> header) rather than as a
    one-off refusal.</li>

  <li><span class="check">&#10003;</span> <strong>Prove LDAP works for
    root before committing the
    switch.</strong> A field holds the root account's email; on saving an
    LDAP switch, Yioop first authenticates root through LDAP using it, and
    if that probe fails to authenticate and match, the switch does not
    happen and the site stays on Yioop authentication, so a misconfigured
    directory can never strand the admin.
    Done, by a readiness gate rather than a blocking save-time probe. An
    operator may choose LDAP and save at any time, but LDAP only becomes
    the method actually used once it is ready; until then the site keeps
    signing members in with their local passwords, so a half-finished or
    broken directory can never strand anyone. The decision on what the
    probe submits is therefore moot: a save-time bind would need a
    one-time root password and could still leave the admin stranded if the
    directory broke later, whereas the readiness gate is checked on every
    sign-in. <code>SigninModel::ldapConfigProblems()</code> returns what is
    left to fix &mdash; no directory server, a missing account suffix or
    base DN, an email shared by two accounts, or a root account with no
    email (so an LDAP sign-in can always reach the administrator) &mdash;
    the sign-in path falls back to local passwords whenever that list is
    non-empty, and the Authentication panel shows the same list
    persistently with an asterisk on each needed field. The actual proof
    that root can bind then comes from root's first real LDAP sign-in,
    which the operator can attempt while still holding a working local
    sign-in.</li>

  <li><span class="check">&#10003;</span> <strong>Account Recovery moves
    below the authentication controls and
    LDAP forces it off.</strong> After the authentication controls, an
    <strong>Account Recovery</strong> label introduces the recovery-mode
    dropdown the fieldset has today (No recovery / Email link / Question).
    When the method is LDAP the directory owns passwords, so the recovery
    control is disabled and pinned to <strong>No User Password Recovery
    Link</strong>.
    Done. The recovery-mode dropdown was lifted out of the
    local-password section (where it had been hidden under LDAP) into its
    own labelled control below both the local and LDAP sections, so it is
    always visible. When LDAP is the chosen method the dropdown renders
    pinned to No&nbsp;Recovery and disabled, the sign-in-method dropdown
    flips it to that state live through a small
    <code>setRecoveryForLdap</code> helper as the method changes, and the
    Security activity stores <code>NO_RECOVERY</code> whatever the form
    sent so a disabled control can never leave a stale recovery mode in
    place. This completes Phase 3.75.
    Follow-up (pending commit): a disabled control submits nothing, so
    under LDAP the recovery dropdown left <code>RECOVERY_MODE</code> out of
    the request and the Security activity warned twice on the undefined
    key. Rather than have the server skip a missing field, the form now
    always sends the value: a hidden companion field carries the off
    setting while the dropdown is disabled, and the sign-in-method change
    swaps which of the two is active so exactly one submits
    <code>RECOVERY_MODE</code>.</li>
</ol>

<h2>Other work</h2>
<ol class="items">
  <li><span class="check">&#10003;</span> <strong>Restart could not
    rebind ports 80 and 443.</strong> <code>sudo php index.php
    secure-restart</code> failed to come back up, the replacement server
    logging <code>Address already in use</code> until it gave up after 120
    seconds. Background daemons are launched through <code>popen</code>,
    and a process launched that way inherits every open descriptor of its
    launcher &mdash; including the web server's listening sockets &mdash;
    so MediaUpdater kept its own copy of ports 80 and 443 and held them in
    use until it exited, which with a long job in flight was minutes away.
    A standalone test reproduced it exactly. There is no portable way to
    mark a PHP stream socket close-on-exec, and closing the numbers from
    inside the launched program does not release them, so the launch
    command now closes every inherited descriptor above standard error in
    the launching shell before the program starts; the command building
    moved into a pure <code>composeBackgroundJob</code> so it can be
    checked directly (<code>CrawlDaemonTest</code>). The closing applies on
    the shell-launched path mac and linux share; Windows starts the child
    without handing it the socket handles, so its launch line is
    unchanged. MediaUpdater also now checks for a stop request before each
    job rather than only between whole passes, so a stop is noticed without
    finishing the rest of the list. A single job that is itself long is
    interrupted too: MediaUpdater installs a stop-signal handler, and a stop
    of MediaUpdater sends that signal, so the running job is cut off and the
    daemon ends at once. This is general across every MediaUpdater job, not
    a per-job change, and is limited to daemons whose work is safe to cut
    off (an interrupted mail batch is gathered again on the next pass), so
    index-writing daemons are left to stop at their own checkpoints. Where
    the signal functions are absent, for example on Windows, the
    between-jobs check stays the fallback.</li>
  <li><span class="next">&#9654;</span> <strong>yioop.com runs out of
    memory after hours of bot traffic (open).</strong> The live server,
    which stays up across requests, grows to its 2&nbsp;GB limit over
    several hours and is killed by a fatal &ldquo;allowed memory size
    exhausted&rdquo; error. The crawler traffic that precedes it asks for
    small things &mdash; wiki history pages and short video thumbnails
    &mdash; each at a unique address (a different token, page and revision
    each time), so no single request is large; the growth is gradual and
    builds up across many requests. The error happened to land in
    <code>UpgradeFunctions.php</code>&apos;s one-row version check, but that
    check cannot need the memory it failed to get, so the landing spot is
    incidental: the process was already near the limit and that small
    request was simply the one that tipped it over. A first pass ruled out
    the obvious holders: the persistent server already releases every
    per-connection and per-request item it keeps &mdash; open streams,
    timers, the connection record, and the saved request context are all
    cleared when a connection closes or an internal request returns &mdash;
    and the request-path caches that were checked
    (<code>UrlParser</code>&apos;s site-list cache,
    <code>IndexManager</code>&apos;s url cache) each have a size cap. The
    leftover suspect is something kept in a long-lived place that survives
    from one request to the next and is added to per request without a cap;
    such a thing is harmless under the one-request-per-process model but
    accumulates in the always-on server. A single crash trace will not
    point to it, because the gradual grower is not the request that finally
    fails; finding it needs a memory-and-connection-count reading sampled
    over time as the server runs under load. An error log is now set to
    capture the next occurrence, and a switchable memory probe has been
    added to the website server to go with it. When
    <code>WEBSITE_MEMORY_SAMPLE_PERIOD</code> is set to a positive number of
    seconds &mdash; for example by adding
    <code>nsconddefine(&quot;WEBSITE_MEMORY_SAMPLE_PERIOD&quot;, 30);</code>
    to <code>src/configs/LocalConfig.php</code> &mdash; the server writes
    one line to the error log that often, giving current and peak memory in
    megabytes alongside the live counts of connections, reading streams,
    writing streams, sessions and timers. Zero or absent leaves it off, and
    the per-pass timing check was measured to cost nothing noticeable.
    Reading those lines over a few hours should show whether memory climbs
    while the connection and stream counts stay flat, which would point at
    something held across requests rather than a connection that is never
    let go, and narrow down where to look next.</li>
  <li><span class="check">&#10003;</span> <strong>One test file per
    subject class.</strong> The tests for the atto MailSite server had
    grown into seven separate files &mdash; its command fibers,
    cooperative file locking, fetch memory use, the TLS handshake reaper,
    MIME parsing, standard-folder provisioning, and UID-restricted
    expunge &mdash; each with its own probe and set-up. They are now one
    <code>MailSiteTest</code> with a single test class. Because the
    groups had used the same property names for different things (the
    probe was four different kinds; the mail server and storage were
    built two ways), each group&apos;s set-up became a small helper its
    own cases call first, so nothing collides and every case keeps its
    original behaviour; all twenty-six pass. The same was done for
    <code>MailSiteFactory</code>, whose address-permission rule and
    outbound mail spool were in two files and are now one
    <code>MailSiteFactoryTest</code> of twelve cases. Tests that look
    related by name but cover their own class are left alone: the six
    <code>Imap</code> parser and client tests, <code>MailSiteMailBackend</code>,
    and the cooperative scheduler test, which borrows a website-shaped
    probe but tests the scheduler.</li>
  <li><span class="check">&#10003;</span> <strong>Two errors the live
    error log showed once memory sampling was turned on.</strong> First, a
    misnamespaced exit: <code>SearchController</code> made seven
    <code>webExit()</code> calls that resolved to
    <code>seekquarry\yioop\library\webExit</code> (five written out that
    way in full, two through the library alias) instead of the atto
    <code>webExit</code> that actually exists, so each of those paths died
    with &ldquo;call to undefined function&rdquo; &mdash; the deferred-search
    path among them. They now call the atto <code>webExit</code>, matching
    the one already-correct call in the same file. This shows up on
    yioop.com because it is the search-engine install and leans on
    <code>SearchController</code> heavily, where the wiki sites rarely reach
    that path. Second, a stray bare header: the WebSocket-upgrade
    &ldquo;501 Not Implemented&rdquo; reply in <code>WebSite::process</code>
    called PHP&apos;s own <code>header()</code> rather than the server&apos;s
    header method. In the always-on server the one-time startup banner has
    already begun output, so a bare <code>header()</code> warns
    &ldquo;headers already sent&rdquo; and the status never reaches the
    client. It now goes through the WebSite header method, which buffers to
    the socket in CLI mode and falls back to PHP&apos;s <code>header()</code>
    only in SAPI mode &mdash; the single place that fallback belongs.
    PHP&apos;s <code>header()</code> is never to be called directly in
    WebSite or Yioop code.</li>
  <li><span class="check">&#10003;</span> <strong>A crashed or Ctrl-C&apos;d
    web server blocked the next start.</strong> Two symptoms: after a
    <code>secure-terminal</code> run was stopped with Ctrl-C it would not
    start again until a separate <code>secure-stop</code> was run first,
    and on yioop.com (where the out-of-memory fatal had killed the server)
    <code>secure-start</code> quietly started nothing. Both came from how a
    running server is recognized. The start refusal leaned on a fifteen-
    minute timestamp window, and a lock written without the server&apos;s
    process id reads as &ldquo;still running&rdquo; because the liveness
    check cannot tell &mdash; so a fresh lock left behind by a server that
    had just died looked alive and blocked a new start for up to fifteen
    minutes. Now every lock write records the process id, and a new
    <code>lockHeldByLiveProcess</code> decides held-or-not from that id
    directly: a server that crashed or was Ctrl-C&apos;d is seen as gone at
    once, with the timestamp window kept only as a fallback for older locks
    or hosts without the POSIX calls. That fallback window was still the
    full fifteen-minute <code>PROCESS_TIMEOUT</code>, far too long: an
    id-less lock left by a server that crashed before this change (the one
    stuck on yioop.com among them) read as alive and blocked the next start
    for up to a quarter hour. It is now a short one-minute
    <code>STALE_LOCK_TIMEOUT</code>, so an id-less stale lock clears within
    a minute. A start or terminal run that finds a
    stale lock removes it and proceeds; one that finds a genuinely live
    server prints a clear &ldquo;already running&rdquo; message and stops,
    so a refusal is never silent. The terminal run now makes this check
    too, so a Ctrl-C&apos;d server&apos;s leftover lock no longer needs a
    separate stop. The same recorded id lets the status report drop a
    crashed daemon immediately rather than after fifteen minutes.</li>
  <li><span class="check">&#10003;</span> <strong>Backgrounded daemons
    died at launch on Linux.</strong> After the recent change that wrapped
    the daemon launch line in a shell loop closing every inherited file
    descriptor (meant to stop a restarted web server from holding the old
    listening socket), <code>php index.php start</code> and
    <code>secure-start</code> would print their startup lines and then
    leave nothing running &mdash; the web server never came up, with no
    error shown. The child was healthy when run in the foreground, so the
    launch path itself was at fault. The blanket close walked descriptors
    3 upward and closed the ones the launching shell relies on, so on the
    dash shell that is <code>/bin/sh</code> on many Linux installs the
    shell died part way through the loop and never reached the step that
    starts the program; macOS, whose <code>/bin/sh</code> is bash, did not
    trip on it, which is why only Linux broke. The descriptor-closing loop
    has been removed and the launch is the plain background start it was
    before. Keeping a restarted server from inheriting the old socket is a
    separate task to solve at the socket itself rather than by closing
    descriptors blindly in the launch line.</li>
  <li><span class="check">&#10003;</span> <strong>A worker spawned by the
    running web server no longer holds its listening socket across a
    restart.</strong> With the dash-fatal descriptor loop gone, the
    leftover task &mdash; stopping a spawned daemon from keeping the web
    server&apos;s socket open &mdash; is solved in the launch itself. A
    program launched from the running server inherits its open listening
    sockets, so until the worker exits the port stays in use and a restart
    cannot rebind it. PHP cannot close a descriptor by its number, and on
    dash the shell cannot safely close them either, but a forked child
    holds its own copy of every open stream as a PHP resource;
    <code>get_resources</code> lists them. The launch now forks, and the
    forked child closes the inherited listening sockets &mdash; then becomes
    the program through a shell that reads nothing from input and sends
    output to the requested file. Two cautions learned on the live
    Postgres server, neither visible against the laptop&apos;s SQLite.
    First, only <em>listening</em> sockets are closed, never connected
    ones: a socket with no peer is a listener and safe to drop, but
    <code>fclose</code> on an inherited connected link (a request in
    flight, or the database connection) sends a shutdown notice on the
    socket the launching server still shares, which on yioop.com closed the
    server&apos;s own Postgres link mid-spawn and every following query
    failed with <code>SSL connection has been closed unexpectedly</code>.
    Second, the brief helper processes the two forks create are ended with
    an immediate kill rather than a normal exit, because a normal exit runs
    PHP&apos;s shutdown and the inherited database handle&apos;s destructor
    would close that same shared connection. Closing the three standard
    streams from inside PHP was also tried and quietly broke the index
    server (it exited inside its listen call with no message because its own
    error stream had been closed), so the shell does the redirect instead.
    Two forks with a new session in the middle let the launcher return at
    once and leave no defunct entry behind. Where the process-control
    functions are absent (Windows, or a build without <code>pcntl</code>)
    the plain background launch stays the fallback. A standalone test holds
    a listener and a connected socket, forks a worker, and confirms the
    listener is dropped while the connected socket keeps working and a fresh
    process rebinds the freed port; start, stop, and restart each come up
    and serve.</li>
</ol>
</body>
</html>
X