From 0ac3f12cb726f5966b78bf43176c1a0bf1096046 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sun, 30 Aug 2026 09:45:39 +0800 Subject: [PATCH 1/2] feat(playtime): allow authorized session extensions Closes #1247. Adds a way for an administrator to give extra time to the playtime session currently being limited, without stopping what is playing and without permanently editing a limit. Until now the only options were blunt: disable limits entirely, which also wipes cooldown and cumulative time, or raise the profile's session limit for good. Two entry points reach one grant path on LimitsManager: **playtime.extend:15m?profile= **playtime.extend:today?profile= and a playtime.extend JSON-RPC method for the app. The card is the primary interface; the switch ID is an admin profile's bearer credential, the same value the profile command takes, and names who permits the grant rather than who receives it. The recipient is always whoever is being limited at the time and is never selectable, so a grant cannot be aimed at another person's session. The card is rejected from any source but a physical reader, and from any token carrying other commands, so an extension cannot be ordered ahead of a launch to slip past the pre-launch limit check. The API method is gated on a new playtime.extend capability held only by admin, absent from every legacy platform grant, and absent from the legacy method allowlist. A duration grant adds to the session allowance; today waives the session limit until the next local midnight. The daily limit is untouched by both: it stays the hard ceiling, and raising it remains a settings change. Single grants are bounded to 1m..24h and the accumulated total per session is capped at 24h, rejected rather than clamped so a caller is never told less was granted than it asked for. Grants apply through effectiveSessionLimit, so the periodic check, createRules and status all observe them from one place. CheckBeforeLaunch previously read the raw configured limit and so would still have blocked the relaunch after a limit stop, which is the case the card exists for; it now reads the effective limit too. Grants are pinned to the profile that owned the session, cleared when that session resets, and persisted in DeviceState so a restart inside the cooldown window does not silently revoke time a parent just gave. A grant re-arms warning thresholds and triggers an immediate re-check rather than waiting up to 30 seconds. Repeated grants are idempotent: the API takes a requestId, and a scanned card dedupes on its UID within a short window so reader bounce grants once while a deliberate second tap still works. Both commands that carry a switch ID are now treated as sensitive. Previously **profile: was written to history in the clear and returned raw by tokens and tokens.history to every client, including unauthorized ones. RedactScript strips credential values from logs, stored history, and both token APIs, and redacts on read as well as on write so rows stored by earlier versions stop being served. It fails closed: text it cannot parse, or whose credentials survive substitution, is replaced wholesale. Requires go-zapscript v0.18.0 for the shared command vocabulary. --- docs/ARCHITECTURE.md | 1 + docs/api/index.md | 2 + docs/api/methods.md | 92 +++ docs/api/notifications.md | 35 + go.mod | 2 +- go.sum | 2 + pkg/api/methods/clients_test.go | 1 + pkg/api/methods/history.go | 18 +- pkg/api/methods/playtime.go | 128 +++- pkg/api/methods/playtime_test.go | 302 ++++++++ pkg/api/methods/run.go | 21 +- pkg/api/models/models.go | 7 + pkg/api/models/params.go | 15 + pkg/api/models/responses.go | 42 +- pkg/api/notifications/notifications.go | 4 + pkg/api/permissions/permissions.go | 7 + pkg/api/permissions/permissions_test.go | 10 +- pkg/api/request_priority.go | 1 + pkg/api/server.go | 1 + pkg/database/database.go | 6 + pkg/platforms/platforms.go | 17 + pkg/service/context.go | 2 + pkg/service/playtime/extensions.go | 591 +++++++++++++++ pkg/service/playtime/extensions_test.go | 673 ++++++++++++++++++ pkg/service/playtime/limits.go | 172 +++-- pkg/service/queues.go | 119 +++- pkg/service/service.go | 4 + pkg/zapscript/commands.go | 6 +- pkg/zapscript/playtime.go | 110 +++ pkg/zapscript/playtime_test.go | 232 ++++++ pkg/zapscript/redact.go | 149 ++++ pkg/zapscript/redact_fuzz_test.go | 75 ++ pkg/zapscript/redact_test.go | 188 +++++ .../fuzz/FuzzRedactScript/3e9d7045f81ff1c9 | 2 + 34 files changed, 2974 insertions(+), 63 deletions(-) create mode 100644 pkg/service/playtime/extensions.go create mode 100644 pkg/service/playtime/extensions_test.go create mode 100644 pkg/zapscript/playtime.go create mode 100644 pkg/zapscript/playtime_test.go create mode 100644 pkg/zapscript/redact.go create mode 100644 pkg/zapscript/redact_fuzz_test.go create mode 100644 pkg/zapscript/redact_test.go create mode 100644 pkg/zapscript/testdata/fuzz/FuzzRedactScript/3e9d7045f81ff1c9 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d0d4912f1..c7d24d5dc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -61,6 +61,7 @@ Device profiles are named buckets of preferences and limits, with no passwords o - **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:`. 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. +- **Playtime extensions**: an administrator can grant extra time to the session currently being limited, without stopping what is playing or editing a limit. Two entry points reach one grant path on `LimitsManager`: the `playtime.extend` API method (gated on the `playtime.extend` capability) and a scanned card holding `**playtime.extend:?profile=`, where the switch ID is an admin profile's bearer credential and the card is rejected from any source but a physical reader. A grant either adds a bounded duration to the session's allowance or waives the session limit until the next local midnight; the daily limit is untouched by both. Grants flow through `effectiveSessionLimit` so every consumer — the periodic check, the pre-launch gate, and status — sees them, and are pinned to the profile that owned the session, persisted in `DeviceState` so a restart inside the cooldown window does not revoke them, and cleared when the session resets. Because both commands carry a switch ID, `pkg/zapscript.RedactScript` strips credentials from logs, stored history, and the token APIs, failing closed on anything it cannot parse. - **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). - **Data swapping**: on platforms implementing the optional `platforms.ProfileDataSwapper` capability, the active profile also owns its save files and save states. `pkg/service/profiles.DataSwapCoordinator` drives it: switches apply through a single worker (briefly waited on so combo-card launches see the new data), swaps while media runs are deferred until it stops and coalesce to the last target, and errors only ever notify (`profiles.data`) — the switch itself never fails on file operations. MiSTer implements it with bind mounts (`pkg/platforms/mister/profiledata.go`): zero on-disk mutation, pools under `zaparoo/profiles//`, main's storage root (`device.bin` SD/USB) resolved per apply, foreign mounts (NAS saves) layered on with the pool inside the share and never touched, ownership proven via a tmpfs ledger (`/run/zaparoo/mounts.json`), and a `/proc/self/mountinfo` watcher re-reconciling when the mount table changes. The `[profiles] swap_data` setting (default on) disables it, converging mounts back to shared. - **Roles and permissions**: profile roles and client roles are separate. Profiles represent people/kiosk identities; the first profile is explicitly created as `admin` with a mandatory PIN and later profiles default `member`. Paired clients represent trusted devices; the first paired client is explicitly confirmed as `admin` and later clients default `member`. Remote profile management requires an admin client. Sensitive local TUI actions use `profiles.verify` as a client-side nuisance gate before sending ordinary requests; there is no retained unlock session or server-side linkage between verification and action. The last admin profile/client cannot be removed or demoted. Existing databases with profiles but no admin enter local setup recovery, allowing one profile to be promoted with a PIN. While `service.encryption` is off, unpaired remote clients retain legacy admin capability; enabling it requires pairing and makes member restrictions enforceable. diff --git a/docs/api/index.md b/docs/api/index.md index c1778b231..843b53d54 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -312,6 +312,7 @@ Methods execute actions and return data from Core. See [API Methods](./methods) | settings.playtime.limits | Return playtime limit configuration. | All clients | | settings.playtime.limits.update | Update playtime limits. | `settings.write` | | playtime | Return playtime session status and usage. | All clients | +| playtime.extend | Grant extra time to the session currently being limited. | `playtime.extend` | | systems | List indexed or supported systems. | All clients | | launchers | List launchers known to running service. | All clients | | launchers.refresh | Refresh launcher cache. | All clients | @@ -372,5 +373,6 @@ Notifications let a server or client know an event has occurred. See the [API No | media.scraping | Progress updates emitted during media scraping (includes progress/status details). | | playtime.limit.reached | A playtime limit (session or daily) has been reached and enforced. | | playtime.limit.warning | A playtime warning notification sent at configured intervals before limit reached. | +| playtime.extended | Extra playtime was granted to the session currently being limited. | | inbox.added | A new inbox message was added to the server. | | update.state | Progress of an update being applied. | diff --git a/docs/api/methods.md b/docs/api/methods.md index eaef5a863..ab945d6c9 100644 --- a/docs/api/methods.md +++ b/docs/api/methods.md @@ -3606,9 +3606,13 @@ None. | cooldownRemaining | string | No | Time until session auto-resets. Only present during `"cooldown"` state. | | dailyUsageToday | string | No | Total playtime accumulated today. Available in all states when data is available. | | dailyRemaining | string | No | Time remaining before daily limit reached. Available in all states if daily limit is configured. | +| sessionExtension | string | No | Extra time granted to the current session on top of the configured session limit. Omitted when nothing was granted. | +| sessionExtendedUntil | string | No | RFC 3339 timestamp when a session-limit waiver lapses. While set, the session limit is not enforced and `sessionRemaining` is omitted; the daily limit still applies. | **Note:** All duration fields use Go's duration format (e.g., `"1h30m45s"`, `"45m"`, `"2h"`). +`sessionRemaining` already accounts for any granted extension, so a client showing time left needs no extra arithmetic. `sessionExtension` is reported separately so a client can show that time was granted rather than silently displaying a larger allowance. See [`playtime.extend`](#playtimeextend). + #### Examples ##### Request @@ -3677,6 +3681,94 @@ None. } ``` +### playtime.extend + +**Access:** `playtime.extend` capability (localhost, or an authenticated admin client). + +Grant extra time to the playtime session currently being limited, without stopping what is playing and without changing any configured limit. + +The recipient is never named by the caller: a grant always applies to the profile playtime is being enforced against at that moment, so it cannot be aimed at someone else's session. Grants are held against the current session only and are cleared when that session resets — when a different profile becomes active, when the cooldown window expires, or when limits are disabled. + +**The daily limit is never affected.** It remains the hard ceiling in both modes; raising it is a settings change, not a grant. + +**Modes:** + +- `duration` adds time to the current session's allowance. It requires a session to extend, so it is accepted during `active` and `cooldown` states but rejected during `reset`. Cooldown is the common case: the limit stopped the game and the player is about to relaunch. +- `today` waives the session limit for the recipient profile until the next local midnight. It is day-scoped rather than session-scoped, so it is accepted in any state, and it is rejected when the system clock is unreliable. + +A single duration grant must be between 1 minute and 24 hours, and the total accumulated across one session is capped at 24 hours. A grant that would exceed the cap is rejected rather than reduced, so a caller is never told less time was added than it asked for. + +The same grant can also be made by scanning a physical card holding `**playtime.extend`, authorized by an administrator profile's switch ID rather than by a paired client. + +#### Parameters + +| Key | Type | Required | Description | +| :-------- | :----- | :------- | :---------------------------------------------------------------------------------------------------------------- | +| mode | string | Yes | `"duration"` or `"today"`. | +| duration | string | No | Time to add, in Go duration format (e.g. `"15m"`, `"1h30m"`). Required for `"duration"` mode, ignored for `"today"`. | +| requestId | string | No | Idempotency key. Repeating a request ID reports the original grant instead of adding more time. | + +#### Result + +| Key | Type | Required | Description | +| :--------------- | :------ | :------- | :----------------------------------------------------------------------------------- | +| mode | string | Yes | The mode that was applied. | +| replayed | boolean | Yes | True when a repeated `requestId` matched an earlier grant and no time was added. | +| duration | string | No | Time this grant added. Omitted for `"today"`. | +| expires | string | No | RFC 3339 timestamp when a `"today"` waiver lapses. Omitted for `"duration"`. | +| sessionExtension | string | No | The session's accumulated extension after this grant. | +| profileId | string | No | Recipient profile. Omitted for the shared profile. | + +A successful grant emits [`playtime.extended`](./notifications.md#playtimeextended). A replayed request granted nothing, so it emits no notification. + +#### Examples + +##### Request + +```json +{ + "jsonrpc": "2.0", + "id": "a1b2c3d4-7a5e-11ef-9c7b-020304050607", + "method": "playtime.extend", + "params": { + "mode": "duration", + "duration": "15m", + "requestId": "5f2c9a10-1d44-4f8e-9f0b-6d1c2a3b4c5d" + } +} +``` + +##### Response + +```json +{ + "jsonrpc": "2.0", + "id": "a1b2c3d4-7a5e-11ef-9c7b-020304050607", + "result": { + "mode": "duration", + "duration": "15m0s", + "sessionExtension": "15m0s", + "profileId": "0194e2a1-6c3f-7b21-9d4e-8a5b6c7d8e9f", + "replayed": false + } +} +``` + +##### Response (waiving the session limit for the rest of the day) + +```json +{ + "jsonrpc": "2.0", + "id": "a1b2c3d4-7a5e-11ef-9c7b-020304050607", + "result": { + "mode": "today", + "expires": "2025-01-23T00:00:00Z", + "profileId": "0194e2a1-6c3f-7b21-9d4e-8a5b6c7d8e9f", + "replayed": false + } +} +``` + ### settings.playtime.limits **Access:** All clients. diff --git a/docs/api/notifications.md b/docs/api/notifications.md index db10d48fd..c81997b40 100644 --- a/docs/api/notifications.md +++ b/docs/api/notifications.md @@ -444,6 +444,41 @@ The warning applies to whichever limit will be reached first (session or daily). } ``` +### playtime.extended + +Sent when extra playtime was granted to the session currently being limited, either through the [`playtime.extend`](./methods.md#playtimeextend) method or by scanning a physical extension card. Warning thresholds are re-armed by a grant, so they fire again against the newly granted time. + +Profiles are identified by ID only. The switch ID authorizing a card grant is a bearer credential and is never published. + +A repeated request that granted no additional time emits no notification. + +#### Parameters + +| Key | Type | Required | Description | +| :--------------- | :----- | :------- | :------------------------------------------------------------------------------- | +| mode | string | Yes | `"duration"` when time was added, `"today"` when the session limit was waived. | +| duration | string | No | Time this grant added (Go duration format). Omitted for `"today"`. | +| expires | string | No | RFC 3339 timestamp when a `"today"` waiver lapses. Omitted for `"duration"`. | +| sessionExtension | string | No | The session's accumulated extension after this grant. | +| profileId | string | No | Recipient profile. Omitted for the shared profile. | +| grantedBy | string | No | Profile that authorized the grant. Omitted when authorized by an admin client rather than a profile credential. | + +#### Example + +```json +{ + "jsonrpc": "2.0", + "method": "playtime.extended", + "params": { + "mode": "duration", + "duration": "15m0s", + "sessionExtension": "15m0s", + "profileId": "0194e2a1-6c3f-7b21-9d4e-8a5b6c7d8e9f", + "grantedBy": "0194e2a1-9f8e-7c65-b432-1a0f9e8d7c6b" + } +} +``` + ## Inbox ### inbox.added diff --git a/go.mod b/go.mod index d01923fdc..72f6d18c3 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/Microsoft/go-winio v0.6.2 github.com/ZaparooProject/go-gameid v0.2.0 github.com/ZaparooProject/go-pn532 v0.23.0 - github.com/ZaparooProject/go-zapscript v0.16.0 + github.com/ZaparooProject/go-zapscript v0.18.0 github.com/adrg/xdg v0.5.3 github.com/andygrunwald/vdf v1.1.0 github.com/bendahl/uinput v1.7.0 diff --git a/go.sum b/go.sum index 5e21e85ce..06dad825c 100644 --- a/go.sum +++ b/go.sum @@ -41,6 +41,8 @@ github.com/ZaparooProject/go-pn532 v0.23.0 h1:iJ5taBHFXQxYhS8zncMonWEW6pOIyklLpn github.com/ZaparooProject/go-pn532 v0.23.0/go.mod h1:ao2ojvudUN8ZqcBAkjodRhCjm2jDf/efS0mdSC6eDHg= 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/ZaparooProject/go-zapscript v0.18.0 h1:zDZI8Ll+XF5y7h50Sr7Ioq10+CeEmoqJsa37jXyYOXc= +github.com/ZaparooProject/go-zapscript v0.18.0/go.mod h1:ofo4vj6lFW0eUuSyPLt0R0JjJxExhn9eitSmFBWQVoU= 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.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= diff --git a/pkg/api/methods/clients_test.go b/pkg/api/methods/clients_test.go index 94eb973f2..290d93844 100644 --- a/pkg/api/methods/clients_test.go +++ b/pkg/api/methods/clients_test.go @@ -119,6 +119,7 @@ func TestHandleClientsCurrent(t *testing.T) { adminCapabilities := []string{ string(permissions.CapInput), + string(permissions.CapPlaytimeExtend), string(permissions.CapProfilesManage), string(permissions.CapScreenshot), string(permissions.CapSettingsWrite), diff --git a/pkg/api/methods/history.go b/pkg/api/methods/history.go index aa14f1beb..35bd01b41 100644 --- a/pkg/api/methods/history.go +++ b/pkg/api/methods/history.go @@ -24,6 +24,7 @@ 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/zapscript" "github.com/rs/zerolog/log" ) @@ -36,22 +37,24 @@ func HandleTokens(env requests.RequestEnv) (any, error) { //nolint:gocritic // s active := env.State.GetActiveCard() if !active.ScanTime.IsZero() { + text, data := zapscript.RedactToken(active.Text, active.Data) resp.Active = append(resp.Active, models.TokenResponse{ Type: active.Type, UID: active.UID, - Text: active.Text, - Data: active.Data, + Text: text, + Data: data, ScanTime: active.ScanTime, }) } last := env.State.GetLastScanned() if !last.ScanTime.IsZero() { + text, data := zapscript.RedactToken(last.Text, last.Data) resp.Last = &models.TokenResponse{ Type: last.Type, UID: last.UID, - Text: last.Text, - Data: last.Data, + Text: text, + Data: data, ScanTime: last.ScanTime, } } @@ -73,12 +76,15 @@ func HandleHistory(env requests.RequestEnv) (any, error) { //nolint:gocritic // } for i := range entries { + // Redact on the way out as well as on the way in: rows written by + // earlier versions still hold credentials in the clear. + text, data := zapscript.RedactToken(entries[i].TokenValue, entries[i].TokenData) resp.Entries[i] = models.HistoryResponseEntry{ Time: entries[i].Time, Type: entries[i].Type, UID: entries[i].TokenID, - Text: entries[i].TokenValue, - Data: entries[i].TokenData, + Text: text, + Data: data, Success: entries[i].Success, } } diff --git a/pkg/api/methods/playtime.go b/pkg/api/methods/playtime.go index a14fea8ec..ec27bb278 100644 --- a/pkg/api/methods/playtime.go +++ b/pkg/api/methods/playtime.go @@ -20,8 +20,15 @@ package methods import ( + "errors" + "fmt" + "time" + "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/notifications" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/permissions" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/validation" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playtime" "github.com/rs/zerolog/log" ) @@ -48,6 +55,10 @@ func HandlePlaytime(env requests.RequestEnv) (any, error) { return resp, nil } + // Report what is actually being enforced, which the active profile's + // override can differ from the global config setting. + resp.LimitsEnabled = env.LimitsManager.EffectiveLimitsEnabled() + // Update with actual status resp.State = status.State resp.SessionActive = status.SessionActive @@ -73,11 +84,25 @@ func HandlePlaytime(env requests.RequestEnv) (any, error) { cumulativeStr := status.SessionCumulativeTime.String() resp.SessionCumulativeTime = &cumulativeStr - // Session remaining (only if session limit is configured) + // Session remaining (only if session limit is configured). A day + // waiver zeroes the session limit, so this is omitted while one is + // active. if status.SessionRemaining > 0 { remainingStr := status.SessionRemaining.String() resp.SessionRemaining = &remainingStr } + + if status.SessionExtension > 0 { + extensionStr := status.SessionExtension.String() + resp.SessionExtension = &extensionStr + } + } + + // A session-limit waiver is scoped to a profile and a day rather than a + // session, so it is reported in every state. + if !status.SessionExtendedUntil.IsZero() { + untilStr := status.SessionExtendedUntil.Format(time.RFC3339) + resp.SessionExtendedUntil = &untilStr } // nil = not calculated (limits disabled or clock unreliable) @@ -94,3 +119,104 @@ func HandlePlaytime(env requests.RequestEnv) (any, error) { return resp, nil } + +// grantClientError maps a rejected grant onto a client-fault error. Storage +// failures are deliberately absent: those are the server's problem and must +// surface as a server error so the caller retries rather than giving up. +func grantClientError(err error) error { + switch { + case errors.Is(err, playtime.ErrGrantModeInvalid), + errors.Is(err, playtime.ErrGrantDurationRange), + errors.Is(err, playtime.ErrGrantCapExceeded), + errors.Is(err, playtime.ErrGrantNoSession), + errors.Is(err, playtime.ErrGrantLimitsDisabled), + errors.Is(err, playtime.ErrGrantClockUnreliable): + return models.ClientErrf("%w", err) + default: + return fmt.Errorf("failed to extend playtime: %w", err) + } +} + +// HandlePlaytimeExtend grants extra time to the session currently being +// limited, without stopping what is playing and without changing any +// configured limit. The daily limit is left alone in every mode: it is the +// hard ceiling, and raising it is a settings change, not a grant. +// +//nolint:gocritic // single-use parameter in API handler +func HandlePlaytimeExtend(env requests.RequestEnv) (any, error) { + var params models.ExtendPlaytimeParams + if err := validation.ValidateAndUnmarshal(env.Params, ¶ms); err != nil { + return nil, models.ClientErrf("invalid params: %w", err) + } + + if err := requireCapability(&env, permissions.CapPlaytimeExtend); err != nil { + return nil, err + } + + log.Info().Str("mode", params.Mode).Msg("received playtime extend request") + + if env.LimitsManager == nil { + return nil, errors.New("playtime limits are not available") + } + + req := &playtime.GrantRequest{ + Source: "api", + AuthorizerClientID: env.ClientID, + // An API request ID stays valid for as long as the grant it + // produced, so a retry after a dropped connection still matches. + IdempotencyKey: params.RequestID, + } + + switch params.Mode { + case models.PlaytimeExtendModeDuration: + if params.Duration == nil || *params.Duration == "" { + return nil, models.ClientErrf("duration is required for mode %q", params.Mode) + } + parsed, err := time.ParseDuration(*params.Duration) + if err != nil { + return nil, models.ClientErrf("invalid duration: %w", err) + } + req.Mode = playtime.GrantModeDuration + req.Duration = parsed + case models.PlaytimeExtendModeToday: + req.Mode = playtime.GrantModeToday + default: + return nil, models.ClientErrf("%w: %q", playtime.ErrGrantModeInvalid, params.Mode) + } + + result, err := env.LimitsManager.Grant(req) + if err != nil { + log.Warn().Err(err).Str("mode", params.Mode).Msg("playtime: extension refused") + return nil, grantClientError(err) + } + + resp := models.ExtendPlaytimeResponse{ + Mode: string(result.Mode), + ProfileID: result.RecipientProfileID, + Replayed: result.Replayed, + } + if result.Duration > 0 { + resp.Duration = result.Duration.String() + } + if result.SessionExtension > 0 { + resp.SessionExtension = result.SessionExtension.String() + } + if !result.ExpiresAt.IsZero() { + resp.Expires = result.ExpiresAt.Format(time.RFC3339) + } + + // A replay granted nothing new, so it must not look like a fresh grant + // to notification subscribers. + if !result.Replayed && env.State != nil { + notifications.PlaytimeExtended(env.State.Notifications, &models.PlaytimeExtendedParams{ + Mode: resp.Mode, + Duration: resp.Duration, + Expires: resp.Expires, + SessionExtension: resp.SessionExtension, + ProfileID: resp.ProfileID, + GrantedBy: result.AuthorizerProfileID, + }) + } + + return resp, nil +} diff --git a/pkg/api/methods/playtime_test.go b/pkg/api/methods/playtime_test.go index 1f943a2bb..780e7f809 100644 --- a/pkg/api/methods/playtime_test.go +++ b/pkg/api/methods/playtime_test.go @@ -21,6 +21,7 @@ package methods import ( "context" + "encoding/json" "testing" "time" @@ -28,11 +29,14 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models/requests" "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playtime" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" testhelpers "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers" "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks" "github.com/jonboulle/clockwork" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -313,3 +317,301 @@ func TestHandlePlaytime_UnreliableClockNilDailyFields(t *testing.T) { mockDB.AssertExpectations(t) } + +// newExtendEnv builds a request environment with an enabled session limit and +// a manager sitting in cooldown, which is the state a card or app most often +// grants into: the limit stopped the game and the player wants to relaunch. +func newExtendEnv( + t *testing.T, role string, isLocal bool, +) (env requests.RequestEnv, notifications <-chan models.Notification) { + t.Helper() + + mockDB := testhelpers.NewMockUserDBI() + mockDB.On("SetDeviceState", mock.Anything, mock.Anything).Return(nil).Maybe() + mockDB.On("GetDeviceState", mock.Anything).Return("", false, nil).Maybe() + mockDB.On("DeleteDeviceState", mock.Anything).Return(nil).Maybe() + mockDB.On("SumMediaPlayTimeForDay", mock.Anything).Return(int64(0), nil).Maybe() + mockDB.On("SumMediaPlayTimeForDayByProfile", mock.Anything, mock.Anything). + Return(int64(0), nil).Maybe() + + enabled := true + cfg := newTestConfig(t, &config.Values{ + Playtime: config.Playtime{ + Limits: config.PlaytimeLimits{Enabled: &enabled, Session: "1h"}, + }, + }) + + currentTime := time.Date(2026, 6, 12, 14, 0, 0, 0, time.UTC) + tm := playtime.NewLimitsManager( + &database.Database{UserDB: mockDB}, nil, cfg, + clockwork.NewFakeClockAt(currentTime), newNoOpMockPlayer(), + ) + t.Cleanup(tm.Stop) + // Walk the real transitions into cooldown: a game ran and stopped, so the + // session is still alive and a grant has something to attach to. + tm.OnMediaStarted() + tm.OnMediaStopped() + + mockPlatform := mocks.NewMockPlatform() + mockPlatform.On("ID").Return("test-platform").Maybe() + mockPlatform.On("Settings").Return(platforms.Settings{ + DataDir: t.TempDir(), ConfigDir: t.TempDir(), + }).Maybe() + st, notificationCh := state.NewState(mockPlatform, "test-boot") + t.Cleanup(st.StopService) + + env = requests.RequestEnv{ + Context: context.Background(), + Config: cfg, + Database: &database.Database{UserDB: mockDB}, + LimitsManager: tm, + State: st, + ClientRole: role, + IsLocal: isLocal, + PlatformID: "test-platform", + } + return env, notificationCh +} + +func extendParams(t *testing.T, params models.ExtendPlaytimeParams) json.RawMessage { + t.Helper() + raw, err := json.Marshal(params) + require.NoError(t, err) + return raw +} + +func TestHandlePlaytimeExtend_RequiresCapability(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + role string + isLocal bool + wantAllow bool + }{ + {name: "paired admin", role: "admin", wantAllow: true}, + {name: "localhost", role: "", isLocal: true, wantAllow: true}, + {name: "paired member", role: "member", wantAllow: false}, + // A legacy client has no identity at all, and the capability is + // deliberately absent from every legacy platform grant. + {name: "legacy", role: "", wantAllow: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + env, _ := newExtendEnv(t, tt.role, tt.isLocal) + env.Params = extendParams(t, models.ExtendPlaytimeParams{ + Mode: models.PlaytimeExtendModeDuration, + Duration: ptrTo("15m"), + }) + + _, err := HandlePlaytimeExtend(env) + + if tt.wantAllow { + require.NoError(t, err) + return + } + require.ErrorIs(t, err, ErrForbidden) + }) + } +} + +func TestHandlePlaytimeExtend_GrantsDuration(t *testing.T) { + t.Parallel() + + env, notificationCh := newExtendEnv(t, "admin", false) + env.Params = extendParams(t, models.ExtendPlaytimeParams{ + Mode: models.PlaytimeExtendModeDuration, + Duration: ptrTo("15m"), + }) + + result, err := HandlePlaytimeExtend(env) + require.NoError(t, err) + + resp, ok := result.(models.ExtendPlaytimeResponse) + require.True(t, ok) + assert.Equal(t, models.PlaytimeExtendModeDuration, resp.Mode) + assert.Equal(t, "15m0s", resp.Duration) + assert.Equal(t, "15m0s", resp.SessionExtension) + assert.Empty(t, resp.Expires) + assert.False(t, resp.Replayed) + + select { + case n := <-notificationCh: + assert.Equal(t, models.NotificationPlaytimeExtended, n.Method) + default: + t.Fatal("expected a playtime.extended notification") + } +} + +func TestHandlePlaytimeExtend_GrantsToday(t *testing.T) { + t.Parallel() + + env, _ := newExtendEnv(t, "admin", false) + env.Params = extendParams(t, models.ExtendPlaytimeParams{ + Mode: models.PlaytimeExtendModeToday, + }) + + result, err := HandlePlaytimeExtend(env) + require.NoError(t, err) + + resp, ok := result.(models.ExtendPlaytimeResponse) + require.True(t, ok) + assert.Equal(t, models.PlaytimeExtendModeToday, resp.Mode) + assert.Equal(t, "2026-06-13T00:00:00Z", resp.Expires) + assert.Empty(t, resp.Duration, "a day waiver adds no fixed amount") +} + +func TestHandlePlaytimeExtend_RequestIDIsIdempotent(t *testing.T) { + t.Parallel() + + env, notificationCh := newExtendEnv(t, "admin", false) + env.Params = extendParams(t, models.ExtendPlaytimeParams{ + Mode: models.PlaytimeExtendModeDuration, + Duration: ptrTo("15m"), + RequestID: "retry-1", + }) + + first, err := HandlePlaytimeExtend(env) + require.NoError(t, err) + <-notificationCh + + second, err := HandlePlaytimeExtend(env) + require.NoError(t, err) + + firstResp, ok := first.(models.ExtendPlaytimeResponse) + require.True(t, ok) + secondResp, ok := second.(models.ExtendPlaytimeResponse) + require.True(t, ok) + + assert.True(t, secondResp.Replayed) + assert.Equal(t, firstResp.SessionExtension, secondResp.SessionExtension, + "a retry must not add more time") + + // A replay granted nothing, so subscribers must not see a second event. + select { + case n := <-notificationCh: + t.Fatalf("unexpected notification for a replayed grant: %s", n.Method) + default: + } +} + +func TestHandlePlaytimeExtend_RejectsBadParams(t *testing.T) { + t.Parallel() + + tests := []struct { + duration *string + name string + mode string + }{ + {name: "missing mode", mode: ""}, + {name: "unknown mode", mode: "forever"}, + {name: "duration mode without duration", mode: models.PlaytimeExtendModeDuration}, + {name: "unparseable duration", mode: models.PlaytimeExtendModeDuration, duration: ptrTo("soon")}, + {name: "duration below minimum", mode: models.PlaytimeExtendModeDuration, duration: ptrTo("10s")}, + {name: "duration above maximum", mode: models.PlaytimeExtendModeDuration, duration: ptrTo("48h")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + env, _ := newExtendEnv(t, "admin", false) + env.Params = extendParams(t, models.ExtendPlaytimeParams{ + Mode: tt.mode, Duration: tt.duration, + }) + + _, err := HandlePlaytimeExtend(env) + require.Error(t, err) + + // Caller fault, not server fault: these must not read as an + // internal error to the client. + var clientErr *models.ClientError + assert.ErrorAs(t, err, &clientErr, "expected a client error for %s", tt.name) + }) + } +} + +func TestHandlePlaytimeExtend_RejectsWhenNoSession(t *testing.T) { + t.Parallel() + + env, _ := newExtendEnv(t, "admin", false) + // Back to a reset session: nothing is being limited, so there is nothing + // to extend. + env.LimitsManager.ResetSession() + env.Params = extendParams(t, models.ExtendPlaytimeParams{ + Mode: models.PlaytimeExtendModeDuration, + Duration: ptrTo("15m"), + }) + + _, err := HandlePlaytimeExtend(env) + require.ErrorIs(t, err, playtime.ErrGrantNoSession) + + var clientErr *models.ClientError + assert.ErrorAs(t, err, &clientErr) +} + +func TestHandlePlaytimeExtend_NoLimitsManager(t *testing.T) { + t.Parallel() + + env, _ := newExtendEnv(t, "admin", false) + env.LimitsManager = nil + env.Params = extendParams(t, models.ExtendPlaytimeParams{ + Mode: models.PlaytimeExtendModeDuration, + Duration: ptrTo("15m"), + }) + + _, err := HandlePlaytimeExtend(env) + require.Error(t, err) +} + +func TestHandlePlaytime_ReportsExtensionFields(t *testing.T) { + t.Parallel() + + env, _ := newExtendEnv(t, "admin", false) + env.Params = extendParams(t, models.ExtendPlaytimeParams{ + Mode: models.PlaytimeExtendModeDuration, + Duration: ptrTo("15m"), + }) + _, err := HandlePlaytimeExtend(env) + require.NoError(t, err) + + result, err := HandlePlaytime(env) + require.NoError(t, err) + + resp, ok := result.(models.PlaytimeStatusResponse) + require.True(t, ok) + require.NotNil(t, resp.SessionExtension) + assert.Equal(t, "15m0s", *resp.SessionExtension) + assert.Nil(t, resp.SessionExtendedUntil) + require.NotNil(t, resp.SessionRemaining) + remaining, err := time.ParseDuration(*resp.SessionRemaining) + require.NoError(t, err) + assert.Greater(t, remaining, time.Hour, + "remaining time should exceed the 1h limit once 15m is granted") +} + +func TestHandlePlaytime_ReportsDayWaiver(t *testing.T) { + t.Parallel() + + env, _ := newExtendEnv(t, "admin", false) + env.Params = extendParams(t, models.ExtendPlaytimeParams{ + Mode: models.PlaytimeExtendModeToday, + }) + _, err := HandlePlaytimeExtend(env) + require.NoError(t, err) + + result, err := HandlePlaytime(env) + require.NoError(t, err) + + resp, ok := result.(models.PlaytimeStatusResponse) + require.True(t, ok) + require.NotNil(t, resp.SessionExtendedUntil) + assert.Equal(t, "2026-06-13T00:00:00Z", *resp.SessionExtendedUntil) + assert.Nil(t, resp.SessionRemaining, + "a waived session limit has no finite remaining time") +} + +func ptrTo[T any](v T) *T { return &v } diff --git a/pkg/api/methods/run.go b/pkg/api/methods/run.go index 65e119a76..37e78e26f 100644 --- a/pkg/api/methods/run.go +++ b/pkg/api/methods/run.go @@ -38,6 +38,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/zapscript" "github.com/go-chi/chi/v5" "github.com/rs/zerolog/log" "golang.org/x/text/unicode/norm" @@ -48,6 +49,22 @@ var ErrNotAllowed = errors.New("not allowed") type NoContent struct{} +// runParamsForLog returns a copy of run params with any bearer credential in +// the ZapScript removed, so a profile card run through the API cannot leave +// its switch ID in the logs. +func runParamsForLog(params *models.RunParams) models.RunParams { + safe := *params + if safe.Text != nil { + redacted := zapscript.RedactScript(*safe.Text) + safe.Text = &redacted + } + if safe.Data != nil && safe.Text != nil && zapscript.HasSensitiveScript(*params.Text) { + empty := "" + safe.Data = &empty + } + return safe +} + func HandleRun(env requests.RequestEnv) (any, error) { //nolint:gocritic // single-use parameter in API handler log.Info().Msg("received run request") @@ -66,7 +83,7 @@ func HandleRun(env requests.RequestEnv) (any, error) { //nolint:gocritic // sing return nil, models.ClientErrf("invalid params: %w", err) } - log.Debug().Msgf("unmarshalled run params: %+v", params) + log.Debug().Msgf("unmarshalled run params: %+v", runParamsForLog(¶ms)) if params.Type != nil { t.Type = *params.Type @@ -97,7 +114,7 @@ func HandleRun(env requests.RequestEnv) (any, error) { //nolint:gocritic // sing t.Unsafe = true } } else { - log.Debug().Msgf("could not unmarshal run params, trying string: %s", env.Params) + log.Debug().Msg("could not unmarshal run params, trying string") var text string err := json.Unmarshal(env.Params, &text) diff --git a/pkg/api/models/models.go b/pkg/api/models/models.go index 7c733f25f..9ae089954 100644 --- a/pkg/api/models/models.go +++ b/pkg/api/models/models.go @@ -38,6 +38,7 @@ const ( NotificationTokensStagedReady = "tokens.staged.ready" //nolint:gosec // not a credential NotificationPlaytimeLimitReached = "playtime.limit.reached" NotificationPlaytimeLimitWarning = "playtime.limit.warning" + NotificationPlaytimeExtended = "playtime.extended" NotificationInboxAdded = "inbox.added" NotificationClientsPaired = "clients.paired" NotificationProfilesActive = "profiles.active" @@ -59,6 +60,11 @@ const ( const ( PlaytimeLimitReasonSession = "session" PlaytimeLimitReasonDaily = "daily" + + // PlaytimeExtendModeDuration adds time to the current session. + PlaytimeExtendModeDuration = "duration" + // PlaytimeExtendModeToday waives the session limit until midnight. + PlaytimeExtendModeToday = "today" ) type UIEventKind string @@ -141,6 +147,7 @@ const ( MethodPlaytimeLimits = "settings.playtime.limits" MethodPlaytimeLimitsUpdate = "settings.playtime.limits.update" MethodPlaytime = "playtime" + MethodPlaytimeExtend = "playtime.extend" MethodClients = "clients" MethodClientsCurrent = "clients.current" MethodClientsDelete = "clients.delete" diff --git a/pkg/api/models/params.go b/pkg/api/models/params.go index 4db0d2a8f..901cd3f7e 100644 --- a/pkg/api/models/params.go +++ b/pkg/api/models/params.go @@ -214,6 +214,21 @@ type UpdatePlaytimeLimitsParams struct { Retention *int `json:"retention" validate:"omitempty,gte=0"` } +// ExtendPlaytimeParams asks for extra time on the session currently being +// limited. The recipient is never named by the caller: a grant always +// applies to the profile governing playtime right now, so it cannot be +// aimed at somebody else's session. +type ExtendPlaytimeParams struct { + // Duration is the time to add, in Go duration format. Required for + // mode "duration" and ignored for "today". + Duration *string `json:"duration" validate:"omitempty,duration"` + // RequestID makes a grant idempotent across retries. Repeating a + // request ID reports the original grant instead of adding more time. + RequestID string `json:"requestId" validate:"omitempty,max=128"` + // Mode is "duration" or "today". + Mode string `json:"mode" validate:"required,oneof=duration today"` +} + type NewClientParams struct { Name string `json:"name" validate:"required,min=1,max=255"` } diff --git a/pkg/api/models/responses.go b/pkg/api/models/responses.go index 5da58ae78..293d18c26 100644 --- a/pkg/api/models/responses.go +++ b/pkg/api/models/responses.go @@ -185,9 +185,16 @@ type PlaytimeStatusResponse struct { CooldownRemaining *string `json:"cooldownRemaining,omitempty"` DailyUsageToday *string `json:"dailyUsageToday,omitempty"` DailyRemaining *string `json:"dailyRemaining,omitempty"` - State string `json:"state"` - SessionActive bool `json:"sessionActive"` - LimitsEnabled bool `json:"limitsEnabled"` + // SessionExtension is the time granted on top of the configured session + // limit for the current session. Omitted when nothing was granted. + SessionExtension *string `json:"sessionExtension,omitempty"` + // SessionExtendedUntil is when a session-limit waiver lapses, RFC3339. + // While it is set the session limit is not enforced and + // sessionRemaining is omitted; the daily limit still applies. + SessionExtendedUntil *string `json:"sessionExtendedUntil,omitempty"` + State string `json:"state"` + SessionActive bool `json:"sessionActive"` + LimitsEnabled bool `json:"limitsEnabled"` } type System struct { @@ -254,6 +261,35 @@ type PlaytimeLimitWarningParams struct { Remaining string `json:"remaining"` } +// PlaytimeExtendedParams is the payload of the playtime.extended +// notification. Profiles are identified by ID only: the switch ID that +// authorized a card grant is a bearer credential and is never published. +type PlaytimeExtendedParams struct { + Mode string `json:"mode"` + // Duration is what this grant added. Omitted for a day waiver. + Duration string `json:"duration,omitempty"` + // Expires is when a day waiver lapses, RFC3339. Omitted otherwise. + Expires string `json:"expires,omitempty"` + // SessionExtension is the session's accumulated duration grant. + SessionExtension string `json:"sessionExtension,omitempty"` + // ProfileID is the recipient. Empty is the shared profile. + ProfileID string `json:"profileId,omitempty"` + // GrantedBy is the profile that authorized the grant. + GrantedBy string `json:"grantedBy,omitempty"` +} + +// ExtendPlaytimeResponse reports what a playtime.extend request granted. +type ExtendPlaytimeResponse struct { + Mode string `json:"mode"` + Duration string `json:"duration,omitempty"` + Expires string `json:"expires,omitempty"` + SessionExtension string `json:"sessionExtension,omitempty"` + ProfileID string `json:"profileId,omitempty"` + // Replayed is true when a repeated requestId matched an earlier grant + // and no additional time was added. + Replayed bool `json:"replayed"` +} + type IndexingStatusResponse struct { TotalSteps *int `json:"totalSteps,omitempty"` CurrentStep *int `json:"currentStep,omitempty"` diff --git a/pkg/api/notifications/notifications.go b/pkg/api/notifications/notifications.go index 031d26872..6349525cb 100644 --- a/pkg/api/notifications/notifications.go +++ b/pkg/api/notifications/notifications.go @@ -128,6 +128,10 @@ func PlaytimeLimitWarning(ns chan<- models.Notification, payload models.Playtime sendNotification(ns, models.NotificationPlaytimeLimitWarning, payload) } +func PlaytimeExtended(ns chan<- models.Notification, payload *models.PlaytimeExtendedParams) { + sendNotification(ns, models.NotificationPlaytimeExtended, payload) +} + func InboxAdded(ns chan<- models.Notification, payload *models.InboxMessage) { sendNotification(ns, models.NotificationInboxAdded, payload) } diff --git a/pkg/api/permissions/permissions.go b/pkg/api/permissions/permissions.go index 6997a233f..c293b70c7 100644 --- a/pkg/api/permissions/permissions.go +++ b/pkg/api/permissions/permissions.go @@ -90,6 +90,12 @@ const ( CapScreenshot Capability = "screenshot" // CapInput covers injecting keyboard and gamepad input. CapInput Capability = "input" + // CapPlaytimeExtend covers granting extra playtime to the session + // currently being limited. It is separate from CapSettingsWrite: a + // grant weakens one session's limit without changing configuration, + // and a client trusted to hand out extra time is not necessarily + // trusted to rewrite the device's settings. + CapPlaytimeExtend Capability = "playtime.extend" // CapUpdateApply covers replacing the running binary and restarting // the service. It is the one capability that is not about weakening // someone's limits: an update decides what code the device runs from @@ -107,6 +113,7 @@ var roleCapabilities = map[Role]map[Capability]bool{ CapSettingsWrite: true, CapScreenshot: true, CapInput: true, + CapPlaytimeExtend: true, CapUpdateApply: true, }, RoleMember: { diff --git a/pkg/api/permissions/permissions_test.go b/pkg/api/permissions/permissions_test.go index b05f0cc27..8172a52bb 100644 --- a/pkg/api/permissions/permissions_test.go +++ b/pkg/api/permissions/permissions_test.go @@ -121,7 +121,10 @@ func TestGrant_Capabilities(t *testing.T) { { name: "paired admin is sorted", grant: Grant{Role: RoleAdmin}, - want: []Capability{CapInput, CapProfilesManage, CapScreenshot, CapSettingsWrite, CapUpdateApply}, + want: []Capability{ + CapInput, CapPlaytimeExtend, CapProfilesManage, + CapScreenshot, CapSettingsWrite, CapUpdateApply, + }, }, { name: "paired member has day-to-day capabilities", @@ -141,7 +144,10 @@ func TestGrant_Capabilities(t *testing.T) { { name: "local member gets local capabilities", grant: Grant{Role: RoleMember, IsLocal: true}, - want: []Capability{CapInput, CapProfilesManage, CapScreenshot, CapSettingsWrite, CapUpdateApply}, + want: []Capability{ + CapInput, CapPlaytimeExtend, CapProfilesManage, + CapScreenshot, CapSettingsWrite, CapUpdateApply, + }, }, { name: "unknown role degrades to member", diff --git a/pkg/api/request_priority.go b/pkg/api/request_priority.go index e10a36f55..f967d11f3 100644 --- a/pkg/api/request_priority.go +++ b/pkg/api/request_priority.go @@ -85,6 +85,7 @@ func classifyAPIMethod(method string) apiRequestPriority { models.MethodConfirm, models.MethodSettingsUpdate, models.MethodPlaytimeLimitsUpdate, + models.MethodPlaytimeExtend, models.MethodClientsDelete, models.MethodInboxDelete, models.MethodInboxClear, diff --git a/pkg/api/server.go b/pkg/api/server.go index 59c8c7bc5..361a8330f 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -385,6 +385,7 @@ func NewMethodMap() *MethodMap { models.MethodPlaytimeLimits: methods.HandlePlaytimeLimits, models.MethodPlaytimeLimitsUpdate: methods.HandlePlaytimeLimitsUpdate, models.MethodPlaytime: methods.HandlePlaytime, + models.MethodPlaytimeExtend: methods.HandlePlaytimeExtend, // systems models.MethodSystems: methods.HandleSystems, // launchers diff --git a/pkg/database/database.go b/pkg/database/database.go index 375150b7e..126c6939f 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -165,6 +165,12 @@ const DeviceStateKeyActiveProfile = "active_profile_id" // skip the table walk entirely. const DeviceStateKeyMediaHistoryIdentitySweep = "media_history_identity_sweep" +// DeviceStateKeyPlaytimeExtensions is the DeviceState key holding granted +// playtime extensions: the current session's duration grant and any +// unexpired per-profile day waivers, as versioned JSON. It stores resolved +// profile IDs only, never the switch IDs used to authorize a grant. +const DeviceStateKeyPlaytimeExtensions = "playtime_extensions" + // Client represents a paired API client. AuthToken and PairingKey are // hidden from JSON (API uses models.PairedClient instead). type Client struct { diff --git a/pkg/platforms/platforms.go b/pkg/platforms/platforms.go index 9d0aac955..9ef26f240 100644 --- a/pkg/platforms/platforms.go +++ b/pkg/platforms/platforms.go @@ -201,6 +201,19 @@ type ProfileSwitchRequest struct { Clear bool } +// PlaytimeExtensionRequest asks the script runner to grant extra time to +// the session currently being limited. AuthorizerSwitchID is the bearer +// credential from the card; the service layer resolves it and checks the +// profile's role, so the command layer never sees a verified identity. +type PlaytimeExtensionRequest struct { + // Mode is models.PlaytimeExtendModeDuration or ...ModeToday. + Mode string + // AuthorizerSwitchID is the switch ID of the authorizing profile. + AuthorizerSwitchID string + // Duration is the time to add, for duration mode only. + Duration time.Duration +} + // CmdResult returns a summary of what global side effects may or may not have // happened as a result of a single ZapScript command running. type CmdResult struct { @@ -211,6 +224,10 @@ type CmdResult struct { // Playlist). The scan path activates without a PIN check — possession // of the card is the authorization. ProfileSwitch *ProfileSwitchRequest + // PlaytimeExtension requests extra time for the current playtime + // session. Like ProfileSwitch this is intent only: the service layer + // verifies the authorizing credential and applies the grant. + PlaytimeExtension *PlaytimeExtensionRequest // Strategy indicates which matching strategy was used for title-based launches. // Empty for non-title commands. Used for testing and debugging title resolution. Strategy string diff --git a/pkg/service/context.go b/pkg/service/context.go index c572a5f48..ec24e5b21 100644 --- a/pkg/service/context.go +++ b/pkg/service/context.go @@ -27,6 +27,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playtime" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/profiles" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" @@ -41,6 +42,7 @@ type ServiceContext struct { State *state.State DB *database.Database Profiles *profiles.Service + LimitsManager *playtime.LimitsManager PlaybackManager audio.PlaybackManager UI *uievents.Service LaunchSoftwareQueue chan *tokens.Token diff --git a/pkg/service/playtime/extensions.go b/pkg/service/playtime/extensions.go new file mode 100644 index 000000000..c10def688 --- /dev/null +++ b/pkg/service/playtime/extensions.go @@ -0,0 +1,591 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package playtime + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers" + "github.com/rs/zerolog/log" +) + +// GrantMode selects what an extension grant does to the recipient's limits. +type GrantMode string + +const ( + // GrantModeDuration adds time to the current session's allowance. It + // applies to one session and is cleared when that session resets. + GrantModeDuration GrantMode = "duration" + // GrantModeToday waives the session limit for the recipient profile + // until the next local midnight. The daily limit still applies. + GrantModeToday GrantMode = "today" +) + +const ( + // MinGrantDuration is the smallest duration grant accepted. Anything + // shorter is not worth the scan and would be swallowed by the 30 second + // check interval. + MinGrantDuration = 1 * time.Minute + // MaxGrantDuration is the largest duration a single grant may add. + MaxGrantDuration = 24 * time.Hour + // MaxSessionExtension caps the duration accumulated across every grant + // applied to one session. Grants that would exceed it are rejected + // rather than clamped, so the caller learns the grant did not apply. + MaxSessionExtension = 24 * time.Hour + // grantLedgerSize bounds the per-session idempotency ledger. Grants are + // rare; this only needs to absorb retries and reader bounce. + grantLedgerSize = 16 + // extensionsStateVersion is the schema version of the persisted + // extensions snapshot. An unknown version fails closed. + extensionsStateVersion = 1 +) + +// Grant rejection reasons. Callers map these onto their own error surfaces: +// a failed ZapScript command for the card path, a client error for the API. +var ( + // ErrGrantModeInvalid is returned for an unrecognized grant mode. + ErrGrantModeInvalid = errors.New("unknown playtime extension mode") + // ErrGrantDurationRange is returned when a duration grant falls outside + // MinGrantDuration..MaxGrantDuration. + ErrGrantDurationRange = errors.New("playtime extension duration out of range") + // ErrGrantCapExceeded is returned when a grant would push the session's + // accumulated extension past MaxSessionExtension. + ErrGrantCapExceeded = errors.New("playtime extension would exceed the session cap") + // ErrGrantNoSession is returned when a duration grant is attempted with + // no session to extend. + ErrGrantNoSession = errors.New("no playtime session to extend") + // ErrGrantLimitsDisabled is returned when limits are not being enforced, + // so there is nothing to extend. + ErrGrantLimitsDisabled = errors.New("playtime limits are not enabled") + // ErrGrantClockUnreliable is returned when a day-scoped grant is + // attempted while the system clock cannot be trusted to find midnight. + ErrGrantClockUnreliable = errors.New("system clock is unreliable") + // ErrGrantStateChanged is returned when the session changed underneath a + // grant while it was being applied. The caller may retry. + ErrGrantStateChanged = errors.New("playtime session changed during grant") + // ErrGrantUnavailable is returned when the grant cannot be persisted, so + // it is refused rather than held only in memory. + ErrGrantUnavailable = errors.New("playtime extension storage unavailable") +) + +// GrantRequest asks for an extension to the effective playtime session. +// The recipient is never chosen by the caller: it is the profile currently +// governing playtime, so a grant cannot be aimed at somebody else. +type GrantRequest struct { + AuthorizerProfileID string + AuthorizerClientID string + Source string + IdempotencyKey string + Mode GrantMode + IdempotencyWindow time.Duration + Duration time.Duration +} + +// GrantResult describes an applied grant. It is also what a deduplicated +// repeat returns, so a retry sees the same answer as the original call. +type GrantResult struct { + // ExpiresAt is when a day waiver lapses. Zero for duration grants. + ExpiresAt time.Time + // RecipientProfileID is the profile the grant applies to. Empty is the + // shared profile, matching daily accounting elsewhere. + RecipientProfileID string + // AuthorizerProfileID is the profile that authorized the grant. + AuthorizerProfileID string + // Mode is the mode that was applied. + Mode GrantMode + // Duration is what this grant added. Zero for day waivers. + Duration time.Duration + // SessionExtension is the session's accumulated duration extension after + // this grant. + SessionExtension time.Duration + // Replayed is true when a matching idempotency key had already been + // applied and no new time was granted. + Replayed bool +} + +// sessionExtension is the duration granted to the current session. It is +// pinned to the profile that owned the session when the grant landed, so a +// later profile switch cannot inherit it. +type sessionExtension struct { + updatedAt time.Time + recipientProfileID string + authorizerProfileID string + total time.Duration +} + +// dayWaiver suspends the session limit for one profile until it expires. +type dayWaiver struct { + expires time.Time + authorizerProfileID string +} + +// appliedGrant is one entry in the per-session idempotency ledger. +type appliedGrant struct { + at time.Time + expiry time.Time + key string + result GrantResult +} + +// Grant applies an extension to the effective session. It resolves the +// recipient, validates the request against current state, persists the new +// snapshot, and only then updates memory: a grant that cannot be stored is +// refused rather than surviving only until the next restart. +// +// On success the caller should treat the returned result as the record of +// what happened, including for a deduplicated repeat. +func (tm *LimitsManager) Grant(req *GrantRequest) (GrantResult, error) { + now := tm.clock.Now() + + // Both helpers take tm.mu, so they must be called before it is held. + if !tm.effectiveEnabled() { + return GrantResult{}, ErrGrantLimitsDisabled + } + + if req.Mode == GrantModeDuration { + if req.Duration < MinGrantDuration || req.Duration > MaxGrantDuration { + return GrantResult{}, fmt.Errorf("%w: %s", ErrGrantDurationRange, req.Duration) + } + } else if req.Mode != GrantModeToday { + return GrantResult{}, fmt.Errorf("%w: %q", ErrGrantModeInvalid, req.Mode) + } + + // A day waiver is anchored to the local calendar, so it needs a clock we + // can believe. Duration grants are measured against the session, which + // already falls back to monotonic elapsed time. + if req.Mode == GrantModeToday && !helpers.IsClockReliable(now) { + return GrantResult{}, fmt.Errorf("%w: year %d", ErrGrantClockUnreliable, now.Year()) + } + + result, err := tm.applyGrant(req, now) + if err != nil { + return GrantResult{}, err + } + + if result.Replayed { + return result, nil + } + + log.Info(). + Str("mode", string(result.Mode)). + Dur("duration", result.Duration). + Dur("session_extension_total", result.SessionExtension). + Time("expires_at", result.ExpiresAt). + Str("recipient_profile_id", result.RecipientProfileID). + Str("authorizer_profile_id", result.AuthorizerProfileID). + Str("authorizer_client_id", req.AuthorizerClientID). + Str("source", req.Source). + Msg("playtime: extension granted") + + // Re-evaluate immediately so the new allowance and the re-armed warning + // thresholds take effect now instead of at the next 30 second tick. + tm.checkLimits() + + return result, nil +} + +// applyGrant performs the locked portion of Grant: validate, persist, commit. +func (tm *LimitsManager) applyGrant(req *GrantRequest, now time.Time) (GrantResult, error) { + tm.mu.Lock() + defer tm.mu.Unlock() + + if replay, ok := tm.lookupGrantLocked(req.IdempotencyKey, now); ok { + return replay, nil + } + + recipient := tm.effectiveProfileIDLocked() + + result := GrantResult{ + Mode: req.Mode, + RecipientProfileID: recipient, + AuthorizerProfileID: req.AuthorizerProfileID, + } + + // Snapshot the state we are about to change so a persistence failure + // leaves nothing behind. + nextSession := tm.sessionExtension + nextWaivers := tm.copyWaiversLocked(now) + + switch req.Mode { + case GrantModeDuration: + // Cooldown is the same session as the game that just stopped, and it + // is when a grant is most useful: the limit stopped the game and the + // player is about to relaunch. Only a reset session has nothing to + // extend. + if tm.state == StateReset { + return GrantResult{}, ErrGrantNoSession + } + + total := req.Duration + if nextSession != nil && nextSession.recipientProfileID == recipient { + total += nextSession.total + } + if total > MaxSessionExtension { + return GrantResult{}, fmt.Errorf( + "%w: %s granted, %s requested, %s cap", + ErrGrantCapExceeded, tm.sessionExtensionTotalLocked(recipient), req.Duration, MaxSessionExtension, + ) + } + + nextSession = &sessionExtension{ + recipientProfileID: recipient, + authorizerProfileID: req.AuthorizerProfileID, + total: total, + updatedAt: now, + } + result.Duration = req.Duration + result.SessionExtension = total + + case GrantModeToday: + // Repeating a day waiver is a no-op rather than a rolling extension: + // the boundary is midnight either way. + if existing, ok := nextWaivers[recipient]; ok && existing.expires.After(now) { + result.ExpiresAt = existing.expires + result.SessionExtension = tm.sessionExtensionTotalLocked(recipient) + tm.recordGrantLocked(req, &result, now) + return result, nil + } + + expires := nextLocalMidnight(now) + nextWaivers[recipient] = dayWaiver{ + expires: expires, + authorizerProfileID: req.AuthorizerProfileID, + } + result.ExpiresAt = expires + result.SessionExtension = tm.sessionExtensionTotalLocked(recipient) + } + + if err := tm.persistExtensionsLocked(nextSession, nextWaivers); err != nil { + return GrantResult{}, err + } + + tm.sessionExtension = nextSession + tm.dayWaivers = nextWaivers + + // Warning thresholds already given were measured against the old + // allowance. Re-arm them so they fire again against the new one. + tm.warningsGiven = make(map[time.Duration]bool) + + tm.recordGrantLocked(req, &result, now) + + return result, nil +} + +// sessionExtensionTotalLocked returns the duration extension in force for +// recipient, or zero when the current grant belongs to another profile. +func (tm *LimitsManager) sessionExtensionTotalLocked(recipient string) time.Duration { + if tm.sessionExtension == nil || tm.sessionExtension.recipientProfileID != recipient { + return 0 + } + return tm.sessionExtension.total +} + +// dayWaiverExpiryLocked returns the expiry of recipient's active day waiver, +// or the zero time when none applies. Expired waivers read as absent whether +// or not they have been pruned yet, so correctness never depends on pruning. +func (tm *LimitsManager) dayWaiverExpiryLocked(recipient string, now time.Time) time.Time { + waiver, ok := tm.dayWaivers[recipient] + if !ok { + return time.Time{} + } + // A waiver written under a good clock must not be honored (or expired) + // while the clock is untrustworthy: "before midnight" is meaningless. + if !helpers.IsClockReliable(now) { + return time.Time{} + } + if !waiver.expires.After(now) { + return time.Time{} + } + return waiver.expires +} + +// copyWaiversLocked returns a copy of the waiver map with expired entries +// dropped. Callers mutate the copy and swap it in on success. +func (tm *LimitsManager) copyWaiversLocked(now time.Time) map[string]dayWaiver { + next := make(map[string]dayWaiver, len(tm.dayWaivers)) + for profileID, waiver := range tm.dayWaivers { + // Keep future waivers under an unreliable clock: the grant may + // simply be waiting for time to be set. dayWaiverExpiryLocked + // refuses to honor them until then. + if !helpers.IsClockReliable(now) || waiver.expires.After(now) { + next[profileID] = waiver + } + } + return next +} + +// lookupGrantLocked returns a previously applied grant for key, if it is +// still within its idempotency window. +func (tm *LimitsManager) lookupGrantLocked(key string, now time.Time) (GrantResult, bool) { + if key == "" { + return GrantResult{}, false + } + for i := range tm.grantLedger { + entry := &tm.grantLedger[i] + if entry.key != key { + continue + } + if !entry.expiry.IsZero() && !entry.expiry.After(now) { + continue + } + replay := entry.result + replay.Replayed = true + return replay, true + } + return GrantResult{}, false +} + +// recordGrantLocked appends an applied grant to the bounded ledger. +func (tm *LimitsManager) recordGrantLocked(req *GrantRequest, result *GrantResult, now time.Time) { + if req.IdempotencyKey == "" { + return + } + entry := appliedGrant{ + key: req.IdempotencyKey, + at: now, + result: *result, + } + if req.IdempotencyWindow > 0 { + entry.expiry = now.Add(req.IdempotencyWindow) + } + tm.grantLedger = append(tm.grantLedger, entry) + if len(tm.grantLedger) > grantLedgerSize { + tm.grantLedger = tm.grantLedger[len(tm.grantLedger)-grantLedgerSize:] + } +} + +// clearSessionExtensionLocked drops the current session's duration grant and +// its idempotency ledger. Day waivers outlive the session and are kept. +// Persistence failures are logged, not returned: the in-memory clear is the +// safe direction, and a stale stored grant is discarded at restore time when +// its recipient no longer matches. +func (tm *LimitsManager) clearSessionExtensionLocked() { + if tm.sessionExtension == nil && len(tm.grantLedger) == 0 { + return + } + tm.sessionExtension = nil + tm.grantLedger = nil + if err := tm.persistExtensionsLocked(nil, tm.dayWaivers); err != nil { + log.Warn().Err(err).Msg("playtime: failed to clear stored session extension") + } +} + +// pruneExpiredWaivers drops lapsed day waivers and rewrites the stored +// snapshot when anything actually changed. +func (tm *LimitsManager) pruneExpiredWaivers() { + now := tm.clock.Now() + + tm.mu.Lock() + defer tm.mu.Unlock() + + if len(tm.dayWaivers) == 0 { + return + } + next := tm.copyWaiversLocked(now) + if len(next) == len(tm.dayWaivers) { + return + } + if err := tm.persistExtensionsLocked(tm.sessionExtension, next); err != nil { + log.Warn().Err(err).Msg("playtime: failed to prune stored day waivers") + return + } + tm.dayWaivers = next +} + +// nextLocalMidnight returns the start of the next local day. Building the +// date rather than adding 24 hours keeps the boundary correct across DST. +func nextLocalMidnight(now time.Time) time.Time { + year, month, day := now.Date() + return time.Date(year, month, day+1, 0, 0, 0, 0, now.Location()) +} + +// persistedExtensions is the stored form of the manager's grant state. It +// holds resolved profile IDs only: switch IDs are bearer credentials and +// never reach durable storage. +type persistedExtensions struct { + Session *persistedSessionExtension `json:"session,omitempty"` + DayWaivers []persistedDayWaiver `json:"dayWaivers,omitempty"` + Version int `json:"version"` +} + +type persistedSessionExtension struct { + UpdatedAt time.Time `json:"updatedAt"` + RecipientProfileID string `json:"recipientProfileId"` + AuthorizerProfileID string `json:"authorizerProfileId"` + TotalSeconds int64 `json:"totalSeconds"` +} + +type persistedDayWaiver struct { + Expires time.Time `json:"expires"` + ProfileID string `json:"profileId"` + AuthorizerProfileID string `json:"authorizerProfileId"` +} + +// persistExtensionsLocked writes the complete snapshot, or deletes the key +// when there is nothing left to remember. +func (tm *LimitsManager) persistExtensionsLocked( + session *sessionExtension, + waivers map[string]dayWaiver, +) error { + if tm.db == nil || tm.db.UserDB == nil { + return ErrGrantUnavailable + } + + if session == nil && len(waivers) == 0 { + if err := tm.db.UserDB.DeleteDeviceState(database.DeviceStateKeyPlaytimeExtensions); err != nil { + return fmt.Errorf("failed to delete playtime extension state: %w", err) + } + return nil + } + + state := persistedExtensions{Version: extensionsStateVersion} + if session != nil { + state.Session = &persistedSessionExtension{ + RecipientProfileID: session.recipientProfileID, + AuthorizerProfileID: session.authorizerProfileID, + TotalSeconds: int64(session.total / time.Second), + UpdatedAt: session.updatedAt, + } + } + for profileID, waiver := range waivers { + state.DayWaivers = append(state.DayWaivers, persistedDayWaiver{ + ProfileID: profileID, + AuthorizerProfileID: waiver.authorizerProfileID, + Expires: waiver.expires, + }) + } + + encoded, err := json.Marshal(state) + if err != nil { + return fmt.Errorf("failed to encode playtime extension state: %w", err) + } + if err := tm.db.UserDB.SetDeviceState( + database.DeviceStateKeyPlaytimeExtensions, string(encoded), + ); err != nil { + return fmt.Errorf("failed to store playtime extension state: %w", err) + } + return nil +} + +// RestoreExtensions reloads granted extensions after a restart. It must run +// after RestoreSessionFromHistory so it can see whether the session the +// duration grant belongs to actually came back. +// +// Day waivers restore on their own: they are scoped to a profile and a +// calendar day, not to a session. A duration grant only restores when its +// session was restored and the recipient still matches, so a grant cannot be +// carried into somebody else's session by restarting the service. +func (tm *LimitsManager) RestoreExtensions(now time.Time) { + if tm.db == nil || tm.db.UserDB == nil { + return + } + + raw, ok, err := tm.db.UserDB.GetDeviceState(database.DeviceStateKeyPlaytimeExtensions) + if err != nil { + log.Warn().Err(err).Msg("playtime: failed to read stored extensions") + return + } + if !ok || raw == "" { + return + } + + var state persistedExtensions + if decodeErr := json.Unmarshal([]byte(raw), &state); decodeErr != nil { + log.Warn().Err(decodeErr).Msg("playtime: discarding malformed extension state") + tm.discardStoredExtensions() + return + } + if state.Version != extensionsStateVersion { + log.Warn(). + Int("version", state.Version). + Int("expected", extensionsStateVersion). + Msg("playtime: discarding extension state written by another version") + tm.discardStoredExtensions() + return + } + + waivers := make(map[string]dayWaiver, len(state.DayWaivers)) + for i := range state.DayWaivers { + stored := &state.DayWaivers[i] + // Keep future waivers under an unreliable clock so a device that + // boots before NTP does not silently drop a grant. + if helpers.IsClockReliable(now) && !stored.Expires.After(now) { + continue + } + waivers[stored.ProfileID] = dayWaiver{ + expires: stored.Expires, + authorizerProfileID: stored.AuthorizerProfileID, + } + } + + tm.mu.Lock() + restoredSession := tm.state != StateReset + recipient := tm.effectiveProfileIDLocked() + + var session *sessionExtension + switch { + case state.Session == nil: + case !restoredSession: + log.Info().Msg("playtime: discarding stored session extension, no session restored") + case state.Session.RecipientProfileID != recipient: + log.Info(). + Str("stored_profile_id", state.Session.RecipientProfileID). + Str("effective_profile_id", recipient). + Msg("playtime: discarding stored session extension, recipient changed") + default: + session = &sessionExtension{ + recipientProfileID: state.Session.RecipientProfileID, + authorizerProfileID: state.Session.AuthorizerProfileID, + total: time.Duration(state.Session.TotalSeconds) * time.Second, + updatedAt: state.Session.UpdatedAt, + } + } + + tm.sessionExtension = session + tm.dayWaivers = waivers + err = tm.persistExtensionsLocked(session, waivers) + tm.mu.Unlock() + + if err != nil { + log.Warn().Err(err).Msg("playtime: failed to rewrite extension state after restore") + } + + if session != nil { + log.Info(). + Dur("session_extension", session.total). + Str("recipient_profile_id", session.recipientProfileID). + Msg("playtime: restored session extension") + } + if len(waivers) > 0 { + log.Info().Int("day_waivers", len(waivers)).Msg("playtime: restored day waivers") + } +} + +// discardStoredExtensions removes state this build cannot interpret, so a +// device does not carry an unreadable grant forward indefinitely. +func (tm *LimitsManager) discardStoredExtensions() { + if err := tm.db.UserDB.DeleteDeviceState(database.DeviceStateKeyPlaytimeExtensions); err != nil { + log.Warn().Err(err).Msg("playtime: failed to discard stored extensions") + } +} diff --git a/pkg/service/playtime/extensions_test.go b/pkg/service/playtime/extensions_test.go new file mode 100644 index 000000000..5e76ba5a7 --- /dev/null +++ b/pkg/service/playtime/extensions_test.go @@ -0,0 +1,673 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package playtime + +import ( + "testing" + "time" + + "github.com/ZaparooProject/zaparoo-core/v2/pkg/config" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/database" + testhelpers "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers" + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// deviceStateStore backs the DeviceState key/value calls with a real map, so +// tests can assert on what was actually persisted rather than on call counts. +type deviceStateStore struct { + values map[string]string +} + +// wireDeviceState points a mock UserDB's key/value calls at store, so tests +// can assert on what was actually persisted rather than on call counts. +func wireDeviceState(mockDB *testhelpers.MockUserDBI, store *deviceStateStore) { + mockDB.On("SetDeviceState", mock.Anything, mock.Anything). + Return(nil). + Run(func(args mock.Arguments) { + store.values[args.String(0)] = args.String(1) + }).Maybe() + // Reads are snapshotted at construction, which is exactly the restore + // case: a fresh manager sees what the previous one persisted. + for key, value := range store.values { + mockDB.On("GetDeviceState", key).Return(value, true, nil).Maybe() + } + mockDB.On("GetDeviceState", mock.Anything).Return("", false, nil).Maybe() + mockDB.On("DeleteDeviceState", mock.Anything). + Return(nil). + Run(func(args mock.Arguments) { + delete(store.values, args.String(0)) + }).Maybe() + mockDB.On("SumMediaPlayTimeForDay", mock.Anything).Return(int64(0), nil).Maybe() + mockDB.On("SumMediaPlayTimeForDayByProfile", mock.Anything, mock.Anything). + Return(int64(0), nil).Maybe() +} + +// newExtensionManager builds a manager whose DeviceState reads and writes go +// to an in-memory store. Passing an existing store simulates a restart: a +// fresh manager over the state the previous one left behind. +func newExtensionManager( + t *testing.T, now time.Time, provider LimitsProvider, store *deviceStateStore, +) (*LimitsManager, *deviceStateStore) { + t.Helper() + + if store == nil { + store = &deviceStateStore{values: make(map[string]string)} + } + mockDB := testhelpers.NewMockUserDBI() + wireDeviceState(mockDB, store) + + cfg := newTestConfig(t, &config.Values{}) + tm := NewLimitsManager( + &database.Database{UserDB: mockDB}, nil, cfg, + clockwork.NewFakeClockAt(now), newNoOpMockPlayer(), + ) + tm.SetLimitsProvider(provider) + + return tm, store +} + +// enterCooldown puts the manager in the state it reaches after a game stops: +// the session is still alive and its accumulated time is remembered. +func (tm *LimitsManager) enterCooldown(cumulative time.Duration) { + tm.mu.Lock() + defer tm.mu.Unlock() + tm.state = StateCooldown + tm.sessionCumulativeTime = cumulative +} + +func durationGrant(d time.Duration) *GrantRequest { + return &GrantRequest{ + Mode: GrantModeDuration, + Duration: d, + AuthorizerProfileID: "parent", + Source: "reader", + } +} + +func TestGrant_DurationExtendsSessionLimit(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + tm, _ := newExtensionManager(t, now, stubProvider{enabled: true, session: time.Hour}, nil) + tm.enterCooldown(30 * time.Minute) + + result, err := tm.Grant(durationGrant(15 * time.Minute)) + require.NoError(t, err) + + assert.Equal(t, GrantModeDuration, result.Mode) + assert.Equal(t, 15*time.Minute, result.Duration) + assert.Equal(t, 15*time.Minute, result.SessionExtension) + assert.False(t, result.Replayed) + + assert.Equal(t, time.Hour+15*time.Minute, tm.effectiveSessionLimit(), + "granted time should raise the session limit") +} + +func TestGrant_DurationAccumulatesAcrossGrants(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + tm, _ := newExtensionManager(t, now, stubProvider{enabled: true, session: time.Hour}, nil) + tm.enterCooldown(0) + + _, err := tm.Grant(durationGrant(15 * time.Minute)) + require.NoError(t, err) + second, err := tm.Grant(durationGrant(10 * time.Minute)) + require.NoError(t, err) + + assert.Equal(t, 10*time.Minute, second.Duration, "result reports this grant only") + assert.Equal(t, 25*time.Minute, second.SessionExtension, "accumulated total covers both") + assert.Equal(t, time.Hour+25*time.Minute, tm.effectiveSessionLimit()) +} + +func TestGrant_DurationRejectedOutsideBounds(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + + tests := []struct { + wantErr error + name string + duration time.Duration + }{ + {name: "zero", duration: 0, wantErr: ErrGrantDurationRange}, + {name: "negative", duration: -5 * time.Minute, wantErr: ErrGrantDurationRange}, + {name: "below minimum", duration: 30 * time.Second, wantErr: ErrGrantDurationRange}, + {name: "above maximum", duration: 25 * time.Hour, wantErr: ErrGrantDurationRange}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tm, _ := newExtensionManager(t, now, stubProvider{enabled: true, session: time.Hour}, nil) + tm.enterCooldown(0) + + _, err := tm.Grant(durationGrant(tt.duration)) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, time.Hour, tm.effectiveSessionLimit(), "a rejected grant changes nothing") + }) + } +} + +func TestGrant_DurationRejectedPastCumulativeCap(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + tm, _ := newExtensionManager(t, now, stubProvider{enabled: true, session: time.Hour}, nil) + tm.enterCooldown(0) + + _, err := tm.Grant(durationGrant(MaxSessionExtension)) + require.NoError(t, err) + + // Rejected rather than clamped: silently granting less than asked would + // leave the card holder believing time was added. + _, err = tm.Grant(durationGrant(time.Minute)) + require.ErrorIs(t, err, ErrGrantCapExceeded) + assert.Equal(t, time.Hour+MaxSessionExtension, tm.effectiveSessionLimit()) +} + +func TestGrant_DurationRejectedWithNoSession(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + tm, store := newExtensionManager(t, now, stubProvider{enabled: true, session: time.Hour}, nil) + + _, err := tm.Grant(durationGrant(15 * time.Minute)) + require.ErrorIs(t, err, ErrGrantNoSession) + assert.Empty(t, store.values, "a refused grant persists nothing") +} + +func TestGrant_RejectedWhenLimitsDisabled(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + tm, _ := newExtensionManager(t, now, stubProvider{enabled: false, session: time.Hour}, nil) + tm.enterCooldown(0) + + _, err := tm.Grant(durationGrant(15 * time.Minute)) + require.ErrorIs(t, err, ErrGrantLimitsDisabled) +} + +// TestGrant_UnblocksRelaunchAfterLimitStop covers the flow the card exists +// for: the session limit stopped the game, a parent grants more time, and the +// next launch has to be allowed. This is what CheckBeforeLaunch reading the +// effective session limit buys. +func TestGrant_UnblocksRelaunchAfterLimitStop(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + tm, _ := newExtensionManager(t, now, stubProvider{enabled: true, session: time.Hour}, nil) + + // The limit stopped the game, so the session is over its allowance. + tm.enterCooldown(time.Hour) + + reason, err := tm.CheckBeforeLaunch() + require.Error(t, err, "relaunch must be blocked before any grant") + assert.Equal(t, "session", reason) + + _, err = tm.Grant(durationGrant(15 * time.Minute)) + require.NoError(t, err) + + reason, err = tm.CheckBeforeLaunch() + require.NoError(t, err, "relaunch must be allowed once time is granted") + assert.Empty(t, reason) +} + +func TestGrant_ResetsWarningsSoTheyFireAgain(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + tm, _ := newExtensionManager(t, now, stubProvider{enabled: true, session: time.Hour}, nil) + tm.enterCooldown(0) + + tm.mu.Lock() + tm.warningsGiven[5*time.Minute] = true + tm.mu.Unlock() + + _, err := tm.Grant(durationGrant(15 * time.Minute)) + require.NoError(t, err) + + tm.mu.Lock() + given := tm.warningsGiven[5*time.Minute] + tm.mu.Unlock() + assert.False(t, given, "thresholds already spent were measured against the old allowance") +} + +func TestGrant_TodayWaivesSessionLimitOnly(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + tm, _ := newExtensionManager(t, now, stubProvider{ + enabled: true, session: time.Hour, daily: 3 * time.Hour, + }, nil) + + result, err := tm.Grant(&GrantRequest{Mode: GrantModeToday, AuthorizerProfileID: "parent"}) + require.NoError(t, err) + + assert.Equal(t, time.Date(2026, 6, 13, 0, 0, 0, 0, time.UTC), result.ExpiresAt) + assert.Equal(t, time.Duration(0), tm.effectiveSessionLimit(), "session limit is waived") + assert.Equal(t, 3*time.Hour, tm.effectiveDailyLimit(), "daily limit is untouched") + + rules := tm.createRules() + require.Len(t, rules, 1, "only the daily rule should remain") + assert.IsType(t, &DailyLimitRule{}, rules[0]) +} + +// A day grant is explicitly day-scoped, so it does not need a session to +// attach to and can be handed out before anyone starts playing. +func TestGrant_TodayAcceptedWithNoSession(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + tm, _ := newExtensionManager(t, now, stubProvider{enabled: true, session: time.Hour}, nil) + + _, err := tm.Grant(&GrantRequest{Mode: GrantModeToday, AuthorizerProfileID: "parent"}) + require.NoError(t, err) + assert.Equal(t, time.Duration(0), tm.effectiveSessionLimit()) +} + +func TestGrant_TodayRepeatKeepsSameBoundary(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + tm, _ := newExtensionManager(t, now, stubProvider{enabled: true, session: time.Hour}, nil) + + first, err := tm.Grant(&GrantRequest{Mode: GrantModeToday, AuthorizerProfileID: "parent"}) + require.NoError(t, err) + second, err := tm.Grant(&GrantRequest{Mode: GrantModeToday, AuthorizerProfileID: "parent"}) + require.NoError(t, err) + + assert.Equal(t, first.ExpiresAt, second.ExpiresAt, + "rescanning must not roll the waiver into another day") +} + +func TestGrant_TodayExpiresAtMidnight(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 23, 30, 0, 0, time.UTC) + tm, _ := newExtensionManager(t, now, stubProvider{enabled: true, session: time.Hour}, nil) + + _, err := tm.Grant(&GrantRequest{Mode: GrantModeToday, AuthorizerProfileID: "parent"}) + require.NoError(t, err) + require.Equal(t, time.Duration(0), tm.effectiveSessionLimit()) + + tm.clock.(*clockwork.FakeClock).Advance(31 * time.Minute) + + assert.Equal(t, time.Hour, tm.effectiveSessionLimit(), + "the ordinary session limit returns after midnight") +} + +func TestGrant_TodayRejectedWhenClockUnreliable(t *testing.T) { + t.Parallel() + + // A day waiver is anchored to the local calendar, so an unset clock + // makes "until midnight" meaningless. + now := time.Date(1970, 1, 1, 12, 0, 0, 0, time.UTC) + tm, store := newExtensionManager(t, now, stubProvider{enabled: true, session: time.Hour}, nil) + + _, err := tm.Grant(&GrantRequest{Mode: GrantModeToday, AuthorizerProfileID: "parent"}) + require.ErrorIs(t, err, ErrGrantClockUnreliable) + assert.Empty(t, store.values) +} + +func TestGrant_RejectsUnknownMode(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + tm, _ := newExtensionManager(t, now, stubProvider{enabled: true, session: time.Hour}, nil) + tm.enterCooldown(0) + + _, err := tm.Grant(&GrantRequest{Mode: "forever", AuthorizerProfileID: "parent"}) + require.ErrorIs(t, err, ErrGrantModeInvalid) +} + +func TestGrant_IdempotencyKeyReplaysWithoutAddingTime(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + tm, _ := newExtensionManager(t, now, stubProvider{enabled: true, session: time.Hour}, nil) + tm.enterCooldown(0) + + req := durationGrant(15 * time.Minute) + req.IdempotencyKey = "retry-1" + + first, err := tm.Grant(req) + require.NoError(t, err) + assert.False(t, first.Replayed) + + second, err := tm.Grant(req) + require.NoError(t, err) + assert.True(t, second.Replayed, "a repeat must report the original grant") + assert.Equal(t, first.SessionExtension, second.SessionExtension) + assert.Equal(t, time.Hour+15*time.Minute, tm.effectiveSessionLimit(), + "a replay must not add more time") +} + +func TestGrant_IdempotencyWindowExpires(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + tm, _ := newExtensionManager(t, now, stubProvider{enabled: true, session: time.Hour}, nil) + tm.enterCooldown(0) + + req := durationGrant(15 * time.Minute) + req.IdempotencyKey = "tap" + req.IdempotencyWindow = 10 * time.Second + + _, err := tm.Grant(req) + require.NoError(t, err) + + tm.clock.(*clockwork.FakeClock).Advance(11 * time.Second) + + // A deliberate second tap later is a second grant, not a duplicate. + again, err := tm.Grant(req) + require.NoError(t, err) + assert.False(t, again.Replayed) + assert.Equal(t, 30*time.Minute, again.SessionExtension) +} + +func TestGrant_DurationPinnedToRecipientProfile(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + provider := &swappableProvider{} + provider.set(stubProvider{enabled: true, session: time.Hour, profileID: "kid-a"}) + + tm, _ := newExtensionManager(t, now, provider, nil) + tm.enterCooldown(0) + + _, err := tm.Grant(durationGrant(15 * time.Minute)) + require.NoError(t, err) + require.Equal(t, time.Hour+15*time.Minute, tm.effectiveSessionLimit()) + + // Somebody else is playing now. The grant belonged to the previous + // session and must not carry over. + provider.set(stubProvider{enabled: true, session: time.Hour, profileID: "kid-b"}) + assert.Equal(t, time.Hour, tm.effectiveSessionLimit()) +} + +func TestGrant_ClearedWhenSessionResets(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + tm, store := newExtensionManager(t, now, stubProvider{enabled: true, session: time.Hour}, nil) + tm.enterCooldown(0) + + _, err := tm.Grant(durationGrant(15 * time.Minute)) + require.NoError(t, err) + require.NotEmpty(t, store.values) + + tm.ResetSession() + + assert.Equal(t, time.Hour, tm.effectiveSessionLimit()) + assert.Empty(t, store.values, "an empty snapshot deletes the stored key") +} + +func TestGrant_SurvivesMediaStopIntoCooldown(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + tm, _ := newExtensionManager(t, now, stubProvider{enabled: true, session: time.Hour}, nil) + + tm.mu.Lock() + tm.state = StateActive + tm.sessionStart = now + tm.sessionStartMono = time.Now() + tm.mu.Unlock() + + _, err := tm.Grant(durationGrant(15 * time.Minute)) + require.NoError(t, err) + + // Stopping a game ends the game, not the session: cooldown is exactly + // when a player relaunches on the time they were just granted. + tm.OnMediaStopped() + + assert.Equal(t, time.Hour+15*time.Minute, tm.effectiveSessionLimit()) +} + +func TestGrant_ClearedWhenLimitsDisabled(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + tm, _ := newExtensionManager(t, now, stubProvider{enabled: true, session: time.Hour}, nil) + tm.enterCooldown(0) + + _, err := tm.Grant(durationGrant(15 * time.Minute)) + require.NoError(t, err) + + tm.SetEnabled(false) + + tm.mu.Lock() + extension := tm.sessionExtension + tm.mu.Unlock() + assert.Nil(t, extension) +} + +func TestGrant_FailsClosedWhenStorageUnavailable(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + cfg := newTestConfig(t, &config.Values{}) + tm := NewLimitsManager(nil, nil, cfg, clockwork.NewFakeClockAt(now), newNoOpMockPlayer()) + tm.SetLimitsProvider(stubProvider{enabled: true, session: time.Hour}) + tm.enterCooldown(0) + + _, err := tm.Grant(durationGrant(15 * time.Minute)) + require.ErrorIs(t, err, ErrGrantUnavailable) + assert.Equal(t, time.Hour, tm.effectiveSessionLimit(), + "a grant that cannot be stored must not apply in memory either") +} + +func TestRestoreExtensions_RestoresGrantWithSession(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + provider := stubProvider{enabled: true, session: time.Hour, profileID: "kid-a"} + tm, store := newExtensionManager(t, now, provider, nil) + tm.enterCooldown(0) + + _, err := tm.Grant(durationGrant(15 * time.Minute)) + require.NoError(t, err) + + // A restart: same stored state, a fresh manager, and a session that came + // back from history. + restored, _ := newExtensionManager(t, now, provider, store) + restored.mu.Lock() + restored.state = StateCooldown + restored.mu.Unlock() + + restored.RestoreExtensions(now) + + assert.Equal(t, time.Hour+15*time.Minute, restored.effectiveSessionLimit()) +} + +func TestRestoreExtensions_DiscardsGrantWithoutSession(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + provider := stubProvider{enabled: true, session: time.Hour, profileID: "kid-a"} + tm, store := newExtensionManager(t, now, provider, nil) + tm.enterCooldown(0) + + _, err := tm.Grant(durationGrant(15 * time.Minute)) + require.NoError(t, err) + + // The cooldown window lapsed while the service was down, so the session + // the grant belonged to is gone. + restored, _ := newExtensionManager(t, now, provider, store) + + restored.RestoreExtensions(now) + + assert.Equal(t, time.Hour, restored.effectiveSessionLimit()) +} + +func TestRestoreExtensions_DiscardsGrantForAnotherProfile(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + tm, store := newExtensionManager(t, now, + stubProvider{enabled: true, session: time.Hour, profileID: "kid-a"}, nil) + tm.enterCooldown(0) + + _, err := tm.Grant(durationGrant(15 * time.Minute)) + require.NoError(t, err) + + restored, _ := newExtensionManager(t, now, + stubProvider{enabled: true, session: time.Hour, profileID: "kid-b"}, store) + restored.mu.Lock() + restored.state = StateCooldown + restored.mu.Unlock() + + restored.RestoreExtensions(now) + + assert.Equal(t, time.Hour, restored.effectiveSessionLimit()) +} + +func TestRestoreExtensions_RestoresWaiverWithoutSession(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + provider := stubProvider{enabled: true, session: time.Hour} + tm, store := newExtensionManager(t, now, provider, nil) + + _, err := tm.Grant(&GrantRequest{Mode: GrantModeToday, AuthorizerProfileID: "parent"}) + require.NoError(t, err) + + // A waiver is scoped to a profile and a day, not to a session, so it + // comes back whether or not a session did. + restored, _ := newExtensionManager(t, now, provider, store) + + restored.RestoreExtensions(now) + + assert.Equal(t, time.Duration(0), restored.effectiveSessionLimit()) +} + +func TestRestoreExtensions_FailsClosedOnBadState(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + state string + }{ + {name: "malformed json", state: `{"version":`}, + {name: "unknown version", state: `{"version":99,"session":{"totalSeconds":900}}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + store := &deviceStateStore{values: map[string]string{ + database.DeviceStateKeyPlaytimeExtensions: tt.state, + }} + tm, _ := newExtensionManager(t, now, + stubProvider{enabled: true, session: time.Hour}, store) + tm.enterCooldown(0) + + tm.RestoreExtensions(now) + + assert.Equal(t, time.Hour, tm.effectiveSessionLimit(), + "state this build cannot read must not enable an extension") + assert.Empty(t, store.values, "unreadable state is discarded rather than carried forward") + }) + } +} + +func TestGetStatus_ReportsExtension(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + tm, _ := newExtensionManager(t, now, stubProvider{enabled: true, session: time.Hour}, nil) + tm.enterCooldown(10 * time.Minute) + + _, err := tm.Grant(durationGrant(15 * time.Minute)) + require.NoError(t, err) + + status := tm.GetStatus() + assert.Equal(t, 15*time.Minute, status.SessionExtension) + assert.True(t, status.SessionExtendedUntil.IsZero()) + assert.Equal(t, 65*time.Minute, status.SessionRemaining, + "remaining time should count the granted extension") +} + +func TestGetStatus_ReportsDayWaiver(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + tm, _ := newExtensionManager(t, now, stubProvider{enabled: true, session: time.Hour}, nil) + + _, err := tm.Grant(&GrantRequest{Mode: GrantModeToday, AuthorizerProfileID: "parent"}) + require.NoError(t, err) + + status := tm.GetStatus() + assert.Equal(t, time.Date(2026, 6, 13, 0, 0, 0, 0, time.UTC), status.SessionExtendedUntil) +} + +func TestNextLocalMidnight(t *testing.T) { + t.Parallel() + + tests := []struct { + now time.Time + want time.Time + name string + }{ + { + name: "midday", + now: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC), + want: time.Date(2026, 6, 13, 0, 0, 0, 0, time.UTC), + }, + { + name: "just before midnight", + now: time.Date(2026, 6, 12, 23, 59, 59, 0, time.UTC), + want: time.Date(2026, 6, 13, 0, 0, 0, 0, time.UTC), + }, + { + name: "end of month rolls over", + now: time.Date(2026, 6, 30, 20, 0, 0, 0, time.UTC), + want: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), + }, + { + name: "end of year rolls over", + now: time.Date(2026, 12, 31, 20, 0, 0, 0, time.UTC), + want: time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, nextLocalMidnight(tt.now)) + }) + } +} + +func TestGrantErrorsAreDistinguishable(t *testing.T) { + t.Parallel() + + // Callers map these onto client errors, so they must not collapse into + // one another through wrapping. + require.NotErrorIs(t, ErrGrantCapExceeded, ErrGrantDurationRange) + require.NotErrorIs(t, ErrGrantNoSession, ErrGrantLimitsDisabled) +} diff --git a/pkg/service/playtime/limits.go b/pkg/service/playtime/limits.go index 8c93b5e07..f36b45926 100644 --- a/pkg/service/playtime/limits.go +++ b/pkg/service/playtime/limits.go @@ -88,24 +88,32 @@ type pinnedLimits struct { // LimitsManager enforces time limits and warnings for gameplay sessions. type LimitsManager struct { - sessionStart time.Time - sessionStartMono time.Time - lastStopTime time.Time - platform platforms.Platform - clock clockwork.Clock - ctx context.Context - cooldownTimer clockwork.Timer - done chan struct{} - warningsGiven map[time.Duration]bool - db *database.Database - notificationsSend chan<- models.Notification - cfg *config.Instance - limits LimitsProvider - sessionLimits *pinnedLimits // launch-time limits for the running game; nil between sessions - player audio.Player - cancel context.CancelFunc - sessionCancel context.CancelFunc // cancels checkLoop for the current game session; nil between sessions - lastProfileID string // last-seen active profile ID, for identity-change detection + sessionStart time.Time + sessionStartMono time.Time + lastStopTime time.Time + platform platforms.Platform + clock clockwork.Clock + ctx context.Context + cooldownTimer clockwork.Timer + done chan struct{} + warningsGiven map[time.Duration]bool + db *database.Database + notificationsSend chan<- models.Notification + cfg *config.Instance + limits LimitsProvider + sessionLimits *pinnedLimits // launch-time limits for the running game; nil between sessions + player audio.Player + cancel context.CancelFunc + sessionCancel context.CancelFunc // cancels checkLoop for the current game session; nil between sessions + lastProfileID string // last-seen active profile ID, for identity-change detection + // sessionExtension is the duration granted to the current session, or + // nil when none is in force. Cleared whenever the session resets. + sessionExtension *sessionExtension + // dayWaivers suspends the session limit per recipient profile until + // midnight. Unlike sessionExtension it outlives the session. + dayWaivers map[string]dayWaiver + // grantLedger deduplicates repeated grants (API retries, reader bounce). + grantLedger []appliedGrant state SessionState sessionCumulativeTime time.Duration subscriptionID int @@ -242,6 +250,7 @@ func (tm *LimitsManager) SetEnabled(enabled bool) { tm.sessionStartReliable = false tm.warningsGiven = make(map[time.Duration]bool) tm.sessionLimits = nil + tm.clearSessionExtensionLocked() } tm.mu.Unlock() } @@ -297,15 +306,13 @@ func (tm *LimitsManager) snapshotLimits() *pinnedLimits { } } -// pinned returns the launch-time limits when they should govern instead of -// the live provider: a game session exists, it was launched (or re-pinned) -// under a profile, and the device has since deactivated to the shared -// profile. Returns nil when live values apply. Must be called without -// tm.mu held. -func (tm *LimitsManager) pinned() *pinnedLimits { - tm.mu.Lock() +// pinnedLocked returns the launch-time limits when they should govern +// instead of the live provider: a game session exists, it was launched (or +// re-pinned) under a profile, and the device has since deactivated to the +// shared profile. Returns nil when live values apply. Caller must hold +// tm.mu; the provider reads service state under its own lock. +func (tm *LimitsManager) pinnedLocked() *pinnedLimits { p := tm.sessionLimits - tm.mu.Unlock() if p == nil || p.profileID == "" { return nil } @@ -318,7 +325,14 @@ func (tm *LimitsManager) pinned() *pinnedLimits { // effectiveEnabled reports whether limits are enforced for the current // session, honoring launch-time pinning. Must be called without tm.mu held. func (tm *LimitsManager) effectiveEnabled() bool { - if p := tm.pinned(); p != nil { + tm.mu.Lock() + defer tm.mu.Unlock() + return tm.effectiveEnabledLocked() +} + +// effectiveEnabledLocked is effectiveEnabled for callers holding tm.mu. +func (tm *LimitsManager) effectiveEnabledLocked() bool { + if p := tm.pinnedLocked(); p != nil { return p.enabled } return tm.limits.PlaytimeLimitsEnabled() @@ -327,26 +341,60 @@ func (tm *LimitsManager) effectiveEnabled() bool { // effectiveDailyLimit returns the daily limit for the current session, // honoring launch-time pinning. Must be called without tm.mu held. func (tm *LimitsManager) effectiveDailyLimit() time.Duration { - if p := tm.pinned(); p != nil { + tm.mu.Lock() + defer tm.mu.Unlock() + if p := tm.pinnedLocked(); p != nil { return p.daily } return tm.limits.DailyLimit() } -// effectiveSessionLimit returns the session limit for the current session, -// honoring launch-time pinning. Must be called without tm.mu held. +// effectiveSessionLimit returns the session limit governing the current +// session: the launch-pinned or live configured limit, adjusted by any +// extension granted to the profile that owns the session. Must be called +// without tm.mu held. func (tm *LimitsManager) effectiveSessionLimit() time.Duration { - if p := tm.pinned(); p != nil { - return p.session + tm.mu.Lock() + defer tm.mu.Unlock() + return tm.effectiveSessionLimitLocked(tm.clock.Now()) +} + +// effectiveSessionLimitLocked is effectiveSessionLimit for callers holding +// tm.mu. A zero return means "no session limit", which is how an active day +// waiver is expressed: createRules simply builds no SessionLimitRule, and +// the daily limit is left untouched. +func (tm *LimitsManager) effectiveSessionLimitLocked(now time.Time) time.Duration { + recipient := tm.effectiveProfileIDLocked() + + var base time.Duration + if p := tm.pinnedLocked(); p != nil { + base = p.session + } else { + base = tm.limits.SessionLimit() + } + + if !tm.dayWaiverExpiryLocked(recipient, now).IsZero() { + return 0 } - return tm.limits.SessionLimit() + if base == 0 { + // No session limit configured, so there is nothing to extend. + return 0 + } + return base + tm.sessionExtensionTotalLocked(recipient) } // effectiveProfileID returns the profile whose history scopes daily usage // accounting, honoring launch-time pinning. Must be called without tm.mu // held. func (tm *LimitsManager) effectiveProfileID() string { - if p := tm.pinned(); p != nil { + tm.mu.Lock() + defer tm.mu.Unlock() + return tm.effectiveProfileIDLocked() +} + +// effectiveProfileIDLocked is effectiveProfileID for callers holding tm.mu. +func (tm *LimitsManager) effectiveProfileIDLocked() string { + if p := tm.pinnedLocked(); p != nil { return p.profileID } return tm.limits.ActiveProfileID() @@ -371,6 +419,11 @@ func (tm *LimitsManager) ResetSession() { log.Debug().Msg("playtime: cancelled cooldown timer (profile switched)") } + // A different person is playing, so any time granted to the previous + // session goes with it. Day waivers are scoped to a profile rather than + // a session and are kept. + tm.clearSessionExtensionLocked() + switch tm.state { case StateActive: log.Info().Msg("playtime: profile switched mid-game, restarting session tracking") @@ -394,13 +447,21 @@ func (tm *LimitsManager) ResetSession() { } } -// IsEnabled returns whether limits are currently enforced. +// IsEnabled returns the runtime enabled toggle. It reflects global config +// only; use EffectiveLimitsEnabled to ask whether limits are actually being +// enforced for whoever is playing. func (tm *LimitsManager) IsEnabled() bool { tm.enabledMu.Lock() defer tm.enabledMu.Unlock() return tm.enabled } +// EffectiveLimitsEnabled reports whether limits are enforced for the current +// session, honoring the active profile's override and the launch-time pin. +func (tm *LimitsManager) EffectiveLimitsEnabled() bool { + return tm.effectiveEnabled() +} + // isSessionActive returns true if a session is currently being tracked. func (tm *LimitsManager) isSessionActive() bool { tm.mu.Lock() @@ -606,6 +667,7 @@ func (tm *LimitsManager) cooldownTimerLoop() { tm.sessionCumulativeTime = 0 tm.lastStopTime = time.Time{} tm.cooldownTimer = nil + tm.clearSessionExtensionLocked() } tm.mu.Unlock() @@ -641,6 +703,8 @@ func (tm *LimitsManager) checkLoop(ctx context.Context) { // checkLimits evaluates all rules and handles warnings/limits. func (tm *LimitsManager) checkLimits() { + tm.pruneExpiredWaivers() + // Enforcement is decided by the effective limits: the live provider, // or the launch-pinned context after a mid-game deactivation. if !tm.effectiveEnabled() { @@ -897,7 +961,10 @@ func (tm *LimitsManager) playWarningSound() { // StatusInfo contains current playtime session and limit status. type StatusInfo struct { - SessionStarted time.Time + SessionStarted time.Time + // SessionExtendedUntil is when an active day waiver lapses, or the zero + // time when the session limit is being enforced normally. + SessionExtendedUntil time.Time DailyUsageToday *time.Duration DailyRemaining *time.Duration State string @@ -905,29 +972,38 @@ type StatusInfo struct { SessionCumulativeTime time.Duration SessionRemaining time.Duration CooldownRemaining time.Duration - SessionActive bool + // SessionExtension is the duration granted to the current session on top + // of the configured session limit. Zero when nothing was granted. + SessionExtension time.Duration + SessionActive bool } // GetStatus returns the current playtime session and limit status. // Always returns a StatusInfo struct with current state information. func (tm *LimitsManager) GetStatus() *StatusInfo { + tm.pruneExpiredWaivers() + + now := tm.clock.Now() + // Snapshot session state under lock tm.mu.Lock() sessionStart := tm.sessionStart currentState := tm.state cumulativeTime := tm.sessionCumulativeTime lastStop := tm.lastStopTime + recipient := tm.effectiveProfileIDLocked() + sessionExtension := tm.sessionExtensionTotalLocked(recipient) + waiverExpiry := tm.dayWaiverExpiryLocked(recipient, now) tm.mu.Unlock() resetTimeout := tm.cfg.SessionResetTimeout() - now := tm.clock.Now() - // State: Reset (no session exists) if currentState == StateReset { status := &StatusInfo{ - State: StateReset.String(), - SessionActive: false, + State: StateReset.String(), + SessionActive: false, + SessionExtendedUntil: waiverExpiry, } // Calculate daily usage/remaining even during reset - this data is valid @@ -984,6 +1060,8 @@ func (tm *LimitsManager) GetStatus() *StatusInfo { SessionCumulativeTime: cumulativeTime, SessionRemaining: sessionRemaining, CooldownRemaining: cooldownRemaining, + SessionExtension: sessionExtension, + SessionExtendedUntil: waiverExpiry, } // For daily usage/remaining, we need to calculate today's total usage @@ -1015,6 +1093,8 @@ func (tm *LimitsManager) GetStatus() *StatusInfo { SessionStarted: sessionStart, SessionDuration: now.Sub(sessionStart), SessionCumulativeTime: cumulativeTime, + SessionExtension: sessionExtension, + SessionExtendedUntil: waiverExpiry, } } @@ -1063,6 +1143,8 @@ func (tm *LimitsManager) GetStatus() *StatusInfo { CooldownRemaining: 0, // Not in cooldown DailyUsageToday: dailyUsageToday, DailyRemaining: dailyRemaining, + SessionExtension: sessionExtension, + SessionExtendedUntil: waiverExpiry, } } @@ -1073,6 +1155,8 @@ func (tm *LimitsManager) GetStatus() *StatusInfo { // - Remaining time < MinimumViableSession (prevents launching a game that will be immediately killed) // On success, reason is "" and error is nil. func (tm *LimitsManager) CheckBeforeLaunch() (string, error) { + tm.pruneExpiredWaivers() + // Whether limits are enforced is decided by the LimitsProvider (global // config, possibly overridden by the active profile). if !tm.limits.PlaytimeLimitsEnabled() { @@ -1080,7 +1164,11 @@ func (tm *LimitsManager) CheckBeforeLaunch() (string, error) { } dailyLimit := tm.limits.DailyLimit() - sessionLimit := tm.limits.SessionLimit() + // Use the effective session limit, not the raw configured one: a grant + // made after a limit stopped the game has to unblock the relaunch, which + // is the whole point of extending a session. Outside a running game the + // launch-time pin is nil, so this otherwise matches the live value. + sessionLimit := tm.effectiveSessionLimit() // If no limits configured, allow launch if dailyLimit == 0 && sessionLimit == 0 { diff --git a/pkg/service/queues.go b/pkg/service/queues.go index e999ff8bb..2ad4642f2 100644 --- a/pkg/service/queues.go +++ b/pkg/service/queues.go @@ -38,6 +38,7 @@ import ( "github.com/ZaparooProject/zaparoo-core/v2/pkg/readers" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playtime" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/profiles" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state" "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" "github.com/ZaparooProject/zaparoo-core/v2/pkg/zapscript" @@ -304,6 +305,12 @@ func runTokenZapScriptWithContext( } } + if result.PlaytimeExtension != nil { + if extendErr := applyPlaytimeExtension(svc, result.PlaytimeExtension, &originToken); extendErr != nil { + return extendErr + } + } + if result.Unsafe { log.Warn().Msg("token has been flagged as unsafe") token.Unsafe = true @@ -339,6 +346,101 @@ func applyProfileSwitch(svc *ServiceContext, req *platforms.ProfileSwitchRequest return nil } +// tokenForLog returns a copy of a token with any bearer credential removed, +// for log lines that print the whole token. +func tokenForLog(t *tokens.Token) tokens.Token { + safe := *t + safe.Text, safe.Data = zapscript.RedactToken(t.Text, t.Data) + return safe +} + +// cardGrantIdempotencyWindow is how long one scanned extension card counts +// as the same grant. It absorbs reader bounce without turning a deliberate +// second tap minutes later into a no-op. +const cardGrantIdempotencyWindow = 10 * time.Second + +// applyPlaytimeExtension grants extra playtime from a scanned card. The +// switch ID on the card is a bearer credential, exactly like a profile +// card: resolving it is the authorization. The difference is that a grant +// weakens somebody's limits, so it additionally requires the credential to +// belong to an administrator profile — a member card grants nothing. +// +// The recipient is never named on the card. It is whichever profile is +// governing playtime when the card is scanned, so a card cannot be aimed at +// a different person's session. +func applyPlaytimeExtension( + svc *ServiceContext, + req *platforms.PlaytimeExtensionRequest, + token *tokens.Token, +) error { + if svc.Profiles == nil { + return errors.New("profiles service not available") + } + if svc.LimitsManager == nil { + return errors.New("playtime limits not available") + } + + profile, err := svc.Profiles.VerifyBySwitchID(req.AuthorizerSwitchID) + if err != nil { + return fmt.Errorf("unknown profile switch ID: %w", err) + } + if profile.Role != profiles.ProfileRoleAdmin { + return fmt.Errorf("profile %s is not an administrator", profile.ProfileID) + } + + grant := &playtime.GrantRequest{ + Source: "reader", + AuthorizerProfileID: profile.ProfileID, + Duration: req.Duration, + } + // A tap is one grant. Reader bounce and a token briefly re-seating both + // re-fire within a second or two, so a short window collapses them, + // while a deliberate second tap later still grants again (up to the + // cumulative session cap). The key is built from the card's identity and + // what it asked for, never from the credential it carries. Without a UID + // there is nothing to tell two cards apart, so dedup is skipped rather + // than risk collapsing distinct cards into one grant. + if token != nil && token.UID != "" { + grant.IdempotencyKey = fmt.Sprintf("%s|%s|%s", token.UID, req.Mode, req.Duration) + grant.IdempotencyWindow = cardGrantIdempotencyWindow + } + switch req.Mode { + case models.PlaytimeExtendModeDuration: + grant.Mode = playtime.GrantModeDuration + case models.PlaytimeExtendModeToday: + grant.Mode = playtime.GrantModeToday + default: + return fmt.Errorf("%w: %q", playtime.ErrGrantModeInvalid, req.Mode) + } + + result, err := svc.LimitsManager.Grant(grant) + if err != nil { + return fmt.Errorf("failed to extend playtime: %w", err) + } + + if result.Replayed { + return nil + } + + payload := &models.PlaytimeExtendedParams{ + Mode: string(result.Mode), + ProfileID: result.RecipientProfileID, + GrantedBy: result.AuthorizerProfileID, + } + if result.Duration > 0 { + payload.Duration = result.Duration.String() + } + if result.SessionExtension > 0 { + payload.SessionExtension = result.SessionExtension.String() + } + if !result.ExpiresAt.IsZero() { + payload.Expires = result.ExpiresAt.Format(time.RFC3339) + } + notifications.PlaytimeExtended(svc.State.Notifications, payload) + + return nil +} + func stopNativePlaybackBeforePrimaryCommand( svc *ServiceContext, cmd gozapscript.Command, @@ -465,13 +567,16 @@ func launchPlaylistMedia( } monotonicStart := int64(systemUptime.Seconds()) + // Never store a bearer credential: history is readable by every client. + historyText, historyData := zapscript.RedactToken(t.Text, t.Data) + he := database.HistoryEntry{ ID: uuid.New().String(), Time: t.ScanTime, Type: t.Type, TokenID: t.UID, - TokenValue: t.Text, - TokenData: t.Data, + TokenValue: historyText, + TokenData: historyData, ClockReliable: helpers.IsClockReliable(now), BootUUID: svc.State.BootUUID(), MonotonicStart: monotonicStart, @@ -619,7 +724,7 @@ func processTokenQueue( continue } - log.Info().Msgf("processing token: %v", t) + log.Info().Msgf("processing token: %v", tokenForLog(&t)) err := svc.Platform.ScanHook(&t) if err != nil { @@ -634,13 +739,17 @@ func processTokenQueue( } monotonicStart := int64(systemUptime.Seconds()) + // Never store a bearer credential: history is readable by + // every client. + historyText, historyData := zapscript.RedactToken(t.Text, t.Data) + he := database.HistoryEntry{ ID: uuid.New().String(), Time: t.ScanTime, Type: t.Type, TokenID: t.UID, - TokenValue: t.Text, - TokenData: t.Data, + TokenValue: historyText, + TokenData: historyData, ClockReliable: helpers.IsClockReliable(now), BootUUID: svc.State.BootUUID(), MonotonicStart: monotonicStart, diff --git a/pkg/service/service.go b/pkg/service/service.go index 548dc56ae..6fbf8ace0 100644 --- a/pkg/service/service.go +++ b/pkg/service/service.go @@ -464,6 +464,9 @@ func startService( // and after the active profile is restored, so the session is judged // against the right profile's limits. limitsManager.RestoreSessionFromHistory(time.Now()) + // Restore granted extensions after the session, so a session-scoped + // grant is only reinstated when the session it belongs to came back. + limitsManager.RestoreExtensions(time.Now()) if limitsResolver.PlaytimeLimitsEnabled() { limitsManager.SetEnabled(true) } @@ -490,6 +493,7 @@ func startService( State: st, DB: db, Profiles: profilesSvc, + LimitsManager: limitsManager, PlaybackManager: playbackManager, UI: uiEvents, LaunchSoftwareQueue: lsq, diff --git a/pkg/zapscript/commands.go b/pkg/zapscript/commands.go index 88fee0679..b991420fd 100644 --- a/pkg/zapscript/commands.go +++ b/pkg/zapscript/commands.go @@ -122,6 +122,8 @@ func lookupCmd(name string) (cmdFunc, bool) { zapscript.ZapScriptCmdProfile: cmdProfile, zapscript.ZapScriptCmdProfileClear: cmdProfileClear, + zapscript.ZapScriptCmdPlaytimeExtend: cmdPlaytimeExtend, + zapscript.ZapScriptCmdMisterINI: forwardCmd, zapscript.ZapScriptCmdMisterCore: forwardCmd, zapscript.ZapScriptCmdMisterScript: forwardCmd, @@ -160,7 +162,9 @@ func lookupCmd(name string) (cmdFunc, bool) { // should not be included in log output. func isSensitiveCommand(cmdName string) bool { switch cmdName { - case zapscript.ZapScriptCmdHTTPGet, + case zapscript.ZapScriptCmdProfile, + zapscript.ZapScriptCmdPlaytimeExtend, + zapscript.ZapScriptCmdHTTPGet, zapscript.ZapScriptCmdHTTPPost, zapscript.ZapScriptCmdInputKeyboard, zapscript.ZapScriptCmdInputGamepad, diff --git a/pkg/zapscript/playtime.go b/pkg/zapscript/playtime.go new file mode 100644 index 000000000..9e7fc852b --- /dev/null +++ b/pkg/zapscript/playtime.go @@ -0,0 +1,110 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package zapscript + +import ( + "errors" + "fmt" + "strings" + "time" + + gozapscript "github.com/ZaparooProject/go-zapscript" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" +) + +var ( + // ErrExtendSourceNotReader is returned when an extension is attempted + // from anywhere but a physical reader. + ErrExtendSourceNotReader = errors.New("playtime extensions can only be granted by scanning a card") + // ErrExtendNotAlone is returned when an extension shares a script with + // other commands. + ErrExtendNotAlone = errors.New("playtime.extend must be the only command on a token") + // ErrExtendProfileMissing is returned when the authorizing profile + // argument is absent. + ErrExtendProfileMissing = errors.New("playtime.extend requires a profile argument") +) + +// cmdPlaytimeExtend handles **playtime.extend:?profile=. +// +// The amount is a Go duration, or "today" to waive the session limit for the +// rest of the local day. The profile argument is the switch ID of the +// authorizing profile — the same bearer credential the profile command takes +// — and names who permits the grant, never who receives it. The recipient is +// always whoever playtime is being enforced against when the card is +// scanned, so a card cannot be aimed at a particular person. +// +// This layer resolves nothing and grants nothing. It validates the shape of +// the request and hands the service layer an intent, which verifies the +// credential belongs to an administrator before applying it. +// +//nolint:gocritic // single-use parameter in command handler +func cmdPlaytimeExtend(pl platforms.Platform, env platforms.CmdEnv) (platforms.CmdResult, error) { + // A grant weakens somebody's limits, so it has to come from physical + // possession of a card. Allowing the API, hooks, playlists or remote + // sources here would turn any path that can run ZapScript into a way + // around a limit. + if env.Source != tokens.SourceReader { + return platforms.CmdResult{}, fmt.Errorf("%w: source %q", ErrExtendSourceNotReader, env.Source) + } + + // Requiring the token to carry nothing else stops a combo card from + // ordering an extension ahead of a launch to slip past the pre-launch + // limit check. + if env.TotalCommands != 1 { + return platforms.CmdResult{}, ErrExtendNotAlone + } + + if len(env.Cmd.Args) != 1 || env.Cmd.Args[0] == "" { + return platforms.CmdResult{}, ErrArgCount + } + + var args gozapscript.PlaytimeExtendArgs + if err := ParseAdvArgs(pl, &env, &args); err != nil { + return platforms.CmdResult{}, err + } + if args.Profile == "" { + return platforms.CmdResult{}, ErrExtendProfileMissing + } + + req := platforms.PlaytimeExtensionRequest{ + AuthorizerSwitchID: args.Profile, + } + + amount := env.Cmd.Args[0] + if strings.EqualFold(amount, gozapscript.PlaytimeExtendToday) { + req.Mode = models.PlaytimeExtendModeToday + } else { + // A Go duration always ends in a unit, so it can never be confused + // with the today keyword. + parsed, err := time.ParseDuration(amount) + if err != nil { + return platforms.CmdResult{}, fmt.Errorf( + "invalid extension amount %q, expected a duration like 15m or %q: %w", + amount, gozapscript.PlaytimeExtendToday, err, + ) + } + req.Mode = models.PlaytimeExtendModeDuration + req.Duration = parsed + } + + return platforms.CmdResult{PlaytimeExtension: &req}, nil +} diff --git a/pkg/zapscript/playtime_test.go b/pkg/zapscript/playtime_test.go new file mode 100644 index 000000000..c1a5afd2d --- /dev/null +++ b/pkg/zapscript/playtime_test.go @@ -0,0 +1,232 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package zapscript + +import ( + "testing" + "time" + + gozapscript "github.com/ZaparooProject/go-zapscript" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens" + "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// newExtendPlatform is the minimum platform the advanced-argument parser +// needs: it reads the launcher list to build its validation context. +func newExtendPlatform(t *testing.T) platforms.Platform { + t.Helper() + pl := mocks.NewMockPlatform() + pl.On("Launchers", mock.Anything).Return([]platforms.Launcher{}).Maybe() + return pl +} + +// extendEnv builds the command environment for a card carrying nothing but +// a playtime extension, scanned on a physical reader. +func extendEnv(amount string, advArgs map[string]string) platforms.CmdEnv { + args := []string{} + if amount != "" { + args = append(args, amount) + } + return platforms.CmdEnv{ + Cmd: gozapscript.Command{ + Name: gozapscript.ZapScriptCmdPlaytimeExtend, + Args: args, + AdvArgs: gozapscript.NewAdvArgs(advArgs), + }, + Source: tokens.SourceReader, + TotalCommands: 1, + } +} + +func TestCmdPlaytimeExtend_Duration(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + amount string + want time.Duration + }{ + {name: "minutes", amount: "15m", want: 15 * time.Minute}, + {name: "compound", amount: "1h30m", want: 90 * time.Minute}, + {name: "hours", amount: "2h", want: 2 * time.Hour}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := cmdPlaytimeExtend(newExtendPlatform(t), + extendEnv(tt.amount, map[string]string{"profile": "switch-abc"})) + require.NoError(t, err) + + require.NotNil(t, result.PlaytimeExtension) + assert.Equal(t, models.PlaytimeExtendModeDuration, result.PlaytimeExtension.Mode) + assert.Equal(t, tt.want, result.PlaytimeExtension.Duration) + assert.Equal(t, "switch-abc", result.PlaytimeExtension.AuthorizerSwitchID) + }) + } +} + +func TestCmdPlaytimeExtend_Today(t *testing.T) { + t.Parallel() + + // The keyword is matched case-insensitively; a card may be written by + // hand in any case. + for _, amount := range []string{"today", "Today", "TODAY"} { + t.Run(amount, func(t *testing.T) { + t.Parallel() + + result, err := cmdPlaytimeExtend(newExtendPlatform(t), + extendEnv(amount, map[string]string{"profile": "switch-abc"})) + require.NoError(t, err) + + require.NotNil(t, result.PlaytimeExtension) + assert.Equal(t, models.PlaytimeExtendModeToday, result.PlaytimeExtension.Mode) + assert.Equal(t, time.Duration(0), result.PlaytimeExtension.Duration) + }) + } +} + +// A grant weakens somebody's limits, so it must require physical possession +// of a card. Any other source would turn a path that can run ZapScript into +// a way around a limit. +func TestCmdPlaytimeExtend_RejectsNonReaderSources(t *testing.T) { + t.Parallel() + + sources := []string{ + tokens.SourceAPI, tokens.SourceHook, tokens.SourcePlaylist, + tokens.SourceGMC, tokens.SourceControl, tokens.SourceRemote, "", + } + + for _, source := range sources { + t.Run(source, func(t *testing.T) { + t.Parallel() + + env := extendEnv("15m", map[string]string{"profile": "switch-abc"}) + env.Source = source + + result, err := cmdPlaytimeExtend(newExtendPlatform(t), env) + require.ErrorIs(t, err, ErrExtendSourceNotReader) + assert.Nil(t, result.PlaytimeExtension) + }) + } +} + +// A combo card must not be able to order an extension ahead of a launch to +// slip past the pre-launch limit check. +func TestCmdPlaytimeExtend_RejectsMixedScript(t *testing.T) { + t.Parallel() + + env := extendEnv("15m", map[string]string{"profile": "switch-abc"}) + env.TotalCommands = 2 + + result, err := cmdPlaytimeExtend(newExtendPlatform(t), env) + require.ErrorIs(t, err, ErrExtendNotAlone) + assert.Nil(t, result.PlaytimeExtension) +} + +func TestCmdPlaytimeExtend_RejectsBadInput(t *testing.T) { + t.Parallel() + + tests := []struct { + wantErr error + advArgs map[string]string + name string + amount string + }{ + { + name: "missing amount", amount: "", + advArgs: map[string]string{"profile": "switch-abc"}, wantErr: ErrArgCount, + }, + { + name: "missing profile", amount: "15m", + advArgs: map[string]string{}, wantErr: ErrExtendProfileMissing, + }, + { + name: "empty profile", amount: "15m", + advArgs: map[string]string{"profile": ""}, wantErr: ErrExtendProfileMissing, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := cmdPlaytimeExtend(newExtendPlatform(t), extendEnv(tt.amount, tt.advArgs)) + require.ErrorIs(t, err, tt.wantErr) + assert.Nil(t, result.PlaytimeExtension) + }) + } +} + +func TestCmdPlaytimeExtend_RejectsUnparseableAmount(t *testing.T) { + t.Parallel() + + for _, amount := range []string{"soon", "15", "tomorrow", "-", "15 minutes"} { + t.Run(amount, func(t *testing.T) { + t.Parallel() + + result, err := cmdPlaytimeExtend(newExtendPlatform(t), + extendEnv(amount, map[string]string{"profile": "switch-abc"})) + require.Error(t, err) + assert.Nil(t, result.PlaytimeExtension) + }) + } +} + +func TestCmdPlaytimeExtend_RejectsUnknownAdvArg(t *testing.T) { + t.Parallel() + + result, err := cmdPlaytimeExtend(newExtendPlatform(t), extendEnv("15m", map[string]string{ + "profile": "switch-abc", + "pin": "1234", + })) + require.Error(t, err, "an unknown argument must not be silently ignored") + assert.Nil(t, result.PlaytimeExtension) +} + +func TestCmdPlaytimeExtend_IsSensitive(t *testing.T) { + t.Parallel() + + // Both commands carry a profile switch ID and must stay out of logs. + assert.True(t, isSensitiveCommand(gozapscript.ZapScriptCmdPlaytimeExtend)) + assert.True(t, isSensitiveCommand(gozapscript.ZapScriptCmdProfile)) + assert.False(t, isSensitiveCommand(gozapscript.ZapScriptCmdLaunch)) +} + +func TestCmdPlaytimeExtend_IsRegistered(t *testing.T) { + t.Parallel() + + assert.True(t, IsValidCommand(gozapscript.ZapScriptCmdPlaytimeExtend)) +} + +// An extension changes no media, so it must not be treated as a launch or +// staged behind the launch guard. +func TestCmdPlaytimeExtend_DoesNotDisruptMedia(t *testing.T) { + t.Parallel() + + assert.False(t, IsMediaLaunchingCommand(gozapscript.ZapScriptCmdPlaytimeExtend)) + assert.False(t, IsMediaDisruptingCommand(gozapscript.ZapScriptCmdPlaytimeExtend)) +} diff --git a/pkg/zapscript/redact.go b/pkg/zapscript/redact.go new file mode 100644 index 000000000..7e9f7ef85 --- /dev/null +++ b/pkg/zapscript/redact.go @@ -0,0 +1,149 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package zapscript + +import ( + "strings" + + gozapscript "github.com/ZaparooProject/go-zapscript" +) + +// RedactedPlaceholder replaces a bearer credential in text that is logged, +// stored, or returned to API clients. +const RedactedPlaceholder = "[redacted]" + +// redactedScript replaces an entire script whose credentials could not be +// isolated. Losing the readable text is preferable to leaking a credential. +// It deliberately carries no command prefix: this value is stored and +// displayed, never executed, and should not read as a runnable command. +const redactedScript = "[redacted script]" + +// scriptCredentials returns the bearer credential values carried by a parsed +// script. Both commands that take a profile switch ID are covered: the +// profile card's positional argument, and the profile argument authorizing a +// playtime extension. +// +// Values already replaced by RedactedPlaceholder are skipped, so redacting +// text twice is a no-op and the verification pass below does not reject its +// own output. +func scriptCredentials(script *gozapscript.Script) []string { + var found []string + for i := range script.Cmds { + cmd := &script.Cmds[i] + var value string + switch cmd.Name { + case gozapscript.ZapScriptCmdProfile: + if len(cmd.Args) > 0 { + value = cmd.Args[0] + } + case gozapscript.ZapScriptCmdPlaytimeExtend: + value = cmd.AdvArgs.Get(gozapscript.KeyProfile) + } + if value != "" && value != RedactedPlaceholder { + found = append(found, value) + } + } + return found +} + +// hasCredentialCommand reports whether any command in the script is one that +// carries a bearer credential, whether or not its value has already been +// replaced. +func hasCredentialCommand(script *gozapscript.Script) bool { + for i := range script.Cmds { + switch script.Cmds[i].Name { + case gozapscript.ZapScriptCmdProfile, gozapscript.ZapScriptCmdPlaytimeExtend: + return true + } + } + return false +} + +// HasSensitiveScript reports whether text involves a bearer credential, so +// callers can also drop adjacent raw copies such as a token's data payload. +// +// This keys off the command rather than the value: a token whose text has +// already been redacted may still have an unredacted raw payload beside it. +// Text that cannot be parsed is treated as sensitive, since an unreadable +// script cannot be shown to be free of credentials. +func HasSensitiveScript(text string) bool { + if strings.TrimSpace(text) == "" { + return false + } + script, err := gozapscript.NewParser(text).ParseScript() + if err != nil { + return true + } + return hasCredentialCommand(&script) +} + +// RedactScript removes bearer credentials from ZapScript text while leaving +// everything else readable, so logs and history stay useful for diagnosis. +// A profile card keeps its command name; an extension card additionally +// keeps its amount, which is the part worth auditing. +// +// Credential values are replaced in the original text rather than the script +// being re-rendered from the parse tree, so traits, spacing and any command +// this function does not know about survive untouched. +// +// It fails closed. Text that cannot be parsed, or whose credentials survive +// the replacement, is replaced wholesale. +func RedactScript(text string) string { + if strings.TrimSpace(text) == "" { + return text + } + + script, err := gozapscript.NewParser(text).ParseScript() + if err != nil { + // An unparseable script cannot be shown to be free of credentials. + return redactedScript + } + + credentials := scriptCredentials(&script) + if len(credentials) == 0 { + return text + } + + redacted := text + for _, credential := range credentials { + redacted = strings.ReplaceAll(redacted, credential, RedactedPlaceholder) + } + + // A quoted or escaped credential does not appear literally in the + // source, so the replacement above can miss it. Re-parse and confirm + // nothing sensitive survived rather than trusting the substitution. + verified, err := gozapscript.NewParser(redacted).ParseScript() + if err != nil || len(scriptCredentials(&verified)) > 0 { + return redactedScript + } + + return redacted +} + +// RedactToken returns a copy of a token safe to log, store, or return to API +// clients. The raw data payload is dropped entirely for sensitive tokens: it +// is an unparsed copy of the same content, so it cannot be redacted in place. +func RedactToken(text, data string) (redactedText, redactedData string) { + redactedText = RedactScript(text) + if HasSensitiveScript(text) { + return redactedText, "" + } + return redactedText, data +} diff --git a/pkg/zapscript/redact_fuzz_test.go b/pkg/zapscript/redact_fuzz_test.go new file mode 100644 index 000000000..ca1f27849 --- /dev/null +++ b/pkg/zapscript/redact_fuzz_test.go @@ -0,0 +1,75 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package zapscript + +import ( + "strings" + "testing" + + gozapscript "github.com/ZaparooProject/go-zapscript" +) + +// FuzzRedactScript checks the invariant that matters for a security +// boundary: whatever untrusted token text arrives, no credential survives +// redaction. Token text comes from an NFC tag, so it is entirely attacker +// controlled and may be malformed in ways the parser has to survive. +func FuzzRedactScript(f *testing.F) { + seeds := []string{ + "**profile:sw-secret", + "**playtime.extend:15m?profile=sw-secret", + "**playtime.extend:today?profile=sw-secret", + "**profile:sw-secret||**launch:/games/snes/mario.sfc", + `**profile:"sw secret"`, + `**profile:"unterminated`, + "**launch:/games/snes/mario.sfc", + "**profile:", + "**PROFILE:sw-secret", + "plain text", + "", + } + for _, seed := range seeds { + f.Add(seed) + } + + f.Fuzz(func(t *testing.T, text string) { + redacted := RedactScript(text) + + // The fail-closed path replaces the script wholesale, so there is + // nothing left to inspect. + if redacted != redactedScript && strings.TrimSpace(redacted) != "" { + // Check the credential position rather than searching for the + // value as a substring: a short credential can legitimately + // occur inside the placeholder itself. + verified, err := gozapscript.NewParser(redacted).ParseScript() + if err == nil { + if survived := scriptCredentials(&verified); len(survived) > 0 { + t.Fatalf("credentials %q survived redaction of %q -> %q", + survived, text, redacted) + } + } + } + + // History is redacted both when written and when served, so + // redacting twice must not degrade the text further. + if again := RedactScript(redacted); again != redacted { + t.Fatalf("redaction is not idempotent: %q -> %q -> %q", text, redacted, again) + } + }) +} diff --git a/pkg/zapscript/redact_test.go b/pkg/zapscript/redact_test.go new file mode 100644 index 000000000..6664a82c8 --- /dev/null +++ b/pkg/zapscript/redact_test.go @@ -0,0 +1,188 @@ +// Zaparoo Core +// Copyright (c) 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Zaparoo Core. +// +// Zaparoo Core is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Zaparoo Core is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Zaparoo Core. If not, see . + +package zapscript + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testSwitchID = "sw-7f3a9c21" + +func TestRedactScript_RemovesCredentials(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + // keep lists substrings that must survive, so redaction stays + // useful for diagnosis rather than blanking everything. + keep []string + }{ + { + name: "profile card", + input: "**profile:" + testSwitchID, + keep: []string{"profile"}, + }, + { + name: "extension card keeps its amount", + input: "**playtime.extend:15m?profile=" + testSwitchID, + keep: []string{"playtime.extend", "15m"}, + }, + { + name: "today extension", + input: "**playtime.extend:today?profile=" + testSwitchID, + keep: []string{"playtime.extend", "today"}, + }, + { + name: "credential removed from a multi-command script", + input: "**profile:" + testSwitchID + "||**launch:/games/snes/mario.sfc", + keep: []string{"launch", "/games/snes/mario.sfc"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := RedactScript(tt.input) + + assert.NotContains(t, got, testSwitchID, "the credential must not survive") + assert.Contains(t, got, RedactedPlaceholder) + for _, keep := range tt.keep { + assert.Contains(t, got, keep, "non-sensitive content should stay readable") + } + }) + } +} + +func TestRedactScript_LeavesOrdinaryScriptsAlone(t *testing.T) { + t.Parallel() + + inputs := []string{ + "**launch:/games/snes/mario.sfc", + "**launch.random:snes", + "**playlist.play||**delay:500", + "/games/snes/mario.sfc", + "", + } + + for _, input := range inputs { + t.Run(input, func(t *testing.T) { + t.Parallel() + assert.Equal(t, input, RedactScript(input), + "a script with no credential should be returned untouched") + }) + } +} + +// Text that cannot be parsed cannot be shown to be free of credentials, so +// it is dropped rather than passed through. +func TestRedactScript_FailsClosedOnUnparseableText(t *testing.T) { + t.Parallel() + + malformed := `**profile:"` + testSwitchID + + got := RedactScript(malformed) + assert.NotContains(t, got, testSwitchID) + assert.Equal(t, redactedScript, got) +} + +// A quoted credential does not appear literally in the source, so the +// substitution can miss it. The verification pass has to catch that. +func TestRedactScript_HandlesQuotedCredential(t *testing.T) { + t.Parallel() + + quoted := `**profile:"sw with spaces"` + + got := RedactScript(quoted) + assert.NotContains(t, got, "sw with spaces") +} + +func TestHasSensitiveScript(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want bool + }{ + {name: "profile card", input: "**profile:" + testSwitchID, want: true}, + { + name: "extension card", + input: "**playtime.extend:15m?profile=" + testSwitchID, + want: true, + }, + {name: "launch", input: "**launch:/games/snes/mario.sfc", want: false}, + {name: "plain text", input: "just some text", want: false}, + {name: "empty", input: "", want: false}, + {name: "malformed fails closed", input: `**profile:"unterminated`, want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, HasSensitiveScript(tt.input)) + }) + } +} + +// The raw data payload is an unparsed copy of the same content, so it cannot +// be redacted in place and has to be dropped entirely. +func TestRedactToken_DropsDataForSensitiveTokens(t *testing.T) { + t.Parallel() + + text, data := RedactToken("**profile:"+testSwitchID, "raw-ndef-bytes") + assert.NotContains(t, text, testSwitchID) + assert.Empty(t, data) + + text, data = RedactToken("**launch:/games/snes/mario.sfc", "raw-ndef-bytes") + assert.Equal(t, "**launch:/games/snes/mario.sfc", text) + assert.Equal(t, "raw-ndef-bytes", data, "an ordinary token keeps its payload") +} + +// The redacted form must stay valid ZapScript so anything that re-parses +// stored history does not start failing. +func TestRedactScript_OutputStaysParseable(t *testing.T) { + t.Parallel() + + inputs := []string{ + "**profile:" + testSwitchID, + "**playtime.extend:15m?profile=" + testSwitchID, + "**profile:" + testSwitchID + "||**launch:/games/snes/mario.sfc", + } + + for _, input := range inputs { + t.Run(input, func(t *testing.T) { + t.Parallel() + + redacted := RedactScript(input) + require.NotEmpty(t, strings.TrimSpace(redacted)) + + // Re-running redaction must be stable, since history is redacted + // both when written and when served. + assert.Equal(t, redacted, RedactScript(redacted), + "redaction should be idempotent") + }) + } +} diff --git a/pkg/zapscript/testdata/fuzz/FuzzRedactScript/3e9d7045f81ff1c9 b/pkg/zapscript/testdata/fuzz/FuzzRedactScript/3e9d7045f81ff1c9 new file mode 100644 index 000000000..a599d3f50 --- /dev/null +++ b/pkg/zapscript/testdata/fuzz/FuzzRedactScript/3e9d7045f81ff1c9 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("**PROFILE:a") From 260256edf43aec28ab4295cab7af8f57801ea6a7 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sun, 30 Aug 2026 10:47:52 +0800 Subject: [PATCH 2/2] fix(playtime): count ZapLink expansion when enforcing a solo extension playtime.extend refuses to run alongside other commands so a combo card cannot order a grant ahead of a launch. A ZapLink carries one command on the card and resolves it into a whole script, and the expanded commands were queued without being counted, so a link resolving to an extension followed by a launch passed the check and weakened the limit the launch was about to be measured against. A repeated today waiver also reported Replayed false even though it grants no new time, so it published a second playtime.extended notification that the documented contract says must not be sent. Scope the session-reset wording to duration grants: a today waiver is day-scoped and survives a profile change, cooldown expiry and disabling limits. --- docs/api/methods.md | 2 +- pkg/service/playtime/extensions.go | 8 ++++-- pkg/service/playtime/extensions_test.go | 5 ++++ pkg/zapscript/commands.go | 13 ++++++---- pkg/zapscript/commands_test.go | 33 +++++++++++++++++++++++++ 5 files changed, 53 insertions(+), 8 deletions(-) diff --git a/docs/api/methods.md b/docs/api/methods.md index ab945d6c9..a957910ae 100644 --- a/docs/api/methods.md +++ b/docs/api/methods.md @@ -3687,7 +3687,7 @@ None. Grant extra time to the playtime session currently being limited, without stopping what is playing and without changing any configured limit. -The recipient is never named by the caller: a grant always applies to the profile playtime is being enforced against at that moment, so it cannot be aimed at someone else's session. Grants are held against the current session only and are cleared when that session resets — when a different profile becomes active, when the cooldown window expires, or when limits are disabled. +The recipient is never named by the caller: a grant always applies to the profile playtime is being enforced against at that moment, so it cannot be aimed at someone else's session. A `duration` grant is held against the current session only and is cleared when that session resets — when a different profile becomes active, when the cooldown window expires, or when limits are disabled. A `today` waiver survives all three because it is day-scoped: it lapses at the next local midnight and nowhere else. **The daily limit is never affected.** It remains the hard ceiling in both modes; raising it is a settings change, not a grant. diff --git a/pkg/service/playtime/extensions.go b/pkg/service/playtime/extensions.go index c10def688..57668f0db 100644 --- a/pkg/service/playtime/extensions.go +++ b/pkg/service/playtime/extensions.go @@ -119,8 +119,9 @@ type GrantResult struct { // SessionExtension is the session's accumulated duration extension after // this grant. SessionExtension time.Duration - // Replayed is true when a matching idempotency key had already been - // applied and no new time was granted. + // Replayed is true when the request granted no new time, either because a + // matching idempotency key had already been applied or because the day + // was already waived. Replayed bool } @@ -263,6 +264,9 @@ func (tm *LimitsManager) applyGrant(req *GrantRequest, now time.Time) (GrantResu if existing, ok := nextWaivers[recipient]; ok && existing.expires.After(now) { result.ExpiresAt = existing.expires result.SessionExtension = tm.sessionExtensionTotalLocked(recipient) + // Nothing new was granted, so this must not read as a fresh grant + // to notification subscribers. + result.Replayed = true tm.recordGrantLocked(req, &result, now) return result, nil } diff --git a/pkg/service/playtime/extensions_test.go b/pkg/service/playtime/extensions_test.go index 5e76ba5a7..a876fa76f 100644 --- a/pkg/service/playtime/extensions_test.go +++ b/pkg/service/playtime/extensions_test.go @@ -299,6 +299,11 @@ func TestGrant_TodayRepeatKeepsSameBoundary(t *testing.T) { assert.Equal(t, first.ExpiresAt, second.ExpiresAt, "rescanning must not roll the waiver into another day") + assert.False(t, first.Replayed) + // No new time was granted, so callers must not publish another + // playtime.extended notification for it. + assert.True(t, second.Replayed, + "a repeat waiver granted nothing and must report as replayed") } func TestGrant_TodayExpiresAtMidnight(t *testing.T) { diff --git a/pkg/zapscript/commands.go b/pkg/zapscript/commands.go index b991420fd..227da4292 100644 --- a/pkg/zapscript/commands.go +++ b/pkg/zapscript/commands.go @@ -529,11 +529,14 @@ func RunCommand( Playlist: plsc, Source: token.Source, PathRoot: token.PathRoot, - TotalCommands: totalCmds, - CurrentIndex: currentIndex, - Unsafe: unsafe, - Database: db, - ExprEnv: exprEnv, + // A ZapLink resolves one card command into a whole script, so the + // count on the card understates what this token runs. Commands that + // insist on running alone have to see the expanded total. + TotalCommands: totalCmds + len(newCmds), + CurrentIndex: currentIndex, + Unsafe: unsafe, + Database: db, + ExprEnv: exprEnv, } if opts.LauncherManager != nil { diff --git a/pkg/zapscript/commands_test.go b/pkg/zapscript/commands_test.go index 965c6f979..f0c7f655b 100644 --- a/pkg/zapscript/commands_test.go +++ b/pkg/zapscript/commands_test.go @@ -900,6 +900,39 @@ func TestRunCommandSkipsZapLinkForRemoteSource(t *testing.T) { mockUserDB.AssertNotCalled(t, "GetZapLinkHost", mock.Anything) } +// TestRunCommandCountsZapLinkExpansionInTotalCommands pins that a command +// which insists on being alone on the token sees the expanded script, not the +// single command printed on the card. A ZapLink resolving to +// "playtime.extend || launch" would otherwise slip a grant in ahead of the +// launch and past the pre-launch limit check. +func TestRunCommandCountsZapLinkExpansionInTotalCommands(t *testing.T) { + t.Parallel() + + const linkURL = "https://zaplink.example.com/extend-then-launch" + mockUserDB := &testhelpers.MockUserDBI{} + mockUserDB.On("GetZapLinkHost", "https://zaplink.example.com").Return(true, true, nil) + mockUserDB.On("GetZapLinkCache", linkURL). + Return("**playtime.extend:1h?profile=switch-abc||**launch:/games/game.rom", nil) + mockUserDB.On("UpdateZapLinkCache", mock.Anything, mock.Anything).Return(nil).Maybe() + db := &database.Database{UserDB: mockUserDB} + + _, err := RunCommand( + t.Context(), + mocks.NewMockPlatform(), + &config.Instance{}, + playlists.PlaylistController{}, + tokens.Token{Source: tokens.SourceReader}, + zapscript.Command{Name: "launch", Args: []string{linkURL}}, + 1, + 0, + db, + RunCommandOptions{}, + &zapscript.ArgExprEnv{}, + ) + + require.ErrorIs(t, err, ErrExtendNotAlone) +} + // TestRunCommandAppliesZapLinkForNonRemoteSource pins that every other // token source still goes through ZapLink resolution as before. The // remote-source skip in RunCommand must not become a blanket skip.