Skip to content

Security Architecture

In this page

Live account, profile, application, and resource authorization in Tuvima Library.

Where this lives in the code

  • src/MediaEngine.Identity
  • src/MediaEngine.Api/Security
  • src/MediaEngine.Admin
  • src/MediaEngine.Web

This describes the Access implementation under final integration. Delivery evidence and remaining acceptance gates are recorded in the Access execution status. The replacement is accepted as one cutover; individual worker checkpoints do not represent deployment.

Accounts and profiles

The profile holds the person; a user is only a sign-in (users, profiles and households spec, storage epoch guid-blob-v13-profile-held-contract). A user (accounts) is an email with a password, passkey, two-step codes or outside sign-in, attached to exactly one profile (accounts.profile_id, at most one user per profile). A profile owns the person: the server administrator and household administrator roles, Read/Watch/Listen/View (profile_feature_grants), the libraries an administrator hid from it (profile_library_hidden), restrictions, history, the optional admin PIN question (profile_admin_protections, profile_admin_unlocks) and Personal Space identity. Every profile belongs to one household (at most eight profiles each). The earlier account-held model (per-account profile grants, feature and library grants, admin protections and unlocks, and the accounts.is_administrator, household_admin, household_id and grants_inherit_from_account_id columns) was removed by upgrade step 0048_drop_account_held_access: a user’s household is its profile’s household, and nothing about access is stored per user.

Signing in shows the profile choice with the user’s own profile pre-selected (password, two-step, passkey, outside sign-in, this computer, invitation; users spec D3). No profile is in use until the person clicks one: the session is “choose a profile” (auth_sessions.profile_pending = 1, choose_profile and profile_pending in the sign-in answer, the tuvima:profile_pending claim), ProfilePendingMiddleware answers 403 profile_required to every route except checking the session, listing the household’s profiles and their pictures, switching (POST /auth/session/switch-profile) and signing out, and the authority sent to the Dashboard lists the household’s profiles with no roles, features or actions. A session that must first replace a temporary password is not marked until the new password is set. The person then switches to any profile of their own household, never another household’s, a server administrator included. There is no per-device “always open as” (the old device_profile_preferences table is dropped, upgrade step 0044). Paired apps are unchanged: a device token is bound to a profile at pairing and when it switches. A profile with a PIN asks for it (the usual wrong-try limit and pause); a profile without one opens freely, Kids profiles included. A session or paired-app token is valid only while its active profile is in the household of the profile the user signs in as, and an unknown profile id and another household’s profile answer exactly alike. The admin unlock is cleared when the person switches profile.

Authority reads the active profile. RequestAuthorityResolver loads the user and the profile being acted as, fresh on every request (IAccessRepository.GetActiveProfileAccessAsync, one query). An effective server administrator is an enabled user acting as a server administrator profile (with an enabled user attached) of their own household that is not a child profile; an effective household administrator is the same with the household administrator role. So Mom signs into her own profile and is not a server administrator; after switching to the server administrator’s profile (their PIN when set) she is, and she stops being one as soon as she switches back. Read/Watch/Listen/View come from the active profile’s grants, and a profile sees every library in its areas unless an administrator hid it from that profile (profile_library_hidden; a new library is visible straight away to everyone with its area, nothing is carried from earlier per-library grants, and the one LibraryVisibilityRule answers it for browse, search, detail pages, players, artwork, streams, events, counts and downloads; a guardrail fails the build when a read path asks anywhere else) (a server administrator profile bypasses all of this, and the Vault is never visible through it and never appears in the hidden list: it keeps its own per-section rules). A household administrator can hide more libraries for their household’s profiles but cannot show a library hidden from their own profile. There is no single-title blocking and there are no labels or tags: ratings, the folder’s “Suitable for limited profiles” switch and the hidden list cover household needs. The rating limit, “allow unrated” and Kids work per profile: an unrated item (only a blank rating, NR, Not Rated or Unrated counts) shows to a limited profile when it sits in a folder marked suitable or the profile allows unrated, saved videos included, and a rated item always follows the limit. The Kids content limit is the active profile’s. Paired apps, HLS and cast grants and Dashboard sessions bind the user and the profile, and the profile’s authorization version (profiles.authorization_version), which rises whenever the profile’s roles, access or admin PIN question change, so changing a profile’s access ends its open tokens and prompts open sessions to re-check. The older account-side columns and tables (accounts.is_administrator, household_admin, account_feature_grants, account_library_hidden, grant_admin_*) are no longer read for any decision.

An optional profile admin PIN protects administrator surfaces independently of sign-in and profile-selection PINs (it asks for the profile’s own PIN). Unlock expiry and authorization revisions are enforced by the Engine. The Dashboard consumes projected navigation/actions and never trusts a locally stored profile role or a seed-owner fallback. Editor entry uses the shared unlock prompt and revalidates before opening.

Rules the Engine keeps: the Library always keeps a server administrator profile whose user can sign in (the last one cannot be demoted, deleted or have its user disabled); a server administrator profile lives in the default household (the one the first server administrator started); a household administrator profile always has a user; attaching a sign-in to a person who already exists never changes that person’s roles or access; a new person added by someone starts with the access of the profile that added them and never an administrator role. A server administrator profile without a PIN can be switched into by anyone in the default household (switching is free unless a profile has a PIN), so a server administrator should set one and turn on the admin PIN question.

There are two administrator levels, and both are roles on a profile. A server administrator runs the Engine: server settings, folders, network, making and removing users, and every household. A household administrator looks after one household only: adding and removing its people, resetting the passwords and two-step codes of its users, PINs, and handing out the features that their own profile already holds (never more) and hiding libraries from the household’s people (they can hide any library, but never show again one that is hidden from their own profile). A household administrator can never act on a server administrator or another household administrator’s user (email, password or access), turn on administrator access for anyone, or reach another household; the Engine answers a lookup of another household’s profile or user as “not found”. Making and removing users (GET/POST/DELETE /access/users) is for a server administrator (D4). Deleting a user (DELETE /access/users/{id}) normally removes only the sign-in: the profile and everything in it stay. The exception is the last enabled user attached to a household-administrator profile of a household that is not the default one, which takes the household and every profile in it, after the caller confirms the list. It is never the actor’s own user, never a server administrator that would be the last one who can sign in, and never the default household. A household never loses its last household administrator profile (“{name} is the last owner of this household. Make someone else an owner first.”); only a server administrator takes the role away from the last one (PUT /access/profiles/{id}/roles), which is how a household with a single owner is emptied before it is removed. Every profile that is deleted, by Delete user or Delete profile, also loses what no foreign key would clear (watch history, undo commands, waiting restored state, its own Trash entries, its offline encodes, progress, taste and reader notes) and its uploaded picture (only a file inside the Library’s own profiles folder is ever deleted); audit and approval history, the library activity log and the reserved View storage label are kept. Household administration needs a person who is not on a child profile. A household administrator can turn on an administrator PIN for their own household administration (never for anyone else’s); once on, it must be unlocked like a server administrator’s. Libraries and lanes are the person’s profile’s own (PUT /access/profiles/{id}/access), and a household administrator hands out only what the profile they are acting as already holds. They cannot change the person another administrator signs in as, and the profile PIN route accepts people only, never applications. The first server administrator joins the household that already holds the server’s Shared Library rather than starting another. Each household has its own Shared Library, and a contribution always lands in the submitter’s household library. Only a server administrator adds, renames or removes a household (/access/households); removal is refused while any person or sign-in remains. Switching person, in the Dashboard or a paired app, is limited to people in the signed-in user’s own household, with the PIN check on top (a server administrator included). Seeing another person’s Activity or Taste is decided by IPersonalScopeAuthorizer: a member or child profile sees only their own, a household administrator also their household or one of its people, a server administrator anyone; opening a scope with other people is audited once per 30 minutes per scope (activity.viewed_others, taste.viewed_others), and only an unlocked server administrator may delete history.

An account needs an email only when it can be used away from this computer: when a password or passkey is set, when an own sign-in or invitation is created, or when an outside sign-in is linked (the provider’s email is used when it supplies a usable, unused one; otherwise the request must carry one, which no Dashboard screen offers yet). The one exception is the this-computer account made at setup, which has only a name and works only on that computer until it is secured; accounts.email and normalized_email are nullable for it. An account without an email cannot sign in with an email, use email recovery or be invited by email (recovery codes still work once it is secured), and the screens show No email for it. There are no email-less “local-only” accounts and no sign-in by Profile ID: a person without their own sign-in is a profile in someone else’s household, opened by switching profiles (guarded by the profile PIN when one is set). At startup, a data store from before this rule has each email-less account’s profiles moved into the server administrator’s household and the email-less account removed; if that would exceed eight profiles, startup stops with a message naming the profiles and the tuvima-admin recovery step instead of dropping anything. A localhost request alone is never an administrator identity. Public setup, authentication, recovery, and health routes are explicitly designated exceptions; other endpoints require an authenticated authority and the applicable operation/resource checks.

Applications and native clients

Applications own registered service permissions. Their credentials are hashed, independently revocable, and shown once when created. Rotating a credential does not change Application permissions. X-Api-Key resolves the live Application and exact credential rather than a role embedded in a key.

Server integrations and automation act as service principals. User clients additionally bind the current account, active profile (in the user’s household, with the profile’s authorization version), device, consent, and native token. Effective delegated access is their intersection. Disabling an account, grant, Application, credential, device, or token must revoke dependent access. The Dashboard service identity only authorizes its narrow transport duties; it cannot substitute for the signed-in human.

Switching person on a paired device. A paired app (a phone or television) switches profile without re-pairing through GET /api/v1/devices/current/profiles and POST /api/v1/devices/current/switch-profile (DeviceProfileSwitchService). Only the paired app’s own bearer token for a live device of its own account may call them: a Dashboard session, another account’s token or a service credential is refused with 403, even when it carries the device id. The people offered and accepted are the profiles of the device’s user’s own household, the same check the Dashboard’s POST /auth/session/switch-profile makes. The PIN goes through IProfilePinVerifier.VerifyProfilePinAsync, the Dashboard switch’s own check, and is asked at every switch, so a Kids profile cannot hand the device back to a PIN-protected person without it. Wrong PINs always count toward the profile’s lockout (5 failures lock it for 15 minutes), as for any app. A refused switch changes nothing and writes no audit entry. The household is checked before the profile is looked up, so an id that does not exist and one of another household both answer 403 profile_not_allowed. A missing or wrong PIN answers 428 pin_required. Both actions share one allowance of 30 a minute per device (DeviceActionThrottle, a bounded in-memory table that drops devices idle for a minute). It is keyed by the device, not the token, so rotating tokens never buys a new allowance, and it is only consulted after the Engine has verified a live paired device of the caller’s own account, so made-up bearer strings or forged device ids allocate nothing. Over the allowance answers 429 rate_limited with Retry-After. Switching to the person the device is already using answers 409 already_active_profile before anything else is checked, so a switch cannot be looped just to mint tokens. A switch re-checks everything a refresh does (account, the new profile’s household, Application and its scopes), then in one transaction rebinds the device to the new profile, revokes every token the device holds (reason profile_switched) and stores a new access and refresh pair in a new token family, so one device never has two live profiles and the previous refresh token cannot be used again. The transaction only goes ahead while the device is still on the profile the caller authenticated as and the caller’s own access token is not revoked, so of two switches made at once exactly one succeeds. Token lifetimes, the device id and the approved scopes do not change. Each switch writes the audit event device.profile_switched (actor account, the new profile, subject device, from_profile_id as the caller authenticated and to_profile_id; never the PIN) in that same transaction, so a switch is never stored without its audit entry and a failed audit write undoes the switch. Wrong profile PINs are limited in the data store, not from a snapshot read in memory: each guess first reserves an attempt with one conditional UPDATE (matching no row while the profile is locked or already has 5 attempts outstanding), and a guess that reserves nothing is refused without being checked. A right guess clears the count only while no lock is running, so a burst of parallel guesses cannot exceed 5 checks or undo a lock it just set, and a lock that has run out starts the count again at 1. This is the shared path the Dashboard switch, profile sign-in and phone backup also use. The new profile’s content limit applies from the next request because the limit is read live for the token’s active profile.

Photos and personal videos for paired apps. The three scopes view.personal.read, view.originals.read and view.galleries.read (ApplicationPermissionIds.NativeClientPhotos, ClientApiScopes.Photos) are in the set an app may request but never in the defaults or the Media Player preset, and they are granted only through the person’s own explicit consent. The Dashboard’s /pair screen lists them as one separate item, Allow access to my photos and videos, unticked by default; the Dashboard turns that single tick into the three scopes in the decision, and a photo scope posted as an ordinary scope field is dropped. ClientAuthorizationService.DecideAsync never grants them unless the decision lists them and still intersects every decision with what the app requested, so a person who leaves the item unticked, an older Dashboard, or a direct caller approving with no scopes cannot grant photo access silently. An app gets only what the person approves: an approval must name its scopes in PairingDecisionRequest.scopes, and one that leaves nothing to grant (omitted, an empty list, or naming nothing the app asked for) throws ArgumentException and the decision endpoint answers 400 before anything is stored, so no device that can do nothing is created and no path grants everything by default. A denial needs no scopes, and an unknown, expired or already decided code answers 404 before the scopes are looked at. The /pair form always sends an explicit list and refuses an approval with nothing ticked on the Dashboard’s server (the screen shows “Choose at least one thing this device may do, or deny the request.” with status 400 and never calls the Engine). The photo scopes also need the requesting Application to hold them: PairingReviewResponse.offers_photo_scopes (the Application is enabled, is the bound user client and HasEffectivePermissionsAsync holds for all three) decides whether /pair shows the photo item, and DecideAsync refuses a photo scope for an Application without the grants with 400, so a crafted post cannot create a device that would fail at token exchange with access_denied. Re-pairing is the only way to change the photo choice; removing the device ends its photo access at once. Removing any one photo grant from the built-in Application later signs out every device that opted in (token validation re-checks the Application grants for every scope on the token), so they must pair again. On existing data stores the one-time access_native_photos_seeded seed added the three photo grants to the built-in Application even where an administrator had trimmed its permissions; they are inert for a device without that device’s own per-pairing consent. Both layers must hold them, as for every Application permission: the built-in native-client Application (granted once at startup by the access_native_photos_seeded marker, so later administrative removal sticks and a deleted Application is not recreated) and the device token’s own scopes (HasDelegatedConsent reads the token claims, EvaluateApplicationPermissionAsync reads the Application grants). Revocation is the existing device revocation: the device’s tokens stop validating at once, so its photo access ends with it (401); an Application that loses the scopes stops serving existing devices too. A device paired before the scopes existed, or one approved without the item, gets 403 on every /api/v1/view route. Resource rules are unchanged: only the active profile’s own photos and videos and Galleries shared with that profile, documents answering 404 as for an unknown id, no write verbs. The Engine enforces that too, not just the door: ViewResourceAuthorizationService refuses any non-read View action from an app (HasApplicationContext) with 403 unless a write scope names it (Galleries with view.galleries.write, uploads with view.upload), so even a device token sent straight to the Engine cannot favourite, hide, archive, trash or restore the person’s items or edit an item’s own details (description, date, people, location, tags). The Documents routes that change something (creating, renaming or deleting tags, correspondents, document types and fields; creating, changing, reordering, deleting or applying auto-filing rules; filing from the Inbox; bulk edits) first ask the same question as changing the person’s own View settings, which a person passes and an app is refused with 403; reading the catalogue and rules and trying a rule out stay open to the app. The Dashboard’s app door (NativeApiForwardPolicy) passes exactly the eleven GET view routes and nothing else under view/. A paired app’s download list. GET/POST /api/v1/devices/current/downloads and DELETE /api/v1/devices/current/downloads/{variantId} (DeviceDownloadService) follow the same device rule: only the paired app’s own bearer token for a live device of its own account, whose token profile is the profile the device is on, may call them. A Dashboard session, another account’s token, a service credential or a token left over from before a profile switch is refused with 403. The device always comes from the token, never the body, and rows are kept per device and profile, so one device never sees another’s list and a profile switch never shows the earlier person’s downloads. Adding a row needs a ready, unexpired variant that this exact profile and device prepared, for an asset the profile may download (CatalogueResourceAuthorizationService.EvaluateAssetAsync with downloads.read, the same check as the download route, so a library hidden from the person and a Kids content limit apply); anything else, including a variant that belongs to someone else, answers 404 exactly like an unknown id. A device holds at most 500 rows. This adds no new way to read file bytes: signed URLs, scope rules and the DownloadsRead check on the download route are unchanged. The same list also takes photos (kind photo, photo_item_id): adding one needs downloads.write and the View photo scope (view.personal.read) on the token, and the item is authorised exactly like the photo preview route (IViewResourceAuthorizationService with the Mine scope for the token’s active profile; a document, someone else’s item and an unknown id are all the same 404, regardless of the profile’s Documents setting). Listing re-checks every kept photo the same way, in one bulk pass (capability and scope resolved once, the items looked up by list, no per-item file check), and omits any the person can no longer read; a file that is only missing from disk for now does not hide a photo. A reported photo size is clamped to 256 MB. The reserved book kind is refused with 400; the photo routes’ own authorization is unchanged. Photos and personal videos for paired apps. The three scopes view.personal.read, view.originals.read and view.galleries.read (ApplicationPermissionIds.NativeClientPhotos, ClientApiScopes.Photos) are in the set an app may request but never in the defaults or the Media Player preset, and they are granted only through the person’s own explicit consent. The Dashboard’s /pair screen lists them as one separate item, Allow access to my photos and videos, unticked by default; the Dashboard turns that single tick into the three scopes in the decision, and a photo scope posted as an ordinary scope field is dropped. ClientAuthorizationService.DecideAsync never grants them from a decision that names no scopes and still intersects every decision with what the app requested, so a person who leaves the item unticked, an older Dashboard, or a direct caller approving with no scopes cannot grant photo access silently. Both layers must hold them, as for every Application permission: the built-in native-client Application (granted once at startup by the access_native_photos_seeded marker, so later administrative removal sticks and a deleted Application is not recreated) and the device token’s own scopes (HasDelegatedConsent reads the token claims, EvaluateApplicationPermissionAsync reads the Application grants). Revocation is the existing device revocation: the device’s tokens stop validating at once, so its photo access ends with it (401); an Application that loses the scopes stops serving existing devices too. A device paired before the scopes existed, or one approved without the item, gets 403 on every /api/v1/view route. Resource rules are unchanged: only the active profile’s own photos and videos and Galleries shared with that profile, documents answering 404 as for an unknown id, no write verbs. The Engine enforces that too, not just the door: ViewResourceAuthorizationService refuses any non-read View action from an app (HasApplicationContext) with 403 unless a write scope names it (Galleries with view.galleries.write, uploads with view.upload), so even a device token sent straight to the Engine cannot favourite, hide, archive, trash or restore the person’s items or edit an item’s own details (description, date, people, location, tags). The Documents routes that change something (creating, renaming or deleting tags, correspondents, document types and fields; creating, changing, reordering, deleting or applying auto-filing rules; filing from the Inbox; bulk edits) first ask the same question as changing the person’s own View settings, which a person passes and an app is refused with 403; reading the catalogue and rules and trying a rule out stay open to the app. The Dashboard’s app door (NativeApiForwardPolicy) passes exactly the eleven GET view routes and nothing else under view/.

Interactive Dashboard clients use DashboardCircuitHttpClientFactory to forward the current circuit’s session outside the pooled HTTP-handler scope. The existing handler pipeline still resolves the protected service credential at send time. A cleared established session suppresses stale ambient-request fallback; an explicit identity-validation token remains a separate supported request. Client configuration and pooled transport lifetime remain owned by the normal HTTP factory.

Profile loading initializes the existing principal and awaits validated authority before its first request. Empty failures remain retryable, and delayed results cannot publish after session identity or access changes. Repeated validation with equivalent authority does not emit a false change event or cancel active playback tools; genuine session, grant or capability changes retain the revocation path. The live refinement evidence is recorded in the implementation report.

TuvimaAuthentication establishes identity; IRequestAuthorityResolver resolves current authority; IAuthorizationEvaluator decides registered operations. Endpoint metadata exposes those decisions for mapped-route guardrails. A valid credential alone is insufficient to authorize an operation.

Resource scope

Catalogue access applies the feature (area) and the hidden-library rule to concrete assets before representative selection, counts, grouping, pagination, and serialization. Every artwork, download, reader, HLS, queue, history, bookmark, and progress path verifies its resource independently. Multiple assets of one work do not let an allowed library reveal another library’s variant. HLS grants retain exact live authority bindings, and personal writes require the current profile.

A browser reader needs the book file itself, and a browser cannot attach the Engine’s credentials. The Dashboard route /engine-book/{assetId}/file (EngineBookProxyEndpoint) therefore fetches GET /read/{assetId}/file for the signed-in person. It is deliberately narrower than the artwork proxy: exactly one asset id in canonical form and one fixed suffix, parsed strictly and rebuilt into the Engine path, so a request can never be steered to another Engine route, and the query string is never forwarded. The Engine call goes through the normal Engine client, which resolves the Dashboard’s credential at send time and forwards the person’s session, so the Engine still decides whether the library is hidden from the person, the feature and the profile content limit; a missing or unreadable credential fails closed, and a signed-in cookie that carries no Engine session answers 401 without calling the Engine at all (the Dashboard’s own credential is never used on its own to read a book). Book reads have their own reader_files rate limit, counted per signed-in session rather than per shared Dashboard address (ReaderFileRateLimitPartition; rate_limiting.reader_files in config/core.json). Only Range, If-Range, If-None-Match and If-Modified-Since cross to the Engine, and only content type, length, range, validators and Retry-After come back. An Engine refusal and a missing book both read as 404, so a browser cannot probe which books exist; other Engine failures read as 502. The Engine route serves only reading formats (.epub, .cbz, .cbr, .pdf), never video or audio, even to someone who may read the library. Responses are private, no-cache (every use is authorised again), nosniff and sandboxed (Content-Security-Policy: default-src 'none'; sandbox) because the file is user content that must never run as a page on the Dashboard origin.

View has its own feature and resource policies. Private access resolves the exact active profile. Explicit administrator inspection remains a separate scope. Shared Library access, contributions, review, and Gallery sharing remain separate policies. A Gallery share authorizes proven Gallery members and derivatives, never sibling assets or an entire private source. View assets never enter catalogue provider enrichment. Deleting identity records must not delete original media or transfer a private space to another person.

Open screens

An open Dashboard screen is a Blazor circuit that never sees a new HTTP request, so it follows access changes by two routes.

  • One-minute check (the backstop). SessionRevalidatingAuthenticationStateProvider asks the Engine once a minute whether the screen’s sign-in still stands. The same call refreshes what the person may do, so there is at most one Engine validation per open screen per minute and none per page interaction. A revoked or disabled sign-in turns the screen anonymous and SessionEndedRedirect (in every layout a signed-in person can reach) sends it to /auth/login. An Engine hiccup (timeout, throttling, restart) never signs anyone out; only a proven invalid sign-in does.
  • Instant for big changes. OpenScreenCircuitHandler registers each signed-in circuit in the singleton OpenScreenRegistry (account, profile, session and a hash of the session token) and removes it when the circuit closes. After the Engine confirms the change, the Dashboard calls CloseWhere so matching screens go to sign-in at once: removing or disabling a person, removing a profile or a profile grant, signing out a device, and signing out other sessions (the kept session stays open).

Not covered: media already streaming (it uses short grants) and Engine-pushed session events.

Authentication and recovery

Tuvima is open from the start, and network location is not checked. Any device that can reach the server can use it, and Tuvima sign-in is always required. Where a visitor connects from never decides whether they are let in: there is no setting for how far away a visitor may be (location checks were removed on 2026-10-11 and the leftover setting retired on 2026-10-17; see ADR 0009), IngressClassifier reports this_computer only for a direct loopback connection and home_network for everything else, ClientIngress.Parse maps every other value to home_network, AuthenticationEndpoints.AllowsClient only checks that the method is enabled, the paired-app door and cast relay always answer, and SecureAccountGate never refuses. Closed router ports are the gatekeeper. Account lockout and the profile PIN limit apply to every attempt wherever it comes from (5 failures lock the credential for 15 minutes). The no-password “use Tuvima on this computer” account can be used from any device that reaches the server, including a guest on the home Wi-Fi, with its administrator rights, until a password or passkey is added; adding one is the protection. The Dashboard serves plain http, so if its port is forwarded to the internet, sign-ins and pairing codes cross the internet unencrypted: for access from outside, use Tailscale or an HTTPS reverse proxy. issued_ingress and this_computer_only still exist in the data store but restrict nothing beyond the sign-in-this-computer account rules above.

Settings → Users and Sign-in holds the accounts (Users), Integrations and Sign-in Methods; households, people, administrator PINs and their own sign-ins are looked after under Settings → Profiles (administrators only), with a Households page under Users and Sign-in for server administrators. Authentication controls local passwords, passkeys, invitation policy, session policy, and external providers. Readiness is derived from real configuration; unavailable sign-in methods carry reasons. Verified external identities use provider, canonical issuer, and immutable subject. Email alone never silently links an identity. See external authentication.

Everyone’s sessions. Settings → Security → Devices lists and ends other people’s sessions through GET /access/sessions and DELETE /access/sessions/{sessionId} (ManagedSessionService). The scope is decided in one place: a server administrator reaches every account, a household administrator reaches accounts of their own household except server administrators and other household administrators (the same protection RequireNotAdministratorTarget gives their sign-ins elsewhere), and anyone else or any app is refused with 403. Ending a session needs a recent sign-in, refuses the caller’s own session with 400 (it is ended with Sign out), is audited as account.session_revoked_by_administrator, and closes the Dashboard screens open on that session. The self-service endpoints (/auth/sessions) are unchanged. Every signed-in person, a member included, sees Sign out everywhere else on Security: it calls DELETE /auth/sessions/others, which takes the account and current session from the caller’s own sign-in (never from the request), so it can only end that account’s other sessions and never the one in use. DELETE /auth/sessions/{sessionId} likewise answers 403 for a session that is not the caller’s own. Devices is a child of Security in the Settings menu for everyone signed in except Kids profiles: a member’s page lists only their own paired devices (GET /access/devices) and browsers (GET /auth/sessions), and the Dashboard only chooses what to show, since the Engine decides what each caller may list or sign out. Paired devices follow the same unlock rule as the sessions list (ManagedClientDeviceService): a server administrator sees every device, and may sign out other people’s, only while their administrator PIN is unlocked (when they use one); until then they see and manage their own account’s devices alone, like a member, and ending someone else’s device answers 403. A household administrator’s household-wide view needs the same unlock.

Starting without a password. Setup can start with just a name (POST /setup/v1/administrator with sign_in = this_computer; the email field is not asked for and is ignored). The account has no password credential and accounts.this_computer_only = 1 (Account.IsThisComputerOnly, changed only through MarkThisComputerOnly/ClearThisComputerOnly). To come back later without a password the Dashboard’s GET /auth/login asks the Engine (GET /auth/this-computer) and, when it names the account, signs in with POST /auth/this-computer and goes straight in: to the profile picker when the account has several profiles (choose_profile), otherwise to Home, with no sign-in page (DashboardAuthenticationEndpoints.StartWithoutPasswordAsync). That means GET /auth/login can sign a visitor in without a click, which is accepted because it applies only to the single passwordless setup administrator. Only a top-level page visit signs in: a request whose fetch metadata says it is not one (Sec-Fetch-Dest other than document, Sec-Fetch-Mode other than navigate, or a Sec-Purpose/Purpose prefetch) gets the sign-in page without a session, because each automatic sign-in is a new Engine session and the Engine ends the least recently used ones past maximum_active_sessions; cookieless icon or background fetches used to start one each and end the session an open screen was using. /favicon.ico is served without sign-in for the same reason (FaviconEndpoint). An explicit sign-out redirects to /auth/login?signedOut=1, which skips the automatic sign-in and offers a single Go back in button. The Engine still refuses /auth/this-computer for a request that arrived with X-Forwarded-For/Forwarded or that the Dashboard marked with X-Tuvima-Client-Forwarded: true. Once an administrator has a password or passkey the account stops being offered and the sign-in page is shown as normal.

Sessions record where they started. Every session stores the place it was made from (this_computer or home_network) in auth_sessions.issued_ingress, for audit only. It no longer limits where the session works.

One sign-in check per burst of requests. A page load fetches dozens of scripts, styles and images from the Dashboard, and each carries the sign-in cookie. DashboardIdentityClient.ValidateCookieAsync answers them from one Engine POST /auth/session/validate through SessionValidationCache: requests from the same session token, place (ingress) and Dashboard credential share one in-flight check, and a check that came back valid is reused for at most 5 seconds (never past the session’s own expires_at or an administrator unlock’s administrator_unlock_expires_at). Only valid answers are kept. A refusal, a throttled or unreachable Engine and a missing or unreadable Dashboard credential are never remembered and never answered from memory (the call goes out and the credential handler refuses it), and a rotated credential, another token or another ingress is a different key. Any call through the identity client that is not a read (sign out, switch profile, revoke a session or device, change access, password or protection) empties the cache before and after it (SessionValidationInvalidationHandler), and a check already under way when that happens is neither joined nor kept. The Engine still validates the session on every call it receives, and each open screen still re-checks every 60 seconds without the cache, so the only added delay is up to 5 seconds for a change made outside this Dashboard (for example host recovery) to reach the cookie check.

The last administrator who can sign in. The server always keeps one enabled server administrator profile whose user can sign in. AccountAccessMutationService refuses (409 with “This is the last administrator who can sign in. Make someone else an administrator first.”) any change that would leave none: demoting that profile (PUT /access/profiles/{id}/roles), deleting its user (DELETE /access/users/{id}) or the profile itself, or disabling its user. The check is made after the change, through IUsableAdministratorService.HasUsableAdministratorSignInAfterAsync, under the current sign-in policy (an administrator whose only sign-in method is switched off in Authentication does not count as someone else), so a second administrator who can sign in makes the change allowed. The check and the save run under AuthenticationPolicyMutationGate, the lock sign-in-method removal also uses, so two administrators removing each other at the same moment cannot both pass. A this-computer-only administrator counts while its profile holds the server administrator role. The data store keeps its own simpler last-administrator rule (an enabled user on a server administrator profile with a password, passkey or linked provider) as a backstop.

The administrator-console PIN stays optional: sign-in and profile switching never ask for it, and the unlock is requested only when entering administrator areas while protection is on. A restricted (child) profile never has administrator authority, even if its grant says AdminEnabled; the Engine refuses to set it and the Dashboard hides the toggle.

Administrator password recovery uses one-time recovery codes or the elevated host command:

Terminal window
dotnet run --project src/MediaEngine.Admin -- auth reset-password --config-dir config

The command takes the password through a non-echoing interactive prompt, requires operating-system administration, and refuses to create a missing database. Recovery invalidates existing security credentials/sessions according to the account recovery service. There is no anonymous localhost password-reset bypass.

Recent sign-in and securing an account. Each session records authenticated_at. Sensitive actions (change password, add or remove a passkey, link or unlink a provider, replace recovery codes, sign out other sessions, set or remove a PIN) are refused with 403 and code: "confirm_its_you" unless it is within RecentSignIn.Window (10 minutes); RecentSignInGuard enforces this on the Engine and a missing session counts as stale. POST /auth/confirm refreshes it with the password or a passkey and respects credential lockout. A this-computer-only account has no secret to confirm with, so its ThisComputer sessions count as recent. POST /auth/account/secure lets that account add a password or passkey (and, when it has no email, the email to sign in with; FirstPartyIdentityService.SecureThisComputerAccountAsync normalises it, refuses one another account uses and stores it), clears this_computer_only, replaces recovery codes and revokes every old session; the Dashboard’s /account/secure form issues the replacement cookie because Blazor circuits cannot set cookies. Changing a password no longer asks for the old one (the recent check replaces it), and no action removes a password. The last-method guard is unchanged.

