The ConClar programme guide supports syncing a user's selections (favourites) to a server so they are available across
devices. The server runs on a separate subdomain (e.g. auth.example.com) from the SPA (e.g.
guide.example.com).
All merge logic lives in the client. The server stores and retrieves selections.
Selections are scoped by authenticated user and app_id.
Authentication is cookie-based, shared across subdomains.
- Client renders an
<a>link tologin_url(from the profile response) with<return_url>replaced. - Auth server authenticates the user (method is server-defined, e.g. OAuth, password, etc.).
- Auth server sets a session cookie and redirects back to the programme guide.
- SPA reloads, fetches profile, syncs selections.
- Client renders an
<a>link tologout_urlwith<return_url>replaced. - Auth server clears the session cookie and redirects back.
- Local selections remain intact in the browser.
The session cookie MUST be set with:
Set-Cookie: session=<token>; Domain=.example.com; Path=/; HttpOnly; Secure; SameSite=None
Domain=.example.com-- MUST be the shared parent domain so the cookie is sent to both subdomainsHttpOnly-- MUST be set to prevent XSS from stealing the session cookieSecure-- MUST be set (HTTPS only)SameSite=None-- MUST be set for cross-origin credentialed requests to work
The SPA makes credentialed cross-origin requests to the API. The server MUST respond with:
Access-Control-Allow-Origin: https://guide.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, PATCH, OPTIONS
Access-Control-Allow-Headers: Content-Type
Access-Control-Allow-Origin MUST be the exact origin, not * -- browsers reject * when credentials are included.
If more than one SPA origin is supported, the server SHOULD dynamically echo an allowed origin value and send
Vary: Origin.
Because SameSite=None means the cookie is sent on cross-origin requests, CSRF is possible. The server MUST check the
Origin header on PATCH requests and reject any that don't match the expected programme guide origin. Modern browsers
always send Origin on CORS requests.
Error response bodies SHOULD use this format across all endpoints:
{
"error": {
"code": "invalid_request",
"message": "All selection values must be boolean"
}
}code SHOULD be a stable machine-readable string; message SHOULD be human-readable and safe to display.
Returns the current authentication state. The client calls this on startup to determine whether to show a login link or sync selections.
Authenticated response:
{
"authenticated": true,
"id": "A0001",
"display_name": "alice",
"logout_url": "https://auth.example.com/logout?return_to=<return_url>"
}Not authenticated response:
{
"authenticated": false,
"login_url": "https://auth.example.com/login?return_to=<return_url>"
}The login_url and logout_url values SHOULD contain the literal string <return_url>. The client replaces this
with encodeURIComponent(window.location.href) before navigating, so the user is redirected back to the same page.
When the server receives a request with a return URL, it MUST:
- Validate it against an allowlist of origins to prevent open redirects.
- If valid, redirect to it after completing login/logout.
- If invalid or missing, redirect to a sensible default.
Returns all of the user's selections for the specified app.
app_id is a required path parameter and SHOULD match the client's configured APP_ID.
Response:
HTTP/1.1 200 OK
Content-Type: application/json
{
"selections": {
"item-123": true,
"item-456": false
}
}
Error responses:
400 Bad Request-- invalidapp_id401 Unauthorized-- session cookie missing or invalid405 Method Not Allowed-- unsupported method on this route
The client sends changed selection values. The server stores them and returns no response body.
app_id is a required path parameter and SHOULD match the client's configured APP_ID. The server MAY validate the
app_id against a list of known app ids and return a 400 Bad Request if it is not in the list.
Values in selections MUST be JSON booleans (true or false). Non-boolean values MUST be rejected.
false is different from an omitted key. Omitted means “no opinion”; false means explicitly not selected. The server
MUST store and return false values explicitly.
The write is atomic: either all provided key/value pairs are persisted, or none are.
For this endpoint, the server MUST enforce all of the following:
- Request
Content-TypeMUST beapplication/json. - Body MUST be valid JSON.
- Body MUST contain a top-level
selectionsobject. selectionskeys MUST be item IDs (opaque strings).selectionsvalues MUST be JSON booleans (trueorfalse).
If Content-Type is invalid, the server SHOULD return 415 Unsupported Media Type.
If any other rule fails, the server MUST return 400 Bad Request.
For all validation failures, the server MUST NOT persist any changes.
An empty object ({"selections": {}}) is valid and SHOULD return 204 No Content.
Request:
PATCH /apps/O2021/selections HTTP/1.1
Content-Type: application/json
{
"selections": {
"item-123": true,
"item-456": false
}
}
Success response:
HTTP/1.1 204 No Content
Error responses:
400 Bad Request-- invalidapp_id400 Bad Request-- request body is invalid (for example, malformed JSON, missingselections, or non-boolean value). No changes are persisted.401 Unauthorized405 Method Not Allowed415 Unsupported Media Type-- content type is notapplication/json429 Too Many Requests-- server-side rate limiting (optional)5xx-- transient server error
-
Last-writer-wins: When two devices change the same item, the most recently synced change wins. If device A selects an item and device B deselects it, the result depends on which syncs last, not which action the user performed last. This is acceptable for convention favourites.
-
No real-time sync: Changes only propagate when a device loads the page. Two devices open simultaneously won't see each other's changes until one reloads. Push-based sync (e.g. WebSockets) is not supported.
This section is non-normative and provided as an example only.
function patchSelections(user, request) {
const appId = request.params.app_id;
if (!appId) {
return {
status: 400,
body: { error: "Invalid app_id" },
};
}
// Validate all entries before writing any.
const entries = Object.entries(request.body.selections);
for (const [, selected] of entries) {
if (typeof selected !== "boolean") {
return {
status: 400,
body: { error: "All selection values must be boolean" },
};
}
}
user.selectionsByApp ||= {};
user.selectionsByApp[appId] ||= {};
for (const [itemId, selected] of entries) {
user.selectionsByApp[appId][itemId] = selected;
}
return {
status: 204,
};
}function getSelections(user, request) {
const appId = request.params.app_id;
if (!appId) {
return {
status: 400,
body: { error: "Invalid app_id" },
};
}
return {
status: 200,
body: { selections: user.selectionsByApp?.[appId] || {} },
};
}