Skip to content

security: the owner's four decisions — server-owned needs, chat needs a character, share codes billed in the room, gives ask first - #101

Merged
dop42 merged 6 commits into
mainfrom
fix/security-decisions
Oct 5, 2026
Merged

dop42 merged 6 commits into
mainfrom
fix/security-decisions

Conversation

@dop42

@dop42 dop42 commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

This implements the owner's four decisions on the questions from #99, one commit per decision. lua tests/run.lua passes with 5143 checks. luac -p and the sandbox-globals check are clean, the manifest is unchanged, and every new message has an en and fr version.

1. Needs are server-authoritative

  • Before: the client decayed hunger and thirst itself and pushed the values to the server (opx:net:needs:push). The server stored whatever came in after bounding it, so a client that always pushed 100 was never hungry.
  • After:
    • The server decays every need on its own clock (DECAY_MS, each need's DECAY_PER_MINUTE) while the character is loaded. Values load when the character module announces the character on the server, without waiting for the client to ask.
    • A need goes up only through the server needs contract. Three paths use it:
      • an item whose USE.STATUS is consumed (the inventory adds it after taking the unit);
      • the new /opx.needs.set <player> <need> <value> command (ACL command.opx.needs.set);
      • the new creator exports GetNeeds, plus AddNeeds / SetNeeds (both gated by WRITERS).
    • The push event and its acknowledgement are removed. The client asks for its values once and displays every values event it receives; its contract is now read-only (GetNeeds).
    • Every change raises opx:on:needs:changed with its reason (loaded, decay, use, staff, ext:<caller>). This happens on the server bus too, so the HUD and subscribers keep working unchanged.
    • The header comment that recorded the old "client owns the values" ruling now records the new one.
    • PUSH_MS and PUSH_DELTA are removed from the config.
    • Nothing in the runtime downs or kills a player whose needs run out, and none is added.

2. Global chat requires a loaded character

  • Before: any admitted connection could broadcast to everyone, including one still on the character screen.
  • After: a connection with no loaded character is refused with chat.notLoaded (en+fr), with a cooldown so a flood earns one toast.
    • Staff can still speak: STAFF_PERMISSION in config/chat.lua, command.opx.admin by default.
    • The console speaks through the chat contract and never goes through this check.
    • The existing tests that assumed unloaded players could speak are updated.

3. Share codes are redeemed in a shop's fitting room and billed at its prices

4. Giving an item asks the receiver to accept

  • Before: the stack moved as soon as the giver pressed give.
  • After: pressing give creates an offer.
    • The receiver sees a menu card with the item, the count, and where the giver stands, in the ux: clearer menus — give list says where a person stands, never their name #91 wording (To your left · 1.2 m). It never shows a name. The receiver picks Accept or Refuse; closing the card counts as a refusal.
    • The server holds a token for 15 s. A sweep withdraws unanswered offers and tells the giver.
    • On Accept, the server checks again that:
      • both players can act;
      • they are still in reach;
      • the same stack is still in the giver's slot;
      • the receiver has room.
    • Rate limits: one offer out per giver, one in per receiver, 3 s between offers from the same giver. The answer counts against the inventory request rate limit.
    • Only the intended receiver can answer a token, and replaying an answer moves nothing.

Also

core/client/ui.lua now yields between copying the locale catalogue and building its key list. Without this, the ~50 new strings pushed one resume of the client boot over its budget test (4,684 instructions against a 4,500 limit). With the yield, each resume handles at most one part, whatever the catalogue's size.

The #99 fuzz battery still passes: 85 net events (the needs push is gone, the give answer is new). It also covers the new inventory:giveAnswer event automatically.

README: new section "Four rules the server keeps", plus the needs exports and the server opx:on:needs:changed event. Config comments in config/needs.lua, config/chat.lua and config/shops.lua are updated.

🤖 Generated with Claude Code

dop42 and others added 6 commits October 5, 2026 15:35
The owner reversed the ruling the server half's header recorded ("the client
owns the values during play ... the guards are ownership and bounds rather
than simulation"). Under it a client decayed its own hunger and thirst and
pushed them every two minutes, so a client that pushed 100 was never hungry
and nothing could tell.

Now the server decays every need on its own clock (DECAY_MS, each need's
DECAY_PER_MINUTE) while the character is loaded -- loaded on the server's own
`character:loaded`, not on a client's ask -- saves on autosave, unload and
departure, and sends `values` (citizenId, values, reason) every time they
move. A need goes up only through the server contract (`GetNeeds`, `SetNeeds`,
`AddNeeds`): an item consumed (the inventory side is in the inventory commit),
the new ACL-gated `/opx.needs.set <player> <need> <value>`, or the creator
exports `GetNeeds` / `AddNeeds` / `SetNeeds` (WRITERS for the writes). Every
move is published on the server bus as `opx:on:needs:changed` with the reason.

The push event, its acknowledgement and the client's decay are gone; the
client contract is read-only (`GetNeeds`) and its `changed` event now carries
the server's reason, so the HUD and subscribers keep working unchanged.
`PUSH_MS` and `PUSH_DELTA` leave the config. There is no starvation in this
runtime, and none is added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every line goes to every client, and it went out for any slot the host had
admitted -- one on the character screen, or one whose entry failed and left
it behind the gate. The owner's ruling: global chat requires a loaded
character. Such a slot is refused `chat.notLoaded` (en + fr), cooled so a
flood earns one toast. Staff still may (`STAFF_PERMISSION` in config/chat.lua,
`command.opx.admin` by default): they are who a player stuck on that screen
needs to reach. The console speaks through the `chat` contract and is never
asked. With no character contract the chat keeps working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s prices

A redeemed code put somebody else's saved look on the player through a free
look grant, from anywhere: the share code was a way round every counter in the
city. The owner's ruling: a code is redeemed only inside a shop's fitting
room, and its pieces are billed at that shop's prices.

The room is now opened under the shop's own name (`shops:<key>`), and
`appearance` answers which priced room a player holds (`PricedRoom`). A redeem
must name the shop; the server checks the player stands at its counter
(`shopAt`: reach, bucket, job gate, not downed) with that shop's room open,
and only then puts the look on -- with no grant of its own. The save that
leaves the room is admitted and billed by the room's charge, slot by slot, at
the shop's `PRICES`, through the server-side billing every rack piece goes
through (#75/#96). Cancel costs nothing and keeps nothing. Refusals
`shops.redeemAtShop` and `shops.redeemInRoom` are en + fr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A GIVE IS AN OFFER. It moved the stack on the giver's press alone, so anybody
in reach could fill a stranger's bag. Now the press raises an offer: the
receiver is sent the item, the count and where the giver stands -- side and
distance from the receiver's facing, the #91 wording, never a name -- and
answers Accept or Refuse on a menu card (closing it is a no). The server holds
the token for fifteen seconds; a sweep withdraws an unanswered one from the
receiver's screen and tells the giver. On a yes it checks again that both can
act, that they are in reach, that the same stack is still in the giver's slot
and that the receiver has room, and only then moves it. One offer out per
giver, one in per receiver, three seconds between two offers from one giver;
the answer spends the request window. Only the receiver it was made to may
answer a token, and a replay finds nothing. en + fr for every new line.

AN ITEM'S STATUS IS ADDED ON THE SERVER, after the unit is consumed, through
the needs contract; the client no longer adds it to a copy it pushes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`CatalogueSliced` yields every CATALOGUE_PART keys and so does the key list
after it, but the copy's last stretch and the list's first shared one resume:
up to two parts' worth, depending on how many keys the catalogues hold. The
fifty strings these decisions add moved that remainder past the boot budget
check (4,684 against 4,500). One `Wait(0)` between the two keeps every resume
to one part, whatever the catalogue's size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- needs: the server loads, decays, raises and sends; there is no push door, a
  client claiming to be full is not believed, staff and creator writes land,
  departure saves the server's values, and the client is read-only and
  publishes the server's reason. Replaces "the last needs push of a
  disconnect", which tested a push that no longer exists.
- chat: no character, no line, and told why; staff without a character still
  speak, signed with the server id; the trimmed-config test now speaks as a
  loaded character.
- give: an offer moves nothing, carries no name, is rate-limited, answered
  only by its receiver, refused out of reach or with no room, withdrawn after
  fifteen seconds, and a replayed yes moves nothing more. The two existing
  give tests accept the offer first.
- share codes: refused away from a counter, at a counter with no room, and in
  another shop's room; inside the shop's own room the look goes on with no
  free grant. The guessing-window test stands in a shop's room.

README gains "Four rules the server keeps" and the needs exports and event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dop42
dop42 merged commit 871cf5d into main Oct 5, 2026
1 check passed
@dop42
dop42 deleted the fix/security-decisions branch October 5, 2026 13:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant