Admin panel — running the site
The panel at /admin is the site's back office: counts, traffic, the user and profile tables, the report queue, staff management, one setting, and a markdown edi
The panel at /admin is the site's back office: counts, traffic, the user and profile tables, the report queue, staff management, one setting, and a markdown editor for the guides. It is admin-only at every layer — the page route, every API route, the header link, and the client's own front gate. This page covers what it shows, what it can change, the guards around the staff list, and — at the end — the things it deliberately does not do. See 15 Roles and permissions for who counts as an admin.
Getting in and what it is not
Route /admin in routes/seo.js, page public/admin.html, script public/js/admin.js.
The panel is:
noindex,nofollowin its own markup, andDisallow: /admininrobots.txt(alongside/api/and/records)- deliberately absent from the nav.
test/header.test.jsasserts that neither/adminnor/recordsis claimed by any nav item, and that the five nav match sets stay disjoint so two items can never both be "current". The reasoning is in the test: claiming one of the five tools would be a lie, so they stay out. The#admin-btnlink is in the header bar, hidden by default.
The server-side gate
requireAdminPage(req, res, pathname, denied) runs before any HTML is sent:
function requireAdminPage(req, res, pathname, denied) {
const user = req.session && req.session.user;
if (!user) {
const login = `${siteUrl('/api/auth/discord/login')}?returnTo=${encodeURIComponent(`${BASE_PATH}${pathname}`)}`;
res.redirect(302, login);
return true;
}
if (!user.isAdmin) {
res.redirect(302, siteUrl(denied || `${GALLERY_PATH}?admin=denied`));
return true;
}
return false;
}
| Session | Response |
|---|---|
| Signed out | 302 to the Discord login with returnTo=<path> |
| Signed in, not admin | 302 to /profiles/hub?admin=denied |
| Admin | the page |
The gallery is the redirect target because it is the page that reads and explains the query — js/app.js raises the toast "The admin panel is only available to admin accounts." Bouncing to the front page instead, which is what this used to do, meant asking for the panel silently landed on the gallery with no sign-in prompt and no way back.
The gate returns a boolean, not the response: res.redirect() returns undefined in Express 4, so a gate that returned the redirect object would never stop the route, and every visit would log ERR_HTTP_HEADERS_SENT. test/admin-access.test.js asserts return true; and return false; are both present and return res.redirect is not.
What the tests pin down
test/admin-access.test.js asserts:
- the admin allowlist is parsed in exactly one place —
routes/auth.js,routes/users.js,lib/notify.js,routes/admin.jsandserver.jsmust not readprocess.env.ADMIN_DISCORD_IDS isAdminDiscordIdis total:[null, undefined, '', 0, false, NaN, {}, []]all read as "not an admin", and a numeric id that is on the list still matches- every route in
routes/admin.js,routes/records.jsandroutes/docs-write.jsis behindrequireAdmin - the admin pages refuse before sending HTML, and both
/adminand/recordsgo through the shared gate - the header renders
#admin-btnhidden and onlypublic/js/auth.jsunhides it, from the session's ownisAdmin - the command palette lists the panel only when
if (admin && !admin.hidden) requireAdminreads the allowlist throughflagsFor(req.session.user).isAdminand neverreq.session.user.isAdmin— a snapshot goes stale the moment a role is added to the code- the admin surface is not reachable through the forum:
routes/forum.jshas norequireAdmin, androutes/admin.jsandroutes/records.jshave norequireModerator
The client-side front gate
public/js/admin.js checks again before it renders anything:
if (!window.SPHAuth) {
el.sub.textContent = 'Could not verify your session — reload the page.';
return;
}
const me = await window.SPHAuth.ready();
if (!me || !me.loggedIn) { window.location.href = `${BASE}/api/auth/discord/login`; return; }
if (!me.isAdmin) { window.location.href = `${BASE}/profiles/hub?admin=denied`; return; }
A missing window.SPHAuth is a distinct path from "not signed in". auth.js used to be deferred, which meant it arrived after this file and the gate read "helper not loaded yet" as "not signed in" — bouncing a valid admin to Discord. The panel served a 200 and then threw the admin straight out. test/script-order.test.js asserts the missing-helper branch exists, asserts js/auth.js is never deferred on any page, and asserts no page script anywhere uses the collapsing gate pattern window.SPHAuth && await window.SPHAuth.ready() || null.
The layout
Reading order down #main:
| Element | Id | Source |
|---|---|---|
| Tool-strip search | admin-search |
Placeholder: "Search everything — username, profile title, comment…" |
| Search hint | admin-search-hint |
#admin-search-hint |
| Title | — | Admin panel |
| Subtitle | admin-sub |
Signed in as <name> · <local timestamp, en-GB> |
| Stats | stat-grid |
Users, Profiles, Comments, Open reports |
| Traffic | traffic-grid |
Four windows |
| Guides | guides-section |
Tree + editor |
| Settings | settings-section |
max_profiles_per_user |
| Staff | staff-section |
Add form + list |
| Reports | reports-section |
#report-list |
| Users | #users-table / #users-body |
|
| Profiles | #profiles-table / #profiles-body |
|
| Recent comments | #comment-list |
|
| Activity sidebar | #activity-section / #activity-list |
|
| Transfer modal | #transfer-overlay |
Everything except staff comes from one call, GET /api/admin/overview: stats (with traffic nested), users, profiles, comments, reports, recentActivity, settings. Staff is a second call, GET /api/admin/staff, because it changes on its own schedule — an admin promotes somebody between page loads and it has nothing to do with the overview numbers.
comments is capped at LIMIT 100 and recentActivity at LIMIT 20 in the SQL. There is no pagination on either, and no control to change that.
The sidebar is a sibling of the main column inside .admin-layout, so it sits beside the tables rather than below them.
Search
One debounced input filters the whole page. input → 150 ms → lowercase the trimmed value into query → renderAll() re-renders users, profiles, comments, reports and activity. matches() is a plain String(str ?? '').toLowerCase().includes(query).
Matched fields per section:
| Section | Fields searched |
|---|---|
| Users | username |
| Profiles | title, uploaderUsername, game |
| Comments | username, body, profileTitle |
| Reports | reporterUsername, reason, profileTitle |
| Activity | text, type |
When a query is present the hint reads Showing matches for "<q>" (curly quotes). The empty states change with it:
| Section | With a query | Without |
|---|---|---|
| Users | No matching users. |
No users yet. |
| Profiles | No matching profiles. |
No profiles yet. |
| Comments | No matching comments. |
No comments yet. |
| Reports | No matching reports. |
No reports. |
| Activity | No matching activity. |
No activity yet. |
The users and profiles empty states are a full-width table row (colspan 5 and 10 respectively) styled num admin-empty.
Stats and traffic
Four traffic cards, each views and uniques over a window:
| Card | Titles | Ids |
|---|---|---|
| 60 minutes | Visits last 60 min |
t-h1-views, t-h1-unique |
| 24 hours | Visits last 24 hours |
t-h24-views, t-h24-unique |
| 7 days | Visits last 7 days |
t-d7-views, t-d7-unique |
| 30 days | Visits last 30 days |
t-d30-views, t-d30-unique |
The analytics model, honestly
lib/analytics.js is 44 lines:
const SALT = process.env.SESSION_SECRET || 'swiftpoint-hub-analytics-salt';
function hashVisitor(ip) {
return crypto.createHmac('sha256', SALT).update(String(ip || '')).digest('hex');
}
function trackPageView(ip, path) {
db.prepare('INSERT INTO pageviews (visitor_hash, path) VALUES (?, ?)')
.run(hashVisitor(ip), String(path || '/').slice(0, 255));
if (++insertCounter % PRUNE_EVERY === 0) {
db.prepare("DELETE FROM pageviews WHERE created_at < datetime('now', '-30 days')").run();
}
}
- the visitor hash is
HMAC-SHA256(client IP, SESSION_SECRET || 'swiftpoint-hub-analytics-salt'), so the raw IP is never stored — it is not truncated, not salted separately, not kept - only the path is kept, truncated to 255 characters
- uniques are
COUNT(DISTINCT visitor_hash) - rows older than 30 days are pruned on every 200th insert
- there is no referrer, no user agent, no session id, no cookie
- page views from admin sessions are skipped on the static pages, and on a profile page the admin view is also excluded from the
viewscounter (if (!isAdminSession(req))guards both)
There is no date-range picker, no chart and no per-path breakdown in the panel. See 19 SEO and analytics.
The users table
#users-table, ordered by created_at DESC.
| Column | Content |
|---|---|
| User | The avatar (#cell-avatar) when there is one, and the username linking to /users/{id} |
| Joined | fmtDate |
| Profiles | profile_count |
| Comments | comment_count |
| Reports | report_count — reports filed by this user |
The row also carries discordId and avatarUrl, which the panel uses for the transfer modal and nothing else the table shows. That is the one surface where the id is published, and test/public-api-privacy.test.js asserts it stays behind the admin guard.
The profiles table
#profiles-table, ordered by created_at DESC.
| Column | Content |
|---|---|
| Title | Linked to /profiles/{id} |
| Game | p.game or – |
| Uploader | uploader_username or – |
| Created | fmtDate |
| Views | p.views |
| DL | p.downloads |
| Votes | upvotes - downvotes, with a + when non-negative |
| Comments | comment_count |
| Reports | open_report_count — the cell gains cell-warn above zero |
| (blank) | Open link and a Transfer button |
Transfer
The Transfer button opens #transfer-overlay:
| Element | Id | Content |
|---|---|---|
| Heading | — | Transfer profile |
| Sub-line | transfer-sub |
“<title>” → assign to: |
| Select | transfer-user |
Every user except the current uploader |
| Buttons | transfer-cancel, transfer-confirm |
Cancel / Transfer |
The backdrop and Escape both close it. Confirming is POST /api/admin/profiles/:id/transfer with { userId }. The server refuses a missing profile (404), an unknown user (400 Target user not found.) and the current owner (400 Profile already belongs to that user.), then notifies the admins ("Profile "X" transferred to Y") and the new owner.
Transfer exists because a profile belongs to an account, and an account can go away. Without it the only options are deleting the profile or leaving it owned by somebody who cannot reach it. (Note a small dead branch: the option template contains (current owner), but the current uploader is filtered out of the list before rendering, so that suffix can never appear.)
Reports
#report-list, ordered CASE r.status WHEN 'open' THEN 0 ELSE 1 END, r.created_at DESC — open first, newest first inside each group.
Each row:
| Part | Content |
|---|---|
| Type label | Comment report when targetType === 'comment', otherwise Profile report |
| Status chip | .r-status plus .r-<status>, only when the status is not open |
| Meta | <reporter> · <date> |
| Target | comment on "<profile title>" or profile "<title>" |
| Snippet | commentSnippet, only for comment reports, first 120 characters |
| Reason | The reported reason |
| Actions | For an open report only: View (to /profiles/<profileId>), Resolve, Dismiss |
Resolve and Dismiss are both POST /api/reports/{id}/status with { status: 'resolved' } or { status: 'dismissed' }, followed by a full reload. The update is conditional on status = 'open', so a second attempt returns 400 Report is already <status>. and an unknown id 404.
Be precise about the moderator gap
This is the part worth stating plainly:
-
A report filed on a forum post notifies moderators (
notifyStaff) and carries the thread id and post id, so the exact reply can be opened.routes/reports.jssays why:The person who answers the forum is the person who can remove a post from the thread it sits in, so the notification goes to the moderators as well and links straight at the post. Telling a moderator about it and giving them no way to act is the same as not telling them.
-
But the triage endpoints behind
/api/reportsarerequireAdmin:GET /api/reports(the queue) andPOST /api/reports/:id/status(resolve/dismiss).test/admin-access.test.jsasserts both, with the reasoning that the queue exposes other people's reported content.
So a moderator is notified of a bad post and cannot clear the report themselves. They can delete the post from the thread, which is the action that actually resolves it; the report row stays open in an admin's queue until an admin looks at it.
There is a second, smaller gap in the same area: the panel's own GET /api/admin/overview does not join forum_posts or forum_threads, and its report mapper emits no threadId, postId or postSnippet. A post report therefore renders in the panel as a "Profile report" against profile "unknown" with a View link to /profiles/null. routes/reports.js does carry the fields; the panel's query does not use them. Both belong in Known gaps.
Comments and activity
Comments
#comment-list, newest first, capped at 100 server-side. Each row:
<span class="comment-meta mono">@username · patch notes · 04 Mar 2026, 09:12</span>
<div class="comment-snippet">…body…</div>
<a class="btn btn--ghost" href="/profiles/{id}">on “<profile title>”</a>
The · patch notes marker appears when kind === 'patch'. There is no delete, no edit and no report button on a comment in this list — comment deletion is on the profile page.
Activity
#activity-list, from the admin's own notifications rows, LIMIT 20. TYPE_LABELS:
type |
Label |
|---|---|
new_user |
User |
new_profile |
Profile |
profile_updated |
Update |
new_comment |
Comment |
new_vote |
Vote |
report |
Report |
An unread item gets unread; a row with a profileId becomes an <a> to that profile, otherwise a plain <div>. The label chip carries a t-<type> class.
Settings
One setting, in #settings-section:
| Setting | Key | Type | Range |
|---|---|---|---|
| Max profiles per user | max_profiles_per_user |
number | 1–100, integer |
The note under the input distinguishes the two cases precisely, and the parenthetical is the fallback number, not the word "fallback":
Active: 3 — same as env default (MAX_PROFILES_PER_USER).
Active: 5 — saved value overrides env default (3).
The first branch fires when the effective value equals String(fallback); the second prints the fallback in parentheses. POST /api/admin/settings sends { key, value }, and on success the note is overwritten with:
Saved — active limit is now 5.
An unknown key is 400 Unknown setting "<key>".; a non-integer or out-of-range number is 400 Max profiles per user must be an integer between 1 and 100.; an unsupported type is 400 Unsupported setting type "<type>".
Under the hood lib/settings.js is a two-function key/value table — getSetting(key, fallback) and setSetting(key, value) with an ON CONFLICT DO UPDATE upsert. fallback for this key is Number(process.env.MAX_PROFILES_PER_USER) || 3. See 17 Data model.
Staff management
#staff-section, backed by GET /api/admin/staff → { staff, people }.
Adding
| Element | Id | Content |
|---|---|---|
| Who | staff-name |
<datalist id="staff-people"> with username · discordId for every account that has ever signed in |
| As | staff-role |
moderator or admin |
| Add | staff-add |
Enter in the input also submits |
The bare value decides the payload: an all-digits string is sent as { discordId }, anything else as { username }. Notes appear in #staff-note-line.
| Situation | Message |
|---|---|
| Empty input | Type a Discord ID, or pick a name from the list. |
| Added | Added <raw>. |
| In flight | Adding… |
| Name matches more than one account | 400 More than one account has that name. Use the Discord ID. |
| Name matches nobody | 400 Nobody who has signed in here is called @<name>. Add their Discord ID instead — only people who have used the site can be found by name. |
| Malformed id | 400 A Discord ID is 5–25 digits. Copy it with the copy button on the user. |
| Already listed | 400 That account is already on the list. |
knownPeople(200) is the account table ordered by join date, capped at 200 by the call and 500 by the function.
Why a name is only sometimes enough. There is no bot token on this site — login is plain OAuth2 with fetch — so nothing here can ask Discord who somebody is. The only place an id is known is the site's own users table. An account that has never signed in can only be added by typing its Discord id, and the error message says exactly that rather than pretending the lookup is complete.
The list
#staff-list is split into two counted groups:
Admins <n>
Moderators <n>
A row shows the role badge, the name (linking to /users/{addedUserId} when known, otherwise the raw Discord id in .staff-who--anon), the id in .mono, and — for env rows — a .staff-from tag reading .env.
Env rows are locked: the actions cell is replaced by a single locked span titled "Set in .env — change it there and restart", with no buttons at all.
Actions
| Action | Call | Confirmation / note |
|---|---|---|
| Make mod / Make admin | PATCH /api/admin/staff/:discordId { role } |
#staff-note-line: Now a <role>. They will need to sign in again for the panel. |
| Remove | DELETE /api/admin/staff/:discordId |
Remove <name>? They lose the forum and the panel immediately. |
| Demote or remove an env entry | refused | That one comes from ADMIN_DISCORD_IDS in .env. Change it there and restart. (role change) / That one comes from the .env allowlist. Remove it there and restart. (removal) |
The guards, which are the point
-
The allowlist is the union of the
.envlists and thestafftable, and the.envlist wins.loadStaff()folds thestaffrows first, then pushes moderator rows forMODERATOR_DISCORD_IDS, then forADMIN_DISCORD_IDSsetsrole = 'admin'andsource = 'env'on whatever row already exists. A database row can therefore never demote or remove an env entry, and never re-grant admin to somebody in the moderator list. -
The last admin cannot be removed or demoted.
guardNotLastAdmin(discordId)counts the admins that survive the action — not the rows — and returnsThat is the last admin. Add somebody else first.The function's comment names the failure it prevents:The guard returns the refusal. Calling it for its side effect and throwing the answer away is how the last admin on a site demotes themselves; there is a test for exactly that.
-
The same account cannot be added twice —
That account is already on the list. -
A Discord id must match
/^\d{5,25}$/. -
Usernames are truncated to 64 characters on insert.
-
A role change to the role already held is refused:
Already a <role>.
test/staff.test.js covers the whole set, including "the .env admin is the way back in, and cannot be removed" and "a name resolves only for people who have signed in, and says so".
The return path
If the table is ever wrong — every admin demoted, an env id removed by hand, a bad migration — the .env list is the way back in. It is not editable from the panel by design, and the section's own note says so: "Anyone listed in .env stays put — that is the way back in if something here goes wrong."
The role change is the one thing that requires a re-sign-in, and the panel says so, because requireAdmin reads the allowlist on each request while the session flag is only a snapshot.
The guides editor
public/js/admin-guides.js, exposing window.SPAdminGuides = { loadTree, open, save }. The guides are files in docs/ and the renderer reads them from there, so the editor uploads nothing and keeps no copy. See 11 The guides system for the engine and the writing API.
The tree
GET /api/admin/docs returns folders (with parent, slug, name, docCount) and docs. renderTree() walks folders recursively:
| Row | Indent | Contents |
|---|---|---|
| Folder | 8 + depth * 12 px |
The visibility tick box, the name (titled with its slug), the document count, and three actions: + (new guide in here), Rename, Del |
| Guide | 20 + depth * 12 px |
The visibility tick box and a <button> titled with the description or the slug; the open one gets is-active |
Empty state: No guides yet.
Who may read it
A tick box per folder and per guide, and a line under the open guide's fields (#g-vis, #g-vis-note). It writes PUT /api/admin/docs/visibility with { scope, target, level }, where level is staff, admin or null to take the rule away.
The box shows the row's own rule, not the effective one. A guide inside a closed folder is closed but its box is unticked, and the note says which folder closed it and that ticking the box there only gets stricter — a box that reads "closed" with nothing ticked in it looks broken, and one that silently means something else is worse. admin.css styles .g-tree-vis as a 14px square in a 12.5px tree; it is a real checkbox in a <label>, because "may this person read this" is a yes/no fact and has to be reachable by keyboard.
The rule is not the file's business and is not in the front matter: it lives in doc_visibility, a rename carries it along, and deleting drops it. What it changes on the public side — the tree, the search list, the notes map, the sitemap, and the 404 a closed guide answers — is in 11 The guides system.
Opening a guide
open(slug) fetches GET /api/admin/docs/:slug and fills #g-title, #g-desc, #g-tags (comma separated) and #g-order, points #g-view at /docs/{slug}, then loads the body as saved — the H1 included, because:
The front matter is what the tree shows, the heading is what the page prints. Show the body as it is saved, heading and all.
#g-state then reads Saved.
The editor
SPMarkdown.create({ … }) with:
| Option | Value | Why |
|---|---|---|
previewUrl |
/api/admin/docs/preview |
The docs engine, not the forum's |
previewField |
'content' |
Not body — sending body produced a blank Preview tab for every guide ever written |
previewData |
`() => ({ slug: state.slug | |
previewClass |
'd-prose' |
The guides' own prose rules, because that is the engine that produced the HTML |
draftKey |
'sp:admin:guide' |
localStorage draft |
rows / max |
18 / 32000 |
|
blocks |
true |
The changelog blocks; the toolbar is the only place they appear |
Save
PUT /api/admin/docs/{slug} with { content, title, description, tags, order }, then:
| Result | Note |
|---|---|
| Changed | Saved. It is live at /docs/<slug> |
| Unchanged | Nothing had changed. |
clearDraft() runs after a successful save, and a beforeunload guard fires whenever state.dirty.
Create, rename, delete
| Action | Call | Prompt / message |
|---|---|---|
| New guide | POST /api/admin/docs/documents with the current guide's parent folder |
Title of the new guide, then Created /docs/<slug> |
| Rename a guide | PATCH /api/admin/docs/:slug |
New title — this also renames the file, and so the URL |
| Rename a folder | PATCH /api/admin/docs/folders/:slug |
New folder name — every guide inside moves with it |
| Delete a guide | DELETE /api/admin/docs/:slug |
Delete /docs/<slug>? The file is removed from the repository. |
| Delete a folder | two DELETE …?recursive=true |
see below |
A rename that would break inbound links says so, because linkedGuides counts the guides whose raw text contains the old path:
Renamed to /docs/<new>. 4 guide(s) link to the old path and will need updating.
Deleting a folder is two requests
The first DELETE …?recursive=true returns 409 with needsConfirm. The panel then asks:
<slug> holds 3 file(s) and 0 folder(s). Delete it and everything in it?
Only a confirmed second call deletes. A folder here holds recordings, so the number is the point.
"Link to…"
The button fetches the 8 most recent threads and the 8 most recent updates, lists them as 1. Update · <title> / 2. Thread · <title> in a single prompt, asks for the link text, and inserts a markdown link. Not an embed, on purpose — forum content changes and gets deleted, and a guide that quotes a ticket is wrong next month. When there is nothing to link to it says Nothing to link to yet — publish an update or start a thread first.
Gaps in the editor
Documented rather than implied:
- No folder re-ordering. There is an
ordernumber input in the Details disclosure, but nothing that reorders the tree — no drag control, no move up/down. Folders come back inlocaleCompareorder, full stop. - No search in the tree. With a folder per section and dozens of guides, finding one is scrolling.
- No multi-select or bulk move. One guide at a time.
- No tree-position preview. The panel cannot show you where a new guide will land before you create it.
What the panel does not do
Stated precisely, because "the admin panel" sounds like more than it is:
- There is no profile-delete button anywhere in the markup. The staff note says admins "can also delete profiles", and
DELETE /api/profiles/:idexists and works — but the panel offers no control for it. Deleting a profile is done from the profile page itself, where the uploader'sDelete profilebutton lives, and only if you are the uploader or an admin.grep -i delete public/admin.html public/js/admin.jsreturns the prose in the staff note and the staffDELETEcall, nothing else. - No pagination on the users or profiles tables, or on comments or activity. The server caps them (all rows, 100, 20); the panel renders what it is given.
- No per-section refresh. Everything reloads from
GET /api/admin/overviewin one go after a mutation. - No report-detail view. The queue shows a target label and a 120-character snippet; there is no page for a single report.
- No traffic date-range picker and no per-path breakdown.
- No bulk staff import or export.
- No settings beyond
max_profiles_per_user.SETTING_DEFShas exactly one entry. - No link to the recordings page (
/records) from anywhere in the panel, even thoughdocs/recordings/sits right there in the tree the panel already walks.
And one deliberate omission: logout is not wired in admin.js. chrome.js owns it and reloads the page so the gate runs again. Wiring it here too would POST the logout twice — the comment in admin.js says so, and the panel's upload-btn handler sits directly beneath it as the only header control the panel adds.
How this is tested
| File | What it protects |
|---|---|
test/admin-access.test.js |
One allowlist, a total check, every admin route behind requireAdmin, the pages refusing before serving, the header and palette offering the panel only to an admin, and the forum not being a path to the admin surface |
test/staff.test.js |
Only an admin changes staff, the .env admin cannot be removed, the last admin cannot be removed or demoted, a name resolves only for people who have signed in, no duplicate adds, the union with .env winning |
test/script-order.test.js |
js/auth.js in <head> on every page, no page script collapsing "could not check" into "signed out", the admin page not bouncing on an unverifiable session, and /admin deciding server-side before serving |
test/dom-contract.test.js |
A page script cannot abort the page on one null; the vote box is wired to the ids the markup has |
test/header.test.js |
"The admin panel is one click away in the bar" — #admin-btn present, hidden by default, and outside the user menu, on every page |
test/forum.test.js |
The admin surface is unreachable through the forum |
test/legal-pages.test.js |
The generated legal pages list max_profiles_per_user and the .env staff lists among what the site stores |
See Testing.
Related: 11 The guides system, 15 Roles and permissions, 17 Data model, 19 SEO and analytics, 21 Configuration and deployment, 22 Testing, 23 Known gaps.