Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ Reference material for Zaparoo Core's architecture, APIs, and subsystems. For de
- **Thread-safe**: `config.Instance` uses `syncutil.RWMutex`
- Maintain backward compatibility — use migrations for breaking changes

## Profiles

Device profiles are named buckets of preferences and limits, with no passwords or accounts. See `pkg/service/profiles/`.

- **Active profile**: one per device, held as a snapshot in service state (`pkg/service/state/`) and persisted in the UserDB `DeviceState` table so it survives restarts. The un-profiled state is the implicit **shared profile** — the device as it behaves when nobody is signed in: global-config limits, unattributed history, default data locations. It is an interpretation, not a database row; deactivating means switching to it.
- **Switching**: via API (`profiles.switch`) or by scanning a card containing `**profile:<switchId>`. The switch ID is a word phrase (e.g. `corn-arm-truck`) generated from an embedded wordlist and is a **bearer credential**: presenting it authorizes a PIN-free switch on every path, so the API only returns switch IDs to privileged (local/admin) clients. The PIN protects pick-from-list switching by `profileId`. PINs gate entry only; deactivating is always free.
- **Playtime limits**: profiles can override the global daily/session limits. `pkg/service/playtime.LimitsManager` reads limits through a `LimitsProvider`; the profile-aware resolver (`pkg/service/profiles.LimitsResolver`) layers the active profile's overrides over global config. Daily usage accounting is scoped to the active profile via the `ProfileID` column on `MediaHistory` (rows are attributed at launch time). Everything about a running game belongs to the profile that launched it: the limits context is pinned at media start, so deactivating mid-game keeps the launch profile's limits until the media stops. The session resets only when the profile *identity* changes (switching to a different person), never on rescans, edits, or deactivation.
- **Require-profile gate**: the `[profiles] require_for_launch` config setting stops the shared profile launching media (profile switch commands still run, so scanning a card unparks the device; a combo card that switches then launches passes).
- **Permissions**: profile management (create/update/delete, reading switch IDs) requires the `profiles.manage` capability — granted to local connections and admin-role paired clients (`pkg/api/permissions`). Client roles are chosen at pairing approval. While `service.encryption` is off, unpaired remote clients retain full access for compatibility; enabling it requires pairing and makes member restrictions enforceable.

## Reader Auto-Detection

