Absorb the README's deep-dive content ahead of the README slim-down
The project README is being reduced to a landing page; every fact it
held that the wiki lacked is merged here first (615 lines across 33
pages): the full email-threading & reply-flow design onto Tickets,
directory layout + module-page pattern + file-locations reference onto
Architecture, per-endpoint tables onto API Reference, MFA login flow +
LDAP subsection + injection/audit notes onto Security, the icon library
and editor internals onto Network Mapper, Branding + landing-page and
help/search internals + debug-tool depth onto System, planned objects
onto CMDB, custody/locations/warranty onto Assets, and more.
Also corrects stale claims found during the audit: the Knowledge
audience table still said customer-visible articles had no reader
(the portal Knowledge section shipped), a pre-#903 Portal-Privacy
ambiguity row, the Workflows trigger-wiring section (catalogue spans
every module now), the superseded System module-access bullet, the
Multi-Tenancy users page marked "Planned" for a shipped feature,
and the locale count (21 languages incl. Ukrainian, not 20).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1a1e5a4
LDAP wiki: login-decision flowchart, and every fix from a code audit
Adds the diagram that was missing: a mermaid flowchart of how the login decides
between local, directory and single sign-on — the two moments the decision
happens (leaving the first box, then pressing Sign in), all four outcomes
including the OIDC-pinned rejection, the group gate, and which directories get
tried for an unknown user.
Audited both pages against the code. Corrected:
FALSE
- "db_verify only ever ADDS columns, never MODIFYs" — true of the $schema
array, not of db_verify; there are seven probe-then-MODIFY precedents, two of
them added last week. The page then contradicted itself by documenting those
two. Replaced with the directional rule: relaxing is safe, tightening isn't.
- A dead line reference (db_verify.php:2614 — the file is 2557 lines) and the
wrong path for $schema (it lives in includes/db_verify_schema.php).
- "covered by an explicit unit-style assertion" — no such test existed. Rather
than soften the sentence, the test now exists (tests/ldap/), and the note
says to go and look before writing that phrase again.
- "an account with no email -> JIT must refuse cleanly" — it provisions them;
that is the whole point of #872/#902.
- "set default_modules so auto-created people aren't handed the keys" — they
were handed the keys. Now fixed in code (#904) and documented, including
that existing accounts are not retroactively restricted.
INCOMPLETE
- The two logins were described as identical. They differ in five ways; there
is now a table. Includes the wrinkle that the analyst lookup is by username
only, so an LDAP analyst typing their email takes the JIT branch.
- The immutable-ID promise omitted the DN fallback, which has exactly the
orphaning consequence the sentence says can't happen.
- The empty-login guard, the retained-secret-on-protocol-switch behaviour, the
LDAPS and phpldapadmin ports, NOCOMPLEXITY, and the OpenLDAP half of the
seeding (missing it produces trap 3's misleading error).
- w.noemail's NW-Sales membership, which is what makes the portal journey
testable at all.
2ba2475
LDAP wiki: portal directory sign-in, and staff with no mailbox
LDAP-and-Active-Directory said a directory account with no email "cannot be
auto-created" — false since #872 for analysts, and false for the portal since
#902. Replaced with a section covering what actually happens for mailbox-less
staff: username sign-in, company from the provider, name-not-address in the
inbox, notes instead of replies, and the multi-company username limit.
Developer guide gains a Portal directory sign-in section: why the candidate
provider list is SCOPED (spraying a password across every client's DC, and
AD lockout counters, are invisible at N=1), the three deliberate divergences
in ldapResolveUser (NULL not '', claim-don't-refuse, company from provider),
why both groups may use the portal, the schema table, and the two things that
broke elsewhere — the privacy policy failing open on a blank address, and
reply having no address validation at all.
Known-gaps list updated: the portal item is done; the multi-company bare
username limit takes its place.
78f094c
Portal wiki: recording on reply, and the second write-it-once rule
Screen Recordings now covers both entry points (raise and reply), the shared
component behind them (screen-recorder.js + record-modal.php + $needsRecorder),
the two deliberately opposite unclaimed-recording behaviours (cancel discards,
send blocks), and ticket_recordings.email_id — which message a recording came
with, NULL meaning the opening one.
Developer guide gains 2e: the claim ownership guard as a second instance of the
2c argument, generalised to "if the two copies disagree, does anything break?
if one merely permits more, write it once". Later sections re-lettered.
Noted the analyst reading pane still ignores email_id — nothing hidden, but a
reply's video shows on the original message too.
19a6e08
Portal wiki: bring the four pages up to the redesigned portal
The pages described the pre-redesign portal: a nav with New Ticket and
Request Something in it, a page-per-ticket, an inline recording panel, and a
guide called "Using the portal".
Now documented: the four-destination nav and why raising a ticket is not in
it, the dashboard's four bands including popular articles, the compose screen,
the two-pane My Tickets, ticket.php as a redirect, the recording modal, and
Help's nine sections.
Developer guide gains the rule the popular-articles work produced — a
visibility rule gets written once (portal_reader.php), with the two knowledge
traps demoted to consequences of it — plus ORDER BY allow-listing and the
three layout traps: someone else's stylesheet may own your prefix, full height
is a flex chain not arithmetic, and specificity beats intent.
0260c62
Self-service: comprehensive, up-to-date wiki with file tables
Self-Service-Portal.md had drifted badly — no mention of the palette or
dark mode, the requester's company, replies, attachments both ways, the
Help Centre, the request catalogue, privacy or reopen-on-reply. Rewritten
as the section hub, keeping the screen-recording and SSO detail that was
still accurate and restoring the help.php and avatar-menu sections.
New pages:
- Portal-Request-Catalogue — offering forms to customers, what happens to
a submission, the version/visibility rules, a worked example, and the
limitations (no per-company catalogue, no uploads, no approvals)
- Self-Service-Developer-Guide — the rules and traps, every one of which
actually bit: the requester/analyst id-space split, server-derived
scope, knowledge's INVERTED tenant rule, published vs archived,
not-found over forbidden, hiding-a-link-is-not-enforcement, and the
fail-closed-on-identity / fail-open-on-visibility asymmetry. Plus the
nowdoc trap that killed a page's JS for two releases, and why rendered
markup is not proof the JS ran.
Every page now carries a colour-keyed table of the PHP/JS files involved
and what each does, per the pattern set in Multi-Tenancy-Developer-Guide
§1a. The tables were built by enumerating the actual repo, not from
memory — all 21 api/self-service endpoints, the 10 pages, the shared
chrome, and the shared logic outside the portal folder.
Verified: every wiki link in these five pages resolves, and every repo
file named in them exists on disk.
Cross-linked from Forms and Knowledge (the two modules whose content the
portal now surfaces) and added to the sidebar.
3ce7980
Portal Help Centre: new page
Covers publishing articles (the Help Centre starts empty by design, since
every article defaults to analysts-only), the three visibility levels and
what is never shown, ticket deflection, and a worked example on a
multi-company system.
ca14e04
Portal privacy: new page explaining the three options with worked examples
A ticket carries correspondence that isn't the requester's — forwards to
suppliers, copied-in colleagues, and their replies. The new Tickets ->
Settings -> Privacy tab decides how much of that the self-service portal
shows, and the rule has edge cases (chat channels exempt, ambiguity
resolves to visible) that are hard to hold in your head, so this walks
through six worked examples.
Cross-linked from Self-Service-Portal, Tickets and the sidebar.
d19930f
Database Verification: document the column drift guard (#884)
Rewrites the section that said columns were unguarded — accurate when written
yesterday, made false by building the guard. Now covers why columns are the
harder case (both files hand-maintained, so drift runs in two directions and
each direction breaks a DIFFERENT install), the #879 bug that prompted it, how
the check works, and where $schema now lives.
Emphasises that the comparison is deliberately forgiving — base type, length
and nullability only, ignoring DEFAULT/case/whitespace — because a guard that
cries wolf gets ignored. The noise-resistance test is called out as
load-bearing rather than incidental.
Records what is STILL unguarded: foreign keys, built from explicit FK groups
with nothing comparing them to freeitsm.sql. Flagged as the next self-check.
Also notes you can run the whole of Database Verification headlessly against a
scratch database ($_SESSION['setup_access'], DB_NAME override) and that php -l
proves nothing here, since a fatal in this endpoint is served as HTTP 200.
Multi-Tenancy dev guide + CMDB case study: file maps updated for $schema's move
to includes/db_verify_schema.php, and the "columns have no guard yet" line in
the case study corrected — written an hour before the guard existed.
7342983
Two new multi-tenancy dev pages, and make file involvement explicit
Ed's feedback: the CMDB/Network Mapper work was substantial enough to deserve
proper documentation, and dev guides should make it much clearer WHICH php/js
files are involved. Fair - the guide named files in prose but had no scannable
map, so it couldn't actually be worked from.
NEW Multi-Tenancy-CMDB-Case-Study: the worked example for a module whose rows
LINK to each other. Why a graph differs from a list (three link mechanisms, all
separate holes); the no-shared-CIs decision and what it forced; the core lesson
that a same-company invariant must bind ALL-ACCESS actors because a scope check
cannot help someone who legitimately reaches both sides; a full colour-keyed
file map; the Network Mapper boundary and why get_diagram.php is deliberately
unfiltered; what stayed shared and why; the move-between-companies gap.
NEW Multi-Tenancy-Test-Harness: the reusable verification kit, roughly 20 lines
from module-agnostic. Leads with ALWAYS INCLUDE A POSITIVE CONTROL, because
breaking that rule produced a green suite that proved nothing. Also the three
tricks that make CLI testing work (DB_NAME defined first, session established
before the endpoint's own session_start, php:// wrapper override for the empty
php://input), the static tenant-count cache forcing separate processes, the
fixture shape, and what to assert.
Developer guide gains section 1a "The files you will touch" - a colour-keyed
table of the ~10 places every module needs, each with what you do and whether
it is skippable. Plus the point that a correctly-scoped module needs NO
JavaScript changes at all (CMDB changed zero lines), and if you are editing JS
to hide rows you are filtering in the wrong layer. The Reference section is now
a "copy from these" table pointing at shipped examples per shape.
Also fixed a pre-existing broken link (db-verify) and pointed two #882
references away from the user-facing Pitfalls page.
ce20284
CMDB is now multi-company; record the invariant and two testing lessons
Multi-Tenancy-Progress: CMDB marked done, with the two deferrals written down
- moving a CI between companies (and the shape it would take), and the
deliberate Network Mapper boundary where diagrams stay unfiltered.
CMDB page: a Multi-company section - one CI, one company, no shared CIs, what
that implies for parent/relationships/object_ref, what stays install-wide
(classes, properties, relationship types, icons) and why, and the move-to-
company gap.
Developer guide:
- new special case for a graph of inter-linking rows. The headline is that a
same-company invariant must bind an ALL-ACCESS actor too - a scope check
can't help when the actor legitimately reaches both sides - and that a link
leaks even when the far row is unreadable.
- ALWAYS INCLUDE A POSITIVE CONTROL. "Was it blocked?" assertions pass just as
happily when the request was broken as when it was refused. php://input is
empty under the CLI SAPI, so a whole write suite failed validation before
reaching any tenancy check and every isolation assertion went green - nine
checks proving nothing. The tell was the positive controls failing too.
- drive the real endpoint rather than a copy of its SQL, and why: parameter
ORDER (SELECT-subquery and JOIN...ON fragments bind before WHERE) is the bug
class a reimplementation can never catch.
82bf4d0
Document the connection shape, the NULL-semantics trap and fresh-install drift
Multi-Tenancy-Developer-Guide:
- section 1 now documents THREE table shapes, not two. Connections (mailboxes,
messaging channels, web chat widgets) are a distinct shape where NULL means
shared intake, not Default-owned, and the list is deliberately install-wide.
Flags that using activeTenantFilter here would hide shared-intake channels
and break per-sender routing.
- notes the credentials exception to "caps guard writes not reads".
- adds analystCanAccessChannel / analystCanAssignTenant to the toolbox.
- verify section: N=1 and N=2 MUST run in separate processes because
tenantCount() memoises in a static; plus the scratch-database fixture
recipe, which doubles as a fresh-install test.
- checklist: name what NULL means; assert the two schema files agree; validate
a client-supplied company against the actor's access rather than existence.
Database-Verification-Developer-Guide: new section on the gap the index drift
guard doesn't cover — columns. Uses the asset_locations.tenant_id bug as the
worked example and explains why it survived review (development only ever
exercises the upgrade path, so fresh-install drift is invisible until someone
downloads it). Suggests extending the self-check to columns.
Multi-Tenancy-Progress: Knowledge and messaging/web chat marked done, with the
open tenant_channel_senders UI gap and the webchat routing asymmetry recorded.
73214be
DB Verify guide: document the index-list drift guard
Add the "you can't forget to regenerate" section: dbVerifyIndexListSelfCheck()
re-parses freeitsm.sql on every verify run and fails loudly when the
generated list has drifted, with the same parser generating and auditing
the list. This is the real answer to "how do we avoid this drift again" —
enforcement, not discipline.
7304314
Add Database Verification developer guide
How the DB Verify page is built and maintained: the mutating Verify flow
vs the read-only Describe flow, how tables are filed under modules
(derived from name, how to handle multi-module tables), the index
backfill and the duplicate-blocks-a-unique behaviour, and why we don't
check against an exported "golden" database. Complements the existing
Database Integrity page (FKs, delete rules, orphans).
40b1959
Knowledge + Web chat: document article visibility (company + audience)
Web-Chat.md was actively WRONG in three places — it carried a caveat saying the
AI "draws from all published articles regardless of the widget's company" and
listed per-company scoping as a future item. That shipped; the page hadn't.
Knowledge.md gains a "Who can see an article" section (the two editor settings
in plain terms, and the question this will generate: why the chat widget goes
quiet on upgrade — every article defaults to Analysts only, deliberately), plus
a developer note on the trap: tenant_id IS NULL means "shared with EVERY
company" here, the OPPOSITE of tickets/assets, so activeTenantFilter() and
apiKeyTenantFilter() must never be used for Knowledge — both would silently hide
every shared article, with no error and green tests.
feddc0f
Add "Setting up OpenLDAP & Samba AD in Docker" walkthrough
A copy-paste guide from nothing to a working LDAP login, mirroring the
Keycloak (Docker) page for SSO: run both directories, seed Samba AD with a
realistic company (nested OUs, a no-email account, a disabled leaver, nested
groups), grant the OpenLDAP ACL that everyone trips over, configure the
provider, and test the group gate with four correct passwords that produce
four different outcomes.
Linked from the sidebar and cross-linked from both LDAP pages.
5efc1be
Add LDAP & Active Directory pages (feature + developer guide)
Covers the LDAP sign-in shipped for issue #47: the admin-facing guide
(setup, group-based access, troubleshooting) and the developer deep-dive
(architecture, schema, the two-step bind, the traps, the Docker test rigs).
Both linked from the sidebar under Single Sign-On.
9180ff2
Web chat dev guide: document the poller ↔ optimistic-echo reconciliation
The Delivery section named polling but not its hazards. Rewrite it to explain
the two independent bubble-writers (poller + optimistic echo), the lastId
cursor, the send.php msg_id contract, and the sendInFlight / pollInFlight
guards — the exact duplication traps hit during the part-3 build — plus the
typing indicator. Add both to Gotchas along with the webchatAddMessage /
lastInsertId ordering trap.
059e84a
Web chat part 3: mark AI answers, office hours + escalation as live
Runtime is wired (send/poll/config/escalate/widget.js). Update both the
feature page and the developer guide: office hours, AI (assist/deflect) and
escalation now live; only offline-email *delivery* remains. Rewrote the
Developer Guide resume plan down to that single piece and noted the
source_email_id / Database Verify gotcha.
8ca45d3
Add Web chat channel wiki: feature page + developer guide
Covers the embeddable website chat widget: end-to-end setup + embed steps,
every setting, the public-key vs per-conversation-token security model,
origin allowlist (+Referer fallback), rate limiting, the email direction
(sends from the company mailbox, never spoofs the visitor), multi-company,
the channel reuse (FreeitsmProvider + ingest), data model, public endpoints,
the AI-answers/office-hours/email-when-away design, a roadmap of what's still
to build, and a developer resume plan. No secrets/keys included.
74648b8
Multi-Tenancy: locations are purely per-company (correct Settings/Progress/Dev Guide)
7b423f9
Multi-Tenancy: add Developer Guide (make a module company-aware); assets in Progress + Settings
4324fd2
Add Mobile: Techniques & Tricks page (CSS/JS deep-dive); cross-link + refresh overview
c331f6e
Mobile Tickets: sticky day-heading polish (#855)
ae6da9d
Mobile Tickets: audit history now a full-screen sheet (#855)
d6313a4
Mobile Tickets: audit history day-grouped feed (#855)
7c81c5e
PHP-Enums-and-RBAC: describe the code that exists, not the code that used to
Ed's catch, and a fair one — the page was comparing enums against a string-keyed
design that no longer exists. It quoted four functions that have been deleted
(rbacCapabilities, rbacAllCapabilityKeys, rbacCapabilityExists,
rbacCapabilityLabel), invented a capability that was never shipped
(tickets.manage_settings), and presented all of it under "what we built last week".
A wiki page should describe the actual code.
Rewritten against the codebase as it stands: Cap:: constants, and a registry
DERIVED from each module's settings manifest. Every symbol and every number on the
page verified against the source — 76 capabilities, 76 constants, and the guard's
parameter really is still `string`.
The rewrite also forced an honest walk-back the old page never made. Its central
claim was that enums would collapse four hand-maintained lists (constants,
registry, tab layout, setting-key map) onto one type. THOSE LISTS WERE ALREADY
COLLAPSED — without enums — by deriving the registry from the manifest. So the
headline benefit is banked, and the metadata arguably lives somewhere better: next
to the tab it belongs to, rather than on the type.
What genuinely remains is one narrow thing: a guard whose signature could refuse a
string outright. requireCapabilityJson(string $capability) still accepts one; the
constant makes the right thing easy and a typo loud, but it cannot make the wrong
thing impossible. That is a real gap, and it is now the ONLY one — which is exactly
why the floor stayed at 7.4.
Also corrected two claims on the floor page that had aged with it: it still called
the enum "the only correctness argument on offer" (it isn't — the constants
answered that), and its phase-2 plan still listed the deleted helpers among the
things the swap would remove.
664d740
RBAC wiki: verified against the code, and two stale claims corrected
Checked every claim the Roles pages make against what the code actually does —
not by reading the wiki back to myself, but by generating the truth from
capabilities.php / the manifests and comparing:
16 modules ✓ 76 capabilities ✓ 143 guarded endpoints ✓
every module has a <module>.manage umbrella ✓
the umbrella satisfies every capability in its module ✓
9 personal-preference tabs declare no capability ✓
an undefined key grants nothing ✓
module 'system' resolves to is_admin ✓
the registry is derived from the manifests ✓
every per-module capability count and every 'sensitive' badge ✓
Two things were stale, both now fixed:
1. The setting-key list in settings_keys.php was described as "a residual list of
modules not yet converted, which shrinks to nothing". It doesn't: every module
IS converted, and what remains is the System module's keys — permanently, by
design, because System is deliberately administrator-only. Corrected in the
wiki and in the code comment.
2. The "guard writes, never reads" rule appeared WITHOUT its credentials exception
on both Roles & Permissions and Security. Only the dev guide carried it. Since
that exception exists precisely because the rule was applied too comfortably —
get_mailboxes was a "read", and it shipped the plaintext OAuth secret to every
analyst — stating the rule without it is the dangerous half.
Also documented two manifest features that had gone in late and never been written
up: a tab may carry a plain 'label' instead of 'label_key' where a module isn't
translated, and renderSettingsTabBar() takes the tab-switcher's function name
(Problem Management's is pmsTab(), and its panels are id="tab-x" not "x-tab").
A converter that assumes the conventions renders a tab bar whose tabs open nothing.
Plus the full field reference for a manifest tab, and the table of shared endpoints
that need per-key authorisation — a recurring shape, not a one-off.
8276a93
RBAC roll-out complete: all 16 modules, and the rules it taught us
Roles & Permissions: the roll-out table is now the finished picture — 16 modules,
76 capabilities, 143 guarded endpoints, with the sensitive ones marked. Records
why the System module is deliberately NOT converted: it is ~20 areas, every one of
them administration by definition, with no operational half to separate out —
splitting it would create permissions only administrators would ever hold, which
is what is_admin already means.
Developer Guide — the six rules the roll-out taught, promoted above the mechanics
because they matter more:
1. A read that returns CREDENTIALS is not an operational read. get_mailboxes was
a "read", and it shipped the plaintext OAuth client secret to every analyst.
2. A config change offered as a shortcut from an operational screen still needs
the config permission (Kanban columns; CMDB property options).
3. Creating is not always administering (create a tag inline: everyday; delete
one: administration).
4. An endpoint serving several tabs cannot have one guard — authorise per
setting key. A recurring shape, not a one-off.
5. A feature reachable from several modules needs requireAnyModuleAccessJson().
6. Converting a module is an AUDIT of it. Expect to find, not merely to move.
Two new traps, both real:
- CRLF. A guard-insertion script searching for "}\n" found nothing in the CRLF
files, strpos returned false, false+2 = 2, and the guard was spliced into the
middle of "<?php" in ten files — which STILL PASSED php -l, because short open
tags made "<?" valid. The HTTP test caught it, the linter did not.
- The audit can hide a hole as well as find one. D005 reported db_verify — the
endpoint that creates every table in the database — as "signature
authenticated", because it scanned for the word relay_secret and db_verify
declares a relay_secret COLUMN. Match the call, not the word. And check your
write-detection covers what you think: the same tool called CREATE TABLE a read.
2627102
Roles dev guide: the guard that fatals, and why your test says it passed
Add requireCapabilityJson() but forget to require rbac.php and the endpoint
doesn't refuse anyone — it dies with "Call to undefined function". That is worse
than no guard: it looks protected and is broken for everyone, admins included.
And PHP serves a fatal as HTTP 200, so a status-code check reports a cheerful
PASS on a completely broken endpoint. That is exactly how it slipped through on
Change Management (#840): one endpoint requires its includes with __DIR__ rather
than a relative path, so the automated insertion missed it, and the test agreed.
Documents both defences: D005 now catches it statically and reports it as BROKEN
above everything else, and the testing section now says in terms — check the
response BODY, never the status code alone.
441f68b