Skip to content

Self Service Developer Guide

Ed Mozley edited this page Jul 19, 2026 · 4 revisions

Self-Service Developer Guide

How to extend the portal without breaking it. Every trap below is one that actually bit β€” several of them shipped.


1. πŸ“ The files you will touch

Adding a page to the portal touches the same handful of places every time.

Colour key: πŸ–₯️ page Β· 🧩 chrome Β· πŸ”Œ API Β· βš™οΈ shared logic Β· πŸ—„οΈ schema Β· 🌍 i18n Β· πŸ“„ docs

🎨 File What you do there Skippable?
πŸ–₯️ self-service/<page>.php the page: $pageTitleKey, $activeNav, $pageData, $pageStyles, $pageScripts, then include header + footer Never
🧩 self-service/includes/header.php one entry in the nav array Never β€” it's the only place nav exists
πŸ”Œ api/self-service/<verb>_<thing>.php the endpoint(s), gated on $_SESSION['ss_user_id'] Never
βš™οΈ includes/<shared>.php any rule shared with the analyst side β€” put it here, not in both Only if the rule is genuinely portal-only
πŸ—„οΈ database/freeitsm.sql + includes/db_verify_schema.php new columns, in both Never β€” a drift guard fails loudly if they disagree
πŸ—„οΈ includes/db_verify_indexes.php regenerate with scripts/gen_db_verify_indexes.php β€” don't hand-edit Only if you added an index
🌍 lang/en/self-service.php + lang/pt-BR/self-service.php every string, in the same commit Never β€” EN-only keys drift silently
🎨 assets/css/self-service.css anything shared between pages Only if it's genuinely page-specific
πŸ“„ CHANGELOG.local.md, README.md, this wiki log it Never

What you don't touch

The nav. It's one array in header.php. If you find yourself editing four pages to add a link, stop β€” that's the pre-#885 layout, and the whole point of the shared chrome was to end it.


2. πŸ”‘ The rules

2a. The requester is not an analyst

They are different id spaces. users.id and analysts.id overlap numerically and mean entirely different people.

Analyst Requester
Table analysts users
Session $_SESSION['analyst_id'] $_SESSION['ss_user_id']
Context ActorContext::fromSession() not usable β€” it reads the analyst session

πŸ”΄ The one that nearly shipped. form_submissions.submitted_by has no foreign key and every reader LEFT JOINs it to analysts. Writing a requester's id there would have silently attributed a customer's request to whichever analyst shared that number. The fix was a separate column (submitted_by_user_id), not a shared one.

Before you store "who did this", ask which id space the column means. If a reader joins it to analysts, a requester's id does not belong in it.

2b. Scope is derived on the server, never accepted from the request

Two things decide what a requester may see, and neither is a parameter:

Scope Where it comes from
Audience (Help Centre) hard-coded to Audience::CUSTOMER in the endpoint
Company looked up from users.tenant_id (the session holds only ss_user_id)

Whoever is asking does not get to declare how trusted they are. The web chat reader (includes/webchat/ai.php) set this rule; the portal follows it.

2c. Knowledge scoping is inverted β€” this has bitten twice

Module tenant_id IS NULL means
tickets, assets, changes unassigned β†’ treat as the Default company's
knowledge_articles shared with EVERY company
// βœ… correct for knowledge
[$sql, $params] = knowledgeTenantFilterForCompany($conn, $userTenantId, 'a');

// ❌ makes every shared article vanish for a non-Default company
[$sql, $params] = activeTenantFilter(...);

2d. Published β‰  visible

Archiving an article does not unpublish it. Check both:

is_published = 1 AND (is_archived = 0 OR is_archived IS NULL)

Omitting the archive half is exactly how deleted articles once reached anonymous web chat visitors.

2e. Return "not found", never "forbidden"

Apply the scope in the query, so an item the requester may not see is indistinguishable from one that doesn't exist. Every portal endpoint returns the same flat not found β€” no existence oracle.

2f. Hiding a link is not enforcement

If a thing has a URL, the URL must enforce the rule β€” not just the listing that links to it.

Attachment ids are sequential and trivially guessable. get_attachment.php therefore applies the privacy policy independently of get_ticket_detail.php. Omitting a link while the URL still serves the file is decoration.

2g. Fail closed on identity, fail open on visibility ambiguity

These pull in opposite directions and both are deliberate:

  • Identity / permission β€” unknown means no. A new form isn't in the catalogue; a new article is internal.
  • Message visibility β€” genuinely undecidable means show it. A message with no recipients recorded stays visible, because hiding an analyst's real reply leaves the customer believing nobody answered, which is worse than showing them a forward.

The strict direction is something an admin chooses, never something the code guesses.


3. πŸͺ€ The traps

The nowdoc trap

$pageScripts is built with a nowdoc (<<<'JS') so JS template literals (${...}) survive PHP. The cost: PHP tags inside it are not executed and reach the browser verbatim.

$pageScripts = <<<'JS'
const TICKET_ID = <?php echo $ticketId; ?>;   // ❌ emitted literally
JS;

That stray < is a syntax error at the top of the block, so the entire page's JavaScript fails to parse β€” no functions defined, nothing runs. It shipped like that for two releases on ticket.php, showing only "Loading ticket…" and a lone SyntaxError.

$pageData = ['ticketId' => $ticketId];        // βœ… PHP side
// …then in the nowdoc:
const TICKET_ID = window.PAGE.ticketId;

footer.php now emits a console.error if a raw PHP tag ever appears in $pageScripts again.

Verifying a page actually works

Rendered markup is not proof the JavaScript ran. Grepping the HTML for your new function names finds them happily inside a script block the browser never parsed.

Parse-check what the browser actually receives:

  1. curl the rendered page with a portal session
  2. Extract the real inline <script> (don't retype it β€” extract it)
  3. Wrap it in a harness with stubs (window.PAGE, window.t, API_BASE) and window.onerror
  4. Run headless Chrome --dump-dom, assert no error and that each function is typeof === 'function'
  5. Add a negative control β€” re-break it and confirm the harness fails. A test never proven to fail proves nothing

Theming: the shared stylesheet is only half the job

self-service.css being tokenised does not mean the portal is themed. Each page carries its own $pageStyles, and those are separate β€” which is how the portal shipped with a dark background and white cards sitting on it.

Use the tokens (--surface, --surface-hover, --text, --text-muted, --border, --ss-accent, --danger-*, --success-*) and check every var(--x) exists in theme.css. A phantom token fails silently to its fallback and looks like it worked.

Pre-auth pages (login, register, verify-email) are light-only by necessity β€” the palette lives on the user record.

Untrusted HTML

Anything a customer can see that someone else wrote goes through safeHtmlFragment() (assets/js/safe-html.js) β€” email bodies and knowledge articles alike.

Stripping <script> is not enough. Scripts genuinely don't execute via innerHTML, but <img src=x onerror=…> fires the moment it's inserted, and Shadow DOM isolates CSS, not script execution.

Never write a second copy. One cleaner serves the inbox and the portal, because a security control kept in two places drifts β€” and it already had, leaving the inbox exposed after the portal was fixed.

Honour body_type

Chat channels store the sender's message verbatim as 'text'. Escaping it beats cleaning it: there's no cleaner to outsmart when nothing is parsed as markup. Any new endpoint returning a message body must select body_type, or the protection is inert.

A feature nobody can reach isn't shipped

Twice now, something was built correctly and was unusable:

  • Dark mode read a preferences table keyed by analyst_id. Portal users have none, so they could only ever get the default β€” a dark mode nobody could turn on.
  • The Help Centre launched against an audience rung where every article defaults to internal, so it was an empty page until a bulk publish tool existed.

Before calling it done: can a real user actually get to this, with the data they actually have?

Claims in comments go stale

The customer audience rung was documented β€” in audience.php and in the editor UI β€” as "nothing reads this yet". Both became false the moment the Help Centre shipped.

If you write "nothing does X yet", you've made a promise to update it when something does.


4. πŸ” Checking your work

# stray PHP tags in a nowdoc (must be 0)
curl -s -b "PHPSESSID=$SID" "http://localhost/freeitsm-app/self-service/<page>.php" | grep -c '<?php'

# every var(--x) used in the portal exists in theme.css
grep -ohE 'var\(\s*--[a-z0-9-]+' self-service/*.php assets/css/self-service.css | sort -u

# i18n parity
php -r '$en=require "lang/en/self-service.php"; $pt=require "lang/pt-BR/self-service.php";
        var_dump(array_diff_key($en, $pt));'

And always pair a "cannot do X" assertion with a positive control β€” proving the guard refuses is worthless if the same call would have failed anyway.

See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally