CogniPrep allows an account on two devices. That is a product rule, written in
clause 4.3 of the terms, and it generates exactly one kind of support email:
"I have changed laptops and I cannot get in."
Answering that used to mean reading rows. Now the admin account screen lists the
devices, each with a human label, and the operator can clear them. The label is
produced by fourteen lines that I keep thinking about, because the only design
decision in them is the order of two arrays.
The function
export function describeUserAgent(userAgent: string | null): string {
if (!userAgent) return 'Unknown device';
const os = DEVICE_OS_PATTERNS.find(([pattern]) => pattern.test(userAgent))?.[1];
const browser = DEVICE_BROWSER_PATTERNS.find(([pattern]) => pattern.test(userAgent))?.[1];
if (browser && os) return `${browser} on ${os}`;
return browser ?? os ?? 'Unknown device';
}
Two finds over two tables of [RegExp, string]. First match wins, which is
the entire parser, and which is why the tables are ordered rather than
alphabetical.
const DEVICE_OS_PATTERNS: [RegExp, string][] = [
// Before the desktop systems: iPadOS and Android both carry other platform
// names in their user-agents.
[/iPhone/, 'iPhone'],
[/iPad/, 'iPad'],
[/Android/, 'Android'],
[/CrOS/, 'ChromeOS'],
[/Windows/, 'Windows'],
[/Macintosh|Mac OS X/, 'macOS'],
[/Linux/, 'Linux'],
];
const DEVICE_BROWSER_PATTERNS: [RegExp, string][] = [
// Order matters. Edge, Opera and Samsung Internet all also claim to be Chrome,
// and every Chromium browser also claims to be Safari.
[/Edg(?:e|A|iOS)?\//, 'Edge'],
[/OPR\/|Opera/, 'Opera'],
[/SamsungBrowser\//, 'Samsung Internet'],
[/Firefox\/|FxiOS\//, 'Firefox'],
[/Chrome\/|CriOS\//, 'Chrome'],
[/Safari\//, 'Safari'],
];
Both tables encode the same historical fact: user-agent strings are a pile of
claims each browser makes in order to be mistaken for an older one. An Android
phone says Linux. An iPad has said Mac OS X since iPadOS 13. Samsung
Internet says SamsungBrowser/26.0 Chrome/122.0.0.0 ... Safari/537.36, which is
three browsers in one string, and the only thing that distinguishes it is that
it says its own name first.
So the rule is: most specific claim first, and read no further. Reverse either
array and every Chromium browser becomes Safari, every Android phone becomes
Linux, and nothing throws.
What it refuses to do
expect(describeUserAgent(null)).toBe('Unknown device');
expect(describeUserAgent('')).toBe('Unknown device');
expect(describeUserAgent('curl/8.4.0')).toBe('Unknown device');
No versions, no hardware model, no guessing. The comment states the ceiling:
Deliberately coarse, the same resolution the device limit itself works at.
Good enough to tell a user's phone from their laptop on a support call, and
nothing more.
That is the argument against pulling in a UA parsing library, which would ship a
regularly updated database so it can tell me the device is a SM-S918B. The
screen is for an operator on a support ticket deciding which of two rows is the
old laptop. "Chrome on Windows" and "Safari on iPhone" answer that. A model
number does not answer it any better, and a dependency that needs updating to
stay correct is a dependency that is quietly wrong between updates.
Eight real user-agent strings in a table driven test, plus three that must come
back Unknown device. The three negatives are the more valuable half: they pin
that an unrecognised string is labelled unknown rather than squeezed into the
nearest row.
The panel it feeds
return {
// Counted the way `decideDeviceSlot` counts, so "2 of 2" here means exactly
// what the device-limit wall means by it.
slotsUsed: new Set(rows.map((row) => row.device_fingerprint)).size,
slotLimit: MAX_DEVICES_PER_USER,
sessions: rows.map(/* ... */),
};
The count is distinct fingerprints, not rows, because that is how the enforcing
code counts. Two numbers on two screens that are meant to be the same number is
a bug waiting for the day they disagree, and the comment exists so the next
person changing one knows the other is watching.
The device list is loaded in the same Promise.all as every other side query on
the account screen, so the screen costs one round trip's latency rather than
eleven.
A reset that signs nobody out
DELETE /api/admin/accounts/[userId]/devices
The comment on the function underneath is the part worth copying:
This is the support fix for a user stuck on the device-limit wall. It signs
nobody out: a device that still holds its cookie just claims a slot again on
its next request, so the slots go to whichever devices the user opens next.
Deleting the registrations and ending the sessions are two different operations
and it would be easy to assume the support tool does both. It does not. The user
stays signed in everywhere they already are, and the first two devices to make a
request own the slots again. That is almost always what the person on the other
end of the ticket wanted, and it means an operator cannot accidentally sign
someone out of a test they are halfway through.
Two more small rules it follows. It takes the same lock as a slot claim, so a
claim in flight either finishes before the delete or runs after it, never half
through it. And a reset that removed nothing writes no audit row, matching the
no-op rule the entitlement editor already uses: an audit log full of
"operator changed nothing" entries is an audit log nobody reads.
See it
- cogniprep.app/terms, clause 4.3, is the product rule all of this implements: two devices per account, free or paid. Worth reading as the spec the code is written against.
- cogniprep.app/help is where the support message that triggers a device reset actually arrives. The urgency selector on that form is the other half of the same workflow.
- cogniprep.app/pricing is why the limit exists at all: everything is a one time purchase, so an account is worth sharing in a way a monthly subscription is not.
Top comments (0)