10 reader types: acr122pcsc, externaldrive, file, libnfc, mqtt, opticaldrive, pn532, rs232barcode, simpleserial, tty2oled
148 changes: 148 additions & 0 deletions docs/api/methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -2613,6 +2613,154 @@ Returns `null` on success.
}
```

## Profiles

Profiles are lightweight device profiles: named buckets of preferences and limits, with no passwords or accounts. One profile is active per device at a time, switched via the API or by scanning an NFC card containing the profile's switch ID (`**profile:<switchId>`).

When no personal profile is active the device is on the implicit **shared profile** — the device as it behaves when nobody is signed in. The shared profile's playtime limits are the global config limits, its history is unattributed, and it owns everything the device did before profiles existed. Deactivating means switching to the shared profile. To stop the shared profile launching media (parking the device until someone identifies themselves), enable the `profilesRequireForLaunch` setting (see [settings](#settings)).

A profile's **switch ID is a bearer credential**: presenting it — by scanning the card it is written on, or by sending it over the API — authorizes switching to that profile with no PIN, on every path. Switch IDs are therefore only returned to privileged clients (local connections and admin-role paired clients) for card-writing; member clients never see them. The optional 4-8 digit **PIN** protects the remaining path: switching by `profileId` picked from the visible profile list. Leaving a profile is always free — PINs gate entry only.

**Trust model.** Profiles are a household convenience boundary, comparable to TV parental controls — not account security. They protect against: gaming playtime limits through a member-role paired client (profile management needs the admin role), through cards (switch IDs are bearer secrets, protected by physical card control), or through rescans (re-activating the active profile does not reset session limits). They do NOT protect against anyone with OS access to the device, an admin-role client, or — while `service.encryption` is off — any unpaired client on the network, which retains full API access for compatibility. Enforcement against app users starts when `service.encryption` requires clients to pair.

### Profile object

| Key | Type | Required | Description |
| :------------ | :------ | :------- | :------------------------------------------------------------------------------------------------------- |
| profileId | string | Yes | Unique identifier of the profile. |
| name | string | Yes | Display name, e.g. "Dad" or "Kid A". |
| switchId | string | No | Word phrase written to profile switch cards, e.g. `corn-arm-truck`. A bearer credential: presenting it switches to the profile with no PIN. Only returned to local connections and admin-role clients; omitted otherwise. |
| hasPin | boolean | Yes | True when the profile has a PIN set. The PIN itself is never returned. |
| limitsEnabled | boolean | No | Playtime limits enabled override. Omitted = inherit the global setting. |
| dailyLimit | string | No | Daily playtime limit override as a duration string (e.g. `2h30m`). Omitted = inherit; `0` = unlimited. |
| sessionLimit | string | No | Session playtime limit override as a duration string. Omitted = inherit; `0` = unlimited. |
| createdAt | number | Yes | Unix timestamp of profile creation. |
| lastUpdatedAt | number | Yes | Unix timestamp of last modification. |

### profiles

List all profiles.

#### Parameters

None.

#### Result

| Key | Type | Required | Description |
| :------- | :--------------------------- | :------- | :---------------- |
| profiles | [Profile](#profile-object)[] | Yes | List of profiles. |

### profiles.new

Create a new profile. Requires the admin role (or a local connection). The switch ID is generated automatically and returned in the result — write it to a card as `**profile:<switchId>`.

#### Parameters

| Key | Type | Required | Description |
| :------------ | :------ | :------- | :---------------------------------------------------------------- |
| name | string | Yes | Display name. |
| pin | string | No | Optional 4-8 digit PIN required to switch to this profile by `profileId`. |
| limitsEnabled | boolean | No | Playtime limits enabled override. |
| dailyLimit | string | No | Daily limit duration override. |
| sessionLimit | string | No | Session limit duration override. |

#### Result

The created [profile object](#profile-object).

### profiles.update

Update a profile. Requires the admin role (or a local connection). Omitted fields are unchanged. If the updated profile is currently active, its limit changes apply immediately (without resetting the running session).

#### Parameters

| Key | Type | Required | Description |
| :----------------- | :------ | :------- | :---------------------------------------------------------------------- |
| profileId | string | Yes | Profile to update. |
| name | string | No | New display name. |
| pin | string | No | Set or replace the PIN. |
| clearPin | boolean | No | Remove the PIN. |
| limitsEnabled | boolean | No | Playtime limits enabled override. |
| dailyLimit | string | No | Daily limit duration override. |
| sessionLimit | string | No | Session limit duration override. |
| clearLimits | boolean | No | Reset all limit overrides back to inheriting the global config. |
| regenerateSwitchId | boolean | No | Issue a new switch ID (lost-card replacement). Old cards stop working. |

#### Result

The updated [profile object](#profile-object).

### profiles.delete

Delete a profile. Requires the admin role (or a local connection). If it is the active profile, the device switches to the shared profile. Past play history keeps its attribution to the deleted profile.

#### Parameters

| Key | Type | Required | Description |
| :-------- | :----- | :------- | :----------------- |
| profileId | string | Yes | Profile to delete. |

#### Result

Null.

### profiles.active

Get the device's currently active profile.

#### Parameters

None.

#### Result

The active profile (a subset of the [profile object](#profile-object) without `switchId` and timestamps), or null when no profile is active.

### profiles.switch

Switch the device's active profile. Switching by `profileId` requires the profile's PIN when one is set. Switching by `switchId` never requires a PIN: the switch ID is a bearer credential, and presenting it is equivalent to scanning the profile's card. Calling with neither `profileId` nor `switchId` switches to the shared profile (deactivates), which never requires a PIN. Providing both is an error.

If a game is running when the profile changes, its playtime keeps counting against the profile that launched it: switching to another profile starts a fresh limit session for the new person, while deactivating leaves the launch profile's limits in force until the media stops.

#### Parameters

| Key | Type | Required | Description |
| :-------- | :----- | :------- | :------------------------------------------------------ |
| profileId | string | No | Profile to activate, by ID. Requires `pin` when the profile has one. |
| switchId | string | No | Profile to activate, by switch ID (bearer credential; no PIN needed). |
| pin | string | No | The profile's PIN, for `profileId` switching. |

#### Result

The new active profile, or null when deactivated (shared profile).

### profiles.verify

Verify a profile credential **without switching**: either a profile ID plus its PIN, or a switch ID (a bearer credential — resolving it is the verification, same as scanning the card). Success returns the profile's identity and changes nothing on the device: no session, no active-profile change, no server-side grant of any kind. Clients use this to gate their own ad-hoc UI items behind a credential — e.g. a kiosk frontend requiring a parent's PIN before opening its settings screen. The security of whatever the client unlocks is entirely the client's responsibility.

PIN attempts share the same per-profile rate limiter as `profiles.switch`, so this method cannot be used to brute-force a PIN any faster than switching attempts could.

#### Parameters

| Key | Type | Required | Description |
| :-------- | :----- | :------- | :-------------------------------------------------------- |
| profileId | string | No | Profile to verify against. Requires `pin` when the profile has one. |
| switchId | string | No | Verify by switch ID (bearer credential; no PIN needed). |
| pin | string | No | The profile's PIN, for `profileId` verification. |

Exactly one of `profileId` or `switchId` is required.

#### Result

| Key | Type | Required | Description |
| :-------- | :------ | :------- | :---------------------------------------- |
| profileId | string | Yes | ID of the verified profile. |
| name | string | Yes | Display name of the verified profile. |
| hasPin | boolean | Yes | Whether the profile has a PIN set. |

Verification failure (wrong PIN, unknown profile or switch ID, rate limited) returns an error, using the same errors as `profiles.switch`.

## Mappings

Mappings are used to modify the contents of tokens before they're launched, based on different types of matching parameters. Stored mappings are queried before every launch and applied to the token if there's a match. This allows, for example, adding ZapScript to a read-only NFC tag based on its UID.
Expand Down
32 changes: 32 additions & 0 deletions docs/api/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -408,3 +408,35 @@ Sent when a new inbox message is added to the server.
}
}
```

## Profiles

### profiles.active

Sent when the device's active profile changes, including deactivation.

#### Parameters

| Key | Type | Required | Description |
| :------ | :----- | :------- | :------------------------------------------------------------------ |
| profile | object \| null | Yes | The new active profile, or null when the device deactivated. |

Comment thread
coderabbitai[bot] marked this conversation as resolved.
The profile object contains `profileId`, `name`, `hasPin` and any playtime limit overrides (`limitsEnabled`, `dailyLimit`, `sessionLimit`).

#### Example

```json
{
"jsonrpc": "2.0",
"method": "profiles.active",
"params": {
"profile": {
"profileId": "1ad28b9a-7aef-11ef-9817-020304050607",
"name": "Kid A",
"hasPin": true,
"limitsEnabled": true,
"dailyLimit": "2h"
}
}
}
```
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ require (
github.com/Microsoft/go-winio v0.6.2
github.com/ZaparooProject/go-gameid v0.2.0
github.com/ZaparooProject/go-pn532 v0.22.1
github.com/ZaparooProject/go-zapscript v0.15.0
github.com/ZaparooProject/go-zapscript v0.16.0
github.com/adrg/xdg v0.5.3
github.com/andygrunwald/vdf v1.1.0
github.com/bendahl/uinput v1.7.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ github.com/ZaparooProject/go-gameid v0.2.0 h1:nYDxozhwsJXacdh/lwHOJofz4SlA609WKT
github.com/ZaparooProject/go-gameid v0.2.0/go.mod h1:gPQg1jQ4jgkguOJeKUiIqZeucz0GsSR67UQ/JOgYUDI=
github.com/ZaparooProject/go-pn532 v0.22.1 h1:Dtuc+sXYZtuNZP+8/DQv68V1MOHUxRAOMVYsqvVFAfQ=
github.com/ZaparooProject/go-pn532 v0.22.1/go.mod h1:NwYx5IE0zAU70ZikNpoPiOF5MUlDn3fD8xImpZixW1k=
github.com/ZaparooProject/go-zapscript v0.15.0 h1:H8YVI2z4p4v6L9GRfwtsMb1TvNQ7M+ne+fUg+3tTnjs=
github.com/ZaparooProject/go-zapscript v0.15.0/go.mod h1:Z3rFyQq/GA+ESpYUtCOA/2Xyftbygv4MfDCajOVDmag=
github.com/ZaparooProject/go-zapscript v0.16.0 h1:2m4NwU+l5xedOEZqubMBLO9lK6tfDzPienYB4tPE4aU=
github.com/ZaparooProject/go-zapscript v0.16.0/go.mod h1:Z3rFyQq/GA+ESpYUtCOA/2Xyftbygv4MfDCajOVDmag=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
Expand Down
2 changes: 1 addition & 1 deletion pkg/api/integration_encryption_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ func pairAndDeriveKey(
) (storedClient *database.Client, pairingKey []byte) {
t.Helper()

pin, _, err := mgr.StartPairing()
pin, _, err := mgr.StartPairing("member")
require.NoError(t, err)

clientPake, err := pake.InitCurve([]byte(pin), 0, pakeCurve)
Expand Down
1 change: 1 addition & 0 deletions pkg/api/methods/clients.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ func HandleClients(env requests.RequestEnv) (any, error) {
resp.Clients[i] = models.PairedClient{
ClientID: c.ClientID,
ClientName: c.ClientName,
Role: c.Role,
CreatedAt: c.CreatedAt,
LastSeenAt: c.LastSeenAt,
}
Expand Down
26 changes: 23 additions & 3 deletions pkg/api/methods/pairing.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,22 @@ import (

"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models/requests"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/permissions"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/validation"
"github.com/rs/zerolog/log"
)

// PairingController is the subset of PairingManager needed by the RPC handlers.
type PairingController interface {
StartPairing() (pin string, expiresAt time.Time, err error)
StartPairing(role string) (pin string, expiresAt time.Time, err error)
CancelPairing()
}

// HandleClientsPairStart returns a handler that initiates a new pairing flow.
// Localhost-only — the user must have physical access to the device.
// Localhost-only — the user must have physical access to the device. The
// optional role param decides the permission role the paired client will
// receive (default member); this is the approval step, so the person
// physically at the device makes the choice.
func HandleClientsPairStart(mgr PairingController) func(requests.RequestEnv) (any, error) {
return func(env requests.RequestEnv) (any, error) {
if !env.IsLocal {
Expand All @@ -43,7 +48,22 @@ func HandleClientsPairStart(mgr PairingController) func(requests.RequestEnv) (an

log.Info().Msg("received clients.pair.start request")

pin, expiresAt, err := mgr.StartPairing()
role := string(permissions.RoleMember)
if len(env.Params) > 0 {
var params models.ClientsPairStartParams
if err := validation.ValidateAndUnmarshal(env.Params, &params); err != nil {
log.Warn().Err(err).Msg("invalid params")
return nil, models.ClientErrf("invalid params: %w", err)
}
if params.Role != "" {
if !permissions.ValidRole(params.Role) {
return nil, models.ClientErrf("invalid role: %s", params.Role)
}
role = params.Role
}
}

pin, expiresAt, err := mgr.StartPairing(role)
if err != nil {
return nil, models.ClientErrf("failed to start pairing: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/api/methods/pairing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ type mockPairingController struct {
cancelled bool
}

func (m *mockPairingController) StartPairing() (string, time.Time, error) {
func (m *mockPairingController) StartPairing(_ string) (string, time.Time, error) {
return m.pin, m.expiresAt, m.err
}

Expand Down
Loading
Loading