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
βš™οΈ includes/knowledge/portal_reader.php use it if your page touches knowledge articles β€” never re-derive the scope Never, if you list or open articles
πŸ—„οΈ 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 (knowledge) hard-coded to Audience::CUSTOMER inside portalKnowledgeScope()
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. A visibility rule gets written once

Reading an article in the portal means satisfying three conditions at the same time:

published AND not archived      β€” the article is live
AND audience >= customer        β€” it is meant for customers
AND (their company OR shared)   β€” it belongs to them

That was copy-pasted across two endpoints. Adding a third copy for the dashboard's popular list is what forced it out into includes/knowledge/portal_reader.php:

require_once '../../includes/knowledge/portal_reader.php';

$userTenantId     = portalUserTenantId($conn, $userId);
[$where, $params] = portalKnowledgeScope($conn, $userTenantId, 'a');
// then append your own conditions, in the same param order

πŸ”‘ Duplicated scope is the dangerous kind of duplication. Nothing breaks when the copies drift β€” one of them just quietly starts showing more than it should. There is no failing test for that; there's a support ticket six months later.

portalKnowledgeScope() exists to make the next two traps unreachable rather than merely documented.

Trap 1 β€” knowledge scoping is inverted, and 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(...);

Trap 2 β€” 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.

The audience is not a parameter

Audience::CUSTOMER is hard-coded inside portalKnowledgeScope(), not passed in. Whoever is asking does not get to declare how trusted they are β€” the same rule the web chat reader (includes/webchat/ai.php) follows.

2d. ORDER BY cannot take a bound parameter

Which means anything user-supplied that reaches it is injection. Sort options are a fixed allow-list, matched to a literal:

$order = ($sort === 'popular') ? 'a.view_count DESC, a.title ASC' : 'a.title ASC';

Not a whitelist of characters, not an escape β€” a switch over strings you wrote.

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.

Layout: three ways the portal has broken

Someone else's stylesheet may own your prefix. user-menu.php emits its <style> block after the page's, and it owns ss-. A page that reused that prefix got its rules overridden β€” an .ss-msg { display: none } from the menu rendered a whole ticket thread blank. tickets.php uses tk-.

Found by measuring getComputedStyle() in the browser, not by reading CSS. Reading tells you what a rule says; only the browser tells you which rule won.

Full height is a flex chain, not arithmetic. calc(100vh - 330px) is a guess that's wrong at every window size but one. Use display: flex end to end and put min-height: 0 on every ancestor β€” without it a flex child will not shrink below its content and the chain silently breaks at that link.

Specificity beats intent. The base rule is body.portal-app .portal-layout. A page overriding with bare .portal-layout loses, and the symptom is subtle β€” padding quietly returning β€” rather than obvious.

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.
  • Portal knowledge 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 portal started reading it.

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