Two-step codes. An account with a password can opt in to time-based one-time codes (RFC 6238: HMAC-SHA1, 30-second steps, 6 digits, one step of clock drift accepted either side) implemented in MediaEngine.Identity/TotpGenerator.cs with no extra library. The secret lives in account_two_step.secret_protected, encrypted with ASP.NET Data Protection (purpose Tuvima.TwoStep.Secret.v1), so copying the data store alone does not reveal it. last_used_step is advanced atomically, so a code works once even across devices. A row with no enabled_at is an unconfirmed setup. When two-step is on, POST /auth/login returns 202 with two_step_required and a pending token instead of a session. The token is random, stored only as a hash in two_step_challenges, lasts 5 minutes, works once, allows 5 wrong codes, and is bound to the ingress it was created for; presenting it from a different ingress ends it. POST /auth/two-step/verify accepts the 6-digit code or a recovery code and issues the session. Wrong codes follow the same lockout rules as passwords: only remote attempts count, and the password failure counter is not reset while a code is still pending, so repeating the password cannot restart code guessing. POST /auth/confirm also needs the code. Passkey and external-provider sign-ins skip the code by design. Turning it on, off and resetting are behind RecentSignInGuard. An administrator can turn it off for another account (POST /access/accounts/{accountId}/two-step/reset, audited as account.two_step_reset; a server administrator needs an unlocked administrator session to reset another administrator), and the host console command tuvima-admin auth reset-two-step --email does the same without a code and writes a two_step_reset identity audit event. Text or email codes, trusted devices and enforcing two-step for everyone are not part of this feature.

Password rules

Account passwords need at least 12 characters (128 at most). The server refuses any password found in the bundled common-password list (src/MediaEngine.Identity/Resources/common-passwords.txt, derived from SecLists, MIT) and any password that equals the account’s email address or the profile’s display name, ignoring case. The rule runs when a password is created or changed: bootstrap, invitation accept, change password, recovery-code reset, reset-link reset and the host reset command. Existing shorter passwords still sign in. The Dashboard shows the same hint under each password field.

Admin-created sign-ins, no self sign-up. Only an administrator creates accounts (the first administrator is created by the setup session). AccountCreationGuardrailTests lists every source file that can create an accounts row and fails when a new one appears or when the administrator-only routes lose their RequireAdministratorOrApplication policy; linked sign-in never creates accounts. An administrator can hand over a new sign-in in two ways, and Tuvima sends no email for either. Invitation code: InvitationCode makes ten characters from ABCDEFGHJKMNPQRSTUVWXYZ23456789 (about 50 bits), shown as XXXXX-XXXXX; only the SHA-256 of the normalized code is stored (account_invitations.token_hash), it works once and expires after auth.invitation_lifetime_hours. The Dashboard shows the code, the link /auth/invite?code=…, and an SVG QR code (QRCoder, MIT) once. Preview and accept answer an unknown, expired, used or disabled-account code with the same plain 404/401, and wrong codes are counted by SignInAttemptLimiter (see the per-address limits below) and the Engine authentication limiter. Temporary password: accounts.must_change_password and temporary_password_expires_at (always 7 days). PasswordChangeRequiredMiddleware (after authentication) refuses every endpoint except ChangeTemporaryPassword, ValidateAuthSession and RevokeAuthSession with 403 password_change_required when the session’s account has the flag, and the Dashboard redirects every page to the full-page /auth/change-password. Changing it checks the temporary password, applies the normal password rules, clears the flag, revokes all of the account’s sessions and issues a new one. Once it has expired, sign-in says “Ask your administrator for a new temporary password.” (only after the password itself was right) and existing sessions stop validating. Choosing a password by any other route (reset, recovery) also clears the flag. Passkey enrolment is not offered on the invitation page; it is added afterwards in Account > Security.

Sign-in limits

Three layers keep guessing in check without letting one person lock out everyone else:

  • The Engine only answers to loopback names. Its AllowedHosts is localhost;127.0.0.1;[::1], so a web page that rebinds a hostname to the user’s machine (DNS rebinding) is refused with 400. The Dashboard (Engine:BaseUrl or TUVIMA_ENGINE_URL, which must therefore be a loopback name), the Docker healthcheck and the installer all use one of those names, and the Dashboard no longer forwards a visitor’s Host header on passkey calls; a guardrail test fails if the setting goes back to *.
  • Passkeys belong to the public address. PublicAddressPasskeyOptions sets the passkey relying-party domain to the host of network.remote.public_hostname and accepts only that exact origin, read again for every request scope so a changed address applies without a restart. With no valid public address no origin is accepted. The sign-in page therefore offers the passkey button (and the Dashboard accepts passkey option requests) only for a visitor who arrived at exactly that origin; anyone on localhost or a LAN address sees password sign-in only.
  • Pairing links ignore forwarded headers. The device-pairing link is built from the public address when set, otherwise the Engine’s own request origin; X-Forwarded-Proto and X-Forwarded-Host are never trusted. The Dashboard’s app door then rewrites verification_uri and verification_uri_complete in the pairing response to the public address, or, with none set, to its own host-allow-listed request origin, so apps never see the Engine’s loopback address.
  • The app door forwards a short, anchored list. NativeApiForwardPolicy passes only the routes the native apps call, by method and exact path shape, and answers everything else 404 without contacting the Engine. Display: GET display/{home|browse|continue|recent|search|contributor-shelves}, display/shelves/{key}, display/groups/{id} and display/artwork/media-assets/{id}/effective-cover. The artwork workspace under display/artwork (library browse, asset search, entity and Universe workspaces, effective work artwork, raw asset content) is not forwarded. Devices: GET devices, DELETE devices/{id}, PUT devices/current/capabilities, GET devices/current/profiles, POST devices/current/switch-profile, GET devices/current/playback-preferences (read-only; the profile comes from the token’s active-profile claim, never the path), GET/POST devices/current/downloads, DELETE devices/current/downloads/{id} and .../photos/{id}; nothing else under devices/.
  • Casting uses a one-title secret link, not the phone’s token. A Chromecast or Google TV cannot send a bearer token, so a paired phone asks POST /api/v1/playback/{assetId}/cast and gets paths under /cast/v1/{grant}/. The grant (CastAccessGrantService, Data Protection purpose exactly Tuvima.Library.Playback.CastAccess.v1, never shared with the HLS grant, so the two cannot be swapped) sits in the URL path and is bound to one asset, the HLS package if any, the account, profile, device, Application, the phone’s token family (not its ten-minute access token), the three authorisation versions, the scopes (they must include playback.read) and an expiry. It is not bound to an IP address. Only a live paired device can create one. Every request re-checks the whole binding: the grant is intact and unexpired, the device is unrevoked and still on that profile, the token family is live, the account, profile grant and Application are enabled with the same versions, the Application still holds playback.read, and the catalogue still allows that profile to play that asset (so a lowered Kids limit stops the TV). Signing the phone out, switching profile, revoking the device or changing permissions therefore stops the TV within about a minute. Lifetime is the film length plus 90 minutes, never under 2 or over 6 hours; the phone asks again to renew. The routes serve GET, HEAD and OPTIONS only, with Cache-Control: private, no-store, Referrer-Policy: no-referrer, X-Content-Type-Options: nosniff and Access-Control-Allow-Origin: * (exposing only Content-Length, Content-Range and Accept-Ranges; never credentials; the preflight allows only Range and caches for 600 seconds). The grant is a secret: it is never logged (the Engine drops any log event that spells /cast/v1/, and its own cast logging records only the first eight hex characters of the grant’s SHA-256). The Engine answers /cast/v1/ only on loopback; the Dashboard relays it (CastReceiverRelayEndpoints) for any device that reaches the server and holds a well-formed grant (the link is the credential, so a leaked cast link works for anyone who can reach the server until it expires; see ADR 0009), forwards just Range and If-Range (never Authorization or a cookie) and returns just the media and CORS headers. The phone’s call goes through the app door as POST playback/{id}/cast only.
  • The artwork workspace is administrator-only on the Engine too. GET /api/v1/display/artwork and the /assets, /assets/{id}/content, /entities/{type}/{id}, /universes/{id}/entities and /works/{id}/effective routes read raw artwork over every Work and Collection with no profile or content-limit filter. They require AdministratorOrApplication(artwork.read) (an administrator, or a service Application), not just the artwork.read scope every paired app holds, so a paired Kids or non-administrator adult token gets 403 even if the door ever widens. A paired administrator’s phone would pass the Engine policy; the door, which never forwards these routes, is what keeps an app off them. Image URLs the workspace links to a person, Work, Universe or entity are stored as the old content route but read (EntityAssetRepository) as the catalogue-checked /stream/artwork/{entity asset id}, so people and entity art stays visible to paired apps and non-administrator Dashboard members. The one profile-checked route, effective-cover, authorizes the owned file for the profile and stays an app route.
  • A paired app only manages itself. For a paired-device token, GET /api/v1/devices returns only the caller’s own device, and DELETE /api/v1/devices/{id} is accepted only for the token’s own device id (sign-out); any other id answers 404, exactly like an unknown device. A profile switch moves the device to another person’s profile, so without this a TV switched to a parent’s profile could list or sign out the parent’s other devices. A signed-in person (the Dashboard) keeps the profile-wide behaviour; device management stays in /access/devices.
  • Deleting from the library is in person only. POST /library/items/deletion-preview and POST /library/items/delete require an effective administrator signed in at the Dashboard, with the surface PIN unlocked where one is set (RequireEffectiveAdministratorInPerson). No paired app, native client or service Application gets them, even one acting for an administrator, and the Engine checks on every call.
  • The Engine treats the Dashboard as many people. Because every Dashboard-forwarded sign-in reaches the Engine from the Dashboard’s own address, requests carrying the Dashboard service credential get their own partition of the authentication policy (300 a minute, AuthenticationRateLimitPartition); the credential is recognised from memory so the limiter still runs before authentication. Any other caller keeps 10 a minute per address.
  • Account lockout counts every failure. After 5 failures the password or PIN is locked for 15 minutes, wherever the attempts come from, because no per-address limit remains. A stranger who knows an email address can lock the owner out; that is the accepted trade-off.

Plugin and event boundaries

Plugin execution receives host-bound identity and declared capabilities. External service permissions are separate from host capabilities. Unimplemented capabilities remain unavailable with a reason. The Fandom Lore service exposes one bounded typed operation with authorization before effects. A plugin that holds the internet permission (network.http) must declare where it connects in its manifest network list (a host, which may begin with one *. wildcard label, and a plain sentence of what is sent there). The declaration is part of the approval statement, so a person sees one “Contacts host: what it sends” line per destination, the plugin card shows the hosts under “Sends data out”, and a changed destination asks for approval again. The plugin web connection (plugin_http) enforces the list: a request or redirect to an undeclared host is refused, and each declared host has a per-minute request limit (the destination’s max_requests_per_minute, default 60). Fandom Lore only looks up Wikidata QIDs for Universes the Library already holds, only calls www.wikidata.org and https addresses on fandom.com (and its subdomains), and never sends Work titles, authors, file names or any list of them; the one value sent to Wikidata is the Universe’s own QID.

Application events use a dedicated /application-events hub and durable outbox. Envelopes contain event_id, event_type, version, occurred_at, server_id, subject, and payload; the server ID is a persisted opaque identity. Delivery rechecks the current principal and resource scope. Replay is bounded and reports gaps. Dashboard Intercom recipients also receive live account/resource checks. Its frozen Lore/Universe contract pair is unchanged; the reviewed ingestion event contracts add optional asset and pre-removal provenance fields so event authorization remains possible after deletion. Durable projection precedes best-effort Dashboard delivery, and one failed recipient does not discard the event or block other recipients. Exact live-connection and producer evidence is tracked in the execution status.

Webhooks belong to service Applications. Endpoint configuration, selected event permissions, and delivery are revalidated. HTTPS is the public default; explicit local-network approval permits private destinations. Loopback, link-local/metadata, redirects, proxy routing, mixed unsafe DNS answers, and DNS rebinding are rejected. Delivery signs the timestamp plus the exact UTF-8 body with HMAC-SHA256. Signing secrets appear once and are protected at rest. Retries retain a stable delivery identity, bounded queue, attempt count, and expiry; errors expose sanitized status without credentials or response bodies. Each attempt is kept in a short delivery history (attempt number, time and a plain result such as HTTP 503; never the event body, the secret or what the receiver said), and Send test sends one signed webhook.test event through the same transport and address checks, once and without retries, only while the webhook is switched on.

Approval before switching on

One rule covers everything an administrator can switch on: a plugin, a Local AI feature, an application key and a webhook, and it also covers widening an integration (giving an application’s existing keys more permissions, or switching an application back on while it holds live keys). Nothing is switched on until a server administrator has seen, in plain words written by the Engine, what it can do and whether anything leaves the machine, and confirmed. The confirmation carries a hash of exactly what was shown (approval_hash), the Engine compares it with the statement as it is now, and only then records who confirmed, when, and the statement in the append-only approval_records table. A statement that changes (a plugin that now asks for another permission, a webhook pointed at another host) is asked about again, and the rule is enforced in the action itself, so an application with plugins.manage or ai.manage gets the same refusal (409 approval_required) and can approve only by sending the hash. Statements never contain secret values: a webhook is described by its receiver’s host only and a key is never shown or stored. Turning something off, and taking permissions away, need no approval. A plugin runs only while it is switched on, compatible and covered by an approval record for its current statement, checked every time the plugin list loads: a manifest that gains a permission (saved through the Engine or edited on disk) leaves the plugin loaded but not running, shown as “Needs approval: its permissions changed” (Needs attention) with Turn on offered, which goes through the normal approval. Local AI feature keys are read without regard to letter case and judged by their canonical key, so changing the case of a key cannot skip the approval. See Engine API Reference.

Secrets, limits, and lifecycle

Provider definitions live under config/providers/; long-lived provider credentials belong in gitignored overlays under config/secrets/. Authentication provider secrets use their dedicated protected overlay. Credentials, PINs, passwords, invitation/recovery tokens, and webhook signing secrets must not enter logs or ordinary DTOs.

Provider keys and plugin keys share one store, IConfigSecretsStore (ConfigFolderSecretsStore), kept in the config folder so it survives a data store wipe. A provider’s keys stay in config/secrets/{provider}.json; a plugin’s keys are in config/secrets/plugins/{plugin-id}.json. Each owner (a provider or a plugin) can reach only its own file, because owner names are limited to lowercase letters, digits, -, _ and .. The files are plaintext (encryption at rest is not part of this store yet), and backups skip the whole secrets/ folder and refuse to restore it. A plugin declares the secrets it needs in its manifest and reads them through IPluginExecutionContext.Secrets, which needs the secrets.plugin permission and returns nothing for an undeclared name; only an administrator can enter or remove a plugin secret, and the Engine never returns a value or writes one to a log.

Rate limits apply to authentication, credential operations, streaming, general API access, and real-time connections according to the registered policies. Live updates keep their own budgets (LiveUpdatesRateLimitPolicies), apart from sign-in: minting the short-lived Intercom token (intercom-token) already needs a signed-in session, so Dashboard-forwarded mints are counted per session (60 a minute) while other callers keep the 10-a-minute per-address sign-in allowance; the /intercom and /application-events hubs each allow 120 negotiate/transport requests a minute per address, because every Dashboard tab arrives from the Dashboard’s own address. The Dashboard renews the token only when it is within a minute of expiring and retries a refused or dropped live connection with jittered backoff (2 seconds doubling to 1 minute, never sooner than Retry-After), so it never keeps itself rate-limited. Folder and managed-asset operations validate intended roots and provenance before disk access; authorization does not waive existing-source protection.

Pre-beta obsolete database/configuration state fails fast and is rebuilt from configured sources. Do not add compatibility authorization schemas, old role readers, or automatic key conversion. This permission covers disposable application state only; original and read-only source media remain protected. The Access cutover procedure covers fresh identity state, native re-pairing, and protection of existing Personal Space directories.

Plugin trust, backups and integration health

A plugin is one of four kinds (official, JSON-only, separate program, sandboxed; see decision 0013), and all of them go through the approval rule above. A package installed from the directory is accepted only when its checksum matches, it comes from an https address on GitHub, it is within size and file-count limits and unpacks without path or link tricks, and (for Verified and Official entries) its ECDSA signature verifies against a Tuvima release key built into the Library; with no key built in, those installs fail closed. How a plugin was installed is recorded beside the plugins folder, never inside it, so a package cannot vouch for itself, and any change to its files makes it private. A directory blocklist switches a harmful plugin off and keeps it off. A separate-program plugin gets a random per-run key held only as a hash in memory and reaches the Library through a local-only door; a sandboxed plugin has no file system, clock, random or environment, and limits on memory, time and work. JSON-only metadata sources are held to the addresses the plugin declared and to a confidence cap, and their rules are set by the Library.

Backups are plain files and are never encrypted, including copies sent to another folder, a network share, S3 or WebDAV; keeping those copies safe is the person’s job. Every copy is read back and compared byte for byte. A copy fetched from a destination for restore goes through the same checks as an uploaded backup (unsafe entries, secrets, compression ratio), and a .tvbk file left by an older version is refused. Backups never include media files, secrets or key folders. An administrator adds or removes a destination in person; a web destination (S3-compatible storage, WebDAV) needs the exact host confirmed, uses https except on the local network, refuses link-local addresses, follows no redirects, reads XML without entity definitions, and keeps its login encrypted with the machine key and never returns it. A saved login is only ever sent to the server it was entered for.

Exporting and importing people. A user file (POST /access/me/export for the server administrator’s own profile and sign-in, POST /access/accounts/{accountId}/export for any other user and POST /access/households/{householdId}/export for a household, all for the server administrator only) carries what is needed to sign in as the people in it: email, password hash, two-step secret (readable, so it works on another computer; only ever the exporting server administrator’s own, so nobody holds a file that carries someone else’s second factor), passkeys, outside sign-in links, PIN hashes and each profile’s own state. Like a backup it is a plain file, never encrypted, and keeping it safe is the person’s job. Only the server administrator exports or imports anything (users spec D16): every other person, household administrators included, is just a user of the platform and gets 403 on every one of these routes, and so does an application, a service or a paired app. /access/me/export takes no account from the request. Every export and import needs an effective server administrator signed in at the Dashboard with the administrator PIN unlocked. All routes ask a stale sign-in to confirm it is them first (403 confirm_its_you) and record account.exported, household.exported or account.imported (never the file’s contents). The file never holds roles, hidden libraries (library ids differ between Libraries), administrator settings, other households’ people, devices, sessions or server settings. Importing never grants a role or hides a library (everything in the person’s areas is visible): every profile arrives as an ordinary member (Kids stays Kids), is_server_admin and is_household_admin are forced to 0 whatever the file says (except the one profile the administrator picks to look after a household the import creates, which needs a sign-in and is not a Kids profile), Read/Watch/Listen/View arrive only when the administrator ticks Keep their access, Vault access is switched off, the profiles always land in a household the administrator chose (never with no household), a second household needs every server administrator to have an email and a password first, and a household takes at most eight people. A sign-in may arrive with a different email, or none; an id already used here is replaced by a new one, so nothing existing is overwritten. The uploaded file is checked with the same safety rules as a restore plus a strict map of the tables a user file may hold and a proof that every row belongs to the file’s own sign-ins or profiles, kept for an hour in the backup folder for the uploading administrator only, and removed once imported (a refused import keeps it so a choice can be changed). A user file is refused by every route into a restore, so it can never replace a Library; a backup or user file made with another data structure is refused in one sentence.

Application keys suggest a one-year expiry; a live key unused for 90 days or expiring within 14 days, and a webhook failing three or five deliveries in a row, show Needs attention or Broken so a quiet failure is noticed. Notification events published to webhooks and plugins carry only a generic title unless “include details” is switched on.

Server folder browsing

Choosing library folders is an administrator action: the Engine’s server-folder actions accept only an administrator or the first-run setup session (revoked when setup completes). They browse every ready drive on Windows, or / on Linux (inside a container, whatever the container can see), plus any configured storage locations. Listing returns folder names and one “already has files” flag, never file contents. Tuvima’s own configuration, key, log, backup and data folders, operating-system folders, and .data folders cannot be chosen; a whole drive can be indexed read-only but not managed; and a folder inside a configured read-only storage location stays read-only however it was reached. Managed folders still pass a real write test.