@typetorch/framework 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -152,8 +152,18 @@ export class CoinService extends Module implements OnStart {
152
152
  - **Dev menu:** devs (Studio, project members, dev badge) get a DEV button, `Ctrl+Shift+D` and `/tt dev`: artifact
153
153
  (with how long the server and client generations have run, the server's uptime and the build's age), server status,
154
154
  logs (server, own client, other players' clients), client and server dex, network stats, module state, a branch and
155
- build picker (Server > Branch) and a Claude prompt. Prod-channel servers are read-only. The window can be dragged by
156
- its header and resized from its corner (double-tap the header to reset).
155
+ build picker (Server > Branch) and a Claude prompt. The sidebar, top to bottom: Artifact, Server, Network, Modules,
156
+ Manage, Logs, Dex, Claude. The window can be dragged by its header and resized from its corner (double-tap the
157
+ header to reset).
158
+ - **Server TPS (framework 0.4.2, kernel 0.4.2):** Server > Status has a *TPS* line next to Memory and Lua heap: the
159
+ last minute's average, its slowest second and the physics FPS (`59.9 avg, 52.0 min, physics 60`; the warning colour
160
+ under 50 on average). Older kernels show `needs kernel 0.4.2`. The same numbers ride on fleet heartbeats (the
161
+ explorer's fleet table).
162
+ - **Dev-only tools (framework 0.4.1, `devtools/access.ts`):** Claude, Logs > Upload, Dex edits on the server, Network
163
+ packet blocking and the hot-swap sound work under dev rules (private, reserved and Studio servers on a dev branch)
164
+ for every dev, and on a public server an owner switched to a dev branch (Server > Branch) for owners only. Everywhere
165
+ else the menu is read-only, with a short reason: "Dev branch only", "Owners only on public servers", "Owner-switched
166
+ servers only". The rules themselves don't change (`TypeTorch.channel`, signatures, owner-only rollbacks).
157
167
  - **Panes and windows (dev menu):** every page shows in a pane with a small header: tap the title to pick another
158
168
  page, or use Split right, Split down, Open in new window and Close. Drag the line between two panes to resize them.
159
169
  The sidebar opens pages in the focused pane (the highlighted one; tap a pane to focus it). Floating windows move by
@@ -171,8 +181,8 @@ export class CoinService extends Module implements OnStart {
171
181
  strings (cut), numbers, booleans, nil, datatypes through `tostring`, an Instance as its full path, a Player as its
172
182
  name, `function`, `thread`, buffer sizes. Nothing is ever called (raw reads only: no metamethods, no getters); a
173
183
  value that is its own ancestor shows as a cycle. A key filter (open nodes stay listed), Refresh and Auto (every 2 s).
174
- Server state comes from the op `state.inspect` (rate-limited, size-capped): devs on dev-channel servers, owners only
175
- on prod-effective servers, since server state can hold player data.
184
+ Server state comes from the op `state.inspect` (rate-limited, size-capped): owners everywhere, other devs where the
185
+ dev-only tools are open to them, since server state can hold player data.
176
186
  - **Logs > Upload:** sends the logs shown (Server: this server's log ring; Client: your own client's; Others: the
177
187
  picked player's client) to the dev PC paired in the Claude tab, which saves them under `<repo>/.typetorch/logs/`.
178
188
  No Claude run, no prompt quota. Not paired: "Pair in the Claude tab first".
@@ -182,11 +192,12 @@ export class CoinService extends Module implements OnStart {
182
192
  verified head). A public switch lasts for that server's lifetime and is never stored, so new servers still boot the
183
193
  signed prod head. Others see "Join", which moves only them to a reserved server. Manage > Servers "Load a build..."
184
194
  does the same for this server, the ticked servers or a share of one branch (A/B pins), with one status line under
185
- the list.
195
+ the list. A public server an owner switched to a dev branch opens the dev-only tools (Claude too) to owners.
186
196
  - **Owners and devs:** two roles (no admins: an older kernel's or typetorch.json's "admin" counts as a dev). The Manage
187
197
  group (Players, Servers, Bans) is for owners only.
188
- - **Remote Claude** (dev-channel servers only): while `typetorch remote-claude` (`@typetorch/dev-server`) runs on a dev's machine, allowlisted
189
- devs prompt Claude Code from the Claude tab. Each dev pairs once by pasting the pairing code printed by
198
+ - **Remote Claude** (a dev-only tool: dev rules, or owners on a public server an owner switched to a dev branch):
199
+ while `typetorch remote-claude` (`@typetorch/dev-server`) runs on a dev's machine, allowlisted devs prompt Claude
200
+ Code from the Claude tab. Each dev pairs once by pasting the pairing code printed by
190
201
  typetorch-dev-server; the game server keeps the session URL and the tokens in memory and never sends them to clients.
191
202
  No Roblox secret is needed.
192
203
 
@@ -26,7 +26,7 @@ Integers are written with every digit (never exponent notation).
26
26
  | `seq` | int64 | That artifact's deploy seq (`0` when unknown) |
27
27
  | `branch` | string | Server branch |
28
28
  | `channel` | string | Effective channel: `prod` or `dev` |
29
- | `dev` | string | `desktop`, `phone`, `tablet`, `console`, `vr`, `unknown` (from the client's hello; `unknown` for server-only events and until the client reports) |
29
+ | `dev` | string | The session's device class: `desktop`, `phone`, `tablet`, `console`, `vr`, `unknown`. Set once per session from the client's first hello (`unknown` for server-only events and until the client reports; frameworks after 0.4.2 keep it when a later hello says otherwise). How it is decided: the `session` / `device` row below |
30
30
  | `newp` | bool | The event belongs to the player's first-ever session |
31
31
  | `state` | string | The player's state **after** this event: `zone:<z>\|screen:<s>\|activity:<a>`, empty parts left out (`""` when nothing is known). Server-only events: `activity:<server activity>` or `""` |
32
32
  | `exp` | string | JSON object of the player's active experiments, `{"onboarding":"short"}`; `{}` when none |
@@ -58,7 +58,7 @@ First-ever-session detail, one row per packed chunk.
58
58
  | kind | name | props |
59
59
  |---|---|---|
60
60
  | session | `join` | `from`: `direct` (Home, search, sorts: Roblox doesn't say), `teleport` (same game), `teleport_game` (another game), `referral` (invite/referral link), `share` (launch data), `follow` (followed someone); `ctx?` (GameJoinContext.JoinSource name); `tp?` (had teleport data); `party?` (players teleported along); `age` (account age: `<1d`, `1-7d`, `7-30d`, `30-90d`, `90-365d`, `1-3y`, `3y+`); `prem` (Premium); `country?` (ISO code); `friends` (how many of the player's friends are in the server: one friend-list lookup per join, at most 10 pages, `0` when it fails); `ret` (days since the last visit, `-1` first visit or unknown); `late?` (the engine started over 60 s after the join). `t` is the join time |
61
- | session | `device` (src client) | `input` (`kbm`, `touch`, `gamepad`, `vr`, `unknown`), `w`, `h` (viewport points), `touch`, `kb`, `mouse`, `pad`, `vr`. Once per session |
61
+ | session | `device` (src client) | `dev?` (the device class, as the `dev` column; frameworks after 0.4.2, older ones leave it out), `input` (`kbm`, `touch`, `gamepad`, `vr`, `unknown`), `w`, `h` (viewport points), `touch`, `kb`, `mouse`, `pad`, `vr`. Once per session. The class (Roblox gives game scripts no OS): `vr` when VRService.VREnabled; `console` when GuiService:IsTenFootInterface(); touch without a keyboard: `tablet` when the viewport's short side is 600 points or more, else `phone` (the first hello waits up to 2 s for a real viewport); a keyboard or mouse: `desktop` (touch laptops, tablets with a keyboard); else `unknown` |
62
62
  | session | `leave` | `secs` (session length), `why`: `left`, `shutdown`, `gone` (left while no generation ran the engine), `swap60?` (left within 60 s of a hot swap) |
63
63
  | tech | `server` | Every `techEvery` s: `fps` (physics), `hb` (Heartbeat rate), `mem` (MB, 10s), `players`, `ping50?`, `ping90?` (ms), `q`, `qr` (queued events, recordings), `dropped`, `sent`, `fails`, `health?`, `errors?` (kernel 0.3.2 health) |
64
64
  | tech | `client` (src client) | Every `techEvery` s: `fps`, `mem` (MB, 10s), `ping?` (ms) |
@@ -26,6 +26,7 @@ local CHUNK_SECONDS = _protocol.CHUNK_SECONDS
26
26
  local CHUNK_TARGET_BYTES = _protocol.CHUNK_TARGET_BYTES
27
27
  local CLIENT_BATCH_BYTES = _protocol.CLIENT_BATCH_BYTES
28
28
  local CLIENT_BATCH_MAX = _protocol.CLIENT_BATCH_MAX
29
+ local deviceClass = _protocol.deviceClass
29
30
  local isEventName = _protocol.isEventName
30
31
  local _props = TS.import(script, script.Parent, "props")
31
32
  local encodeProps = _props.encodeProps
@@ -60,23 +61,28 @@ local IGNORED_GUIS = {
60
61
  }
61
62
  --* A screen that closes and opens again within this many seconds (ResetOnSpawn) never left.
62
63
  local SCREEN_DEBOUNCE = 1
63
- local function deviceInfo()
64
+ --* The camera's viewport in points (0 x 0 without a camera).
65
+ local function viewportSize()
64
66
  local camera = Workspace.CurrentCamera
65
- local viewport = if camera then camera.ViewportSize else Vector2.new(0, 0)
67
+ return if camera then camera.ViewportSize else Vector2.new(0, 0)
68
+ end
69
+ --* Seconds the first hello waits for a real viewport (a camera reports 1 x 1 for a moment at startup).
70
+ local VIEWPORT_WAIT = 2
71
+ local function deviceInfo()
72
+ local viewport = viewportSize()
66
73
  local vr = VRService.VREnabled
67
74
  local consoleOk, isConsole = pcall(function()
68
75
  return GuiService:IsTenFootInterface()
69
76
  end)
70
- local dev = "unknown"
71
- if vr then
72
- dev = "vr"
73
- elseif consoleOk and isConsole then
74
- dev = "console"
75
- elseif UserInputService.TouchEnabled and not UserInputService.KeyboardEnabled then
76
- dev = if math.min(viewport.X, viewport.Y) >= 600 then "tablet" else "phone"
77
- elseif UserInputService.KeyboardEnabled or UserInputService.MouseEnabled then
78
- dev = "desktop"
79
- end
77
+ local dev = deviceClass({
78
+ vr = vr,
79
+ tenFoot = consoleOk and isConsole == true,
80
+ touch = UserInputService.TouchEnabled,
81
+ keyboard = UserInputService.KeyboardEnabled,
82
+ mouse = UserInputService.MouseEnabled,
83
+ w = viewport.X,
84
+ h = viewport.Y,
85
+ })
80
86
  local last = UserInputService:GetLastInputType()
81
87
  local input = "unknown"
82
88
  if vr then
@@ -698,7 +704,7 @@ do
698
704
  end
699
705
  end)
700
706
  if not ok then
701
- warn("[src/analytics/client.ts:506]", `[analytics] server message failed: {string.sub(tostring(err), 1, 200)}`)
707
+ warn("[src/analytics/client.ts:518]", `[analytics] server message failed: {string.sub(tostring(err), 1, 200)}`)
702
708
  end
703
709
  end)
704
710
  trove:add(function()
@@ -805,6 +811,11 @@ do
805
811
  end
806
812
  function ClientAnalytics:startSending()
807
813
  self.trove:add(task.spawn(function()
814
+ -- The device class reads the viewport (phone vs tablet): give the camera a moment to report a real size.
815
+ local waitUntil = os.clock() + VIEWPORT_WAIT
816
+ while math.min(viewportSize().X, viewportSize().Y) <= 1 and os.clock() < waitUntil and not self.stopped do
817
+ task.wait(0.1)
818
+ end
808
819
  -- Hello until the server answers (its engine may start later than this one): every 5 s for a minute, then
809
820
  -- every 30 s.
810
821
  local tries = 0
@@ -839,7 +850,7 @@ do
839
850
  end
840
851
  function ClientAnalytics:track(kind, name, props)
841
852
  if not isEventName(name) then
842
- warn("[src/analytics/client.ts:622]", `[analytics] {kind} event name must be 1-64 characters: {string.sub(tostring(name), 1, 80)}`)
853
+ warn("[src/analytics/client.ts:637]", `[analytics] {kind} event name must be 1-64 characters: {string.sub(tostring(name), 1, 80)}`)
843
854
  return nil
844
855
  end
845
856
  self:push(kind, name, encodeProps(props))
@@ -897,7 +908,7 @@ do
897
908
  end
898
909
  local control = _condition
899
910
  if not isExperimentName(name) or not isVariantList(variants) then
900
- warn("[src/analytics/client.ts:656]", `[analytics] experiment("{string.sub(tostring(name), 1, 64)}"): bad name or variants (1-16 distinct names)`)
911
+ warn("[src/analytics/client.ts:671]", `[analytics] experiment("{string.sub(tostring(name), 1, 64)}"): bad name or variants (1-16 distinct names)`)
901
912
  return control
902
913
  end
903
914
  local deadline = os.clock() + 10
@@ -35,6 +35,27 @@ export interface HelloInfo {
35
35
  /** Seconds from the client starting to the engine starting (first client generation only). */
36
36
  load?: number;
37
37
  }
38
+ /** What the client knows about its device when it says hello (UserInputService, VRService, GuiService, the camera). */
39
+ export interface DeviceFacts {
40
+ vr: boolean;
41
+ /** GuiService:IsTenFootInterface(): a console (Xbox, PlayStation). */
42
+ tenFoot: boolean;
43
+ touch: boolean;
44
+ keyboard: boolean;
45
+ mouse: boolean;
46
+ /** Viewport size in points (0 when the camera has none yet). */
47
+ w: number;
48
+ h: number;
49
+ }
50
+ /** A touch-only device whose viewport's short side is at least this many points is a tablet, else a phone. */
51
+ export declare const TABLET_MIN_SHORT_SIDE = 600;
52
+ /**
53
+ * The session's device class (the `dev` column), in this order: VR headset; console (ten-foot interface); touch without
54
+ * a keyboard: tablet when the viewport's short side is 600 points or more, else phone (also when the viewport isn't
55
+ * known); a keyboard or mouse: desktop (a touch laptop or a tablet with a keyboard counts here); else unknown. Roblox
56
+ * gives game scripts no OS, so this is the closest a game gets.
57
+ */
58
+ export declare function deviceClass(f: DeviceFacts): DeviceKind;
38
59
  /** [ageMs, kind, name, propsJson] */
39
60
  export type ClientEvent = [
40
61
  age: number,
@@ -22,6 +22,32 @@ local CLIENT_BATCH_BYTES = 12000
22
22
  local CHUNK_TARGET_BYTES = 6000
23
23
  local CHUNK_MAX_BYTES = 16384
24
24
  local CHUNK_SECONDS = 10
25
+ --* What the client knows about its device when it says hello (UserInputService, VRService, GuiService, the camera).
26
+ --* A touch-only device whose viewport's short side is at least this many points is a tablet, else a phone.
27
+ local TABLET_MIN_SHORT_SIDE = 600
28
+ --[[
29
+ *
30
+ * The session's device class (the `dev` column), in this order: VR headset; console (ten-foot interface); touch without
31
+ * a keyboard: tablet when the viewport's short side is 600 points or more, else phone (also when the viewport isn't
32
+ * known); a keyboard or mouse: desktop (a touch laptop or a tablet with a keyboard counts here); else unknown. Roblox
33
+ * gives game scripts no OS, so this is the closest a game gets.
34
+
35
+ ]]
36
+ local function deviceClass(f)
37
+ if f.vr then
38
+ return "vr"
39
+ end
40
+ if f.tenFoot then
41
+ return "console"
42
+ end
43
+ if f.touch and not f.keyboard then
44
+ return if math.min(f.w, f.h) >= TABLET_MIN_SHORT_SIDE then "tablet" else "phone"
45
+ end
46
+ if f.keyboard or f.mouse then
47
+ return "desktop"
48
+ end
49
+ return "unknown"
50
+ end
25
51
  --* [ageMs, kind, name, propsJson]
26
52
  local DEVICES = {
27
53
  ["desktop"] = true,
@@ -127,6 +153,7 @@ local function cleanClientEvent(value)
127
153
  return { math.floor(age), kind, name, props }
128
154
  end
129
155
  return {
156
+ deviceClass = deviceClass,
130
157
  cleanHello = cleanHello,
131
158
  isEventName = isEventName,
132
159
  cleanClientEvent = cleanClientEvent,
@@ -136,5 +163,6 @@ return {
136
163
  CHUNK_TARGET_BYTES = CHUNK_TARGET_BYTES,
137
164
  CHUNK_MAX_BYTES = CHUNK_MAX_BYTES,
138
165
  CHUNK_SECONDS = CHUNK_SECONDS,
166
+ TABLET_MIN_SHORT_SIDE = TABLET_MIN_SHORT_SIDE,
139
167
  CLIENT_KINDS = CLIENT_KINDS,
140
168
  }
@@ -1284,9 +1284,14 @@ do
1284
1284
  end
1285
1285
  local first = session.device == nil
1286
1286
  session.device = info
1287
- session.dev = info.dev
1287
+ -- The device class is the session's: set by the first hello and kept (a later hello after a hot swap, or a keyboard
1288
+ -- plugged into a tablet, doesn't move the session's rows to another class), unless the first one didn't know.
1289
+ if first or session.dev == "unknown" then
1290
+ session.dev = info.dev
1291
+ end
1288
1292
  if first and self.options.sessions ~= false then
1289
1293
  self:emit(session, "session", "device", encodeProps({
1294
+ dev = info.dev,
1290
1295
  input = info.input,
1291
1296
  w = info.w,
1292
1297
  h = info.h,
@@ -1494,7 +1499,7 @@ do
1494
1499
  end
1495
1500
  end)
1496
1501
  if not ok then
1497
- warn("[src/analytics/server.ts:1075]", `[analytics] client message failed: {string.sub(tostring(err), 1, 200)}`)
1502
+ warn("[src/analytics/server.ts:1077]", `[analytics] client message failed: {string.sub(tostring(err), 1, 200)}`)
1498
1503
  end
1499
1504
  end)
1500
1505
  self.trove:add(function()
@@ -1777,7 +1782,7 @@ do
1777
1782
  self.lastWarnAt = now
1778
1783
  local more = if self.suppressed > 0 then ` (+{self.suppressed} more in the last minute)` else ""
1779
1784
  self.suppressed = 0
1780
- warn("[src/analytics/server.ts:1270]", `[analytics] {message}{more}`)
1785
+ warn("[src/analytics/server.ts:1272]", `[analytics] {message}{more}`)
1781
1786
  return true
1782
1787
  end
1783
1788
  function ServerAnalytics:noteSuccess()
@@ -0,0 +1,71 @@
1
+ import type { Channel, DevInfo, KernelStatus, ServerType } from "../kernel";
2
+ /**
3
+ * Framework 0.4.1: who may use the dev-only devtools on this server. Every such gate asks here: Claude (prompts, live
4
+ * tools, toolbox inserts, keeping the dev machine's session, the long-polls; claude.ts, toolbox-server.ts), Logs >
5
+ * Upload, Dex edits (explorer-server.ts), Modules > State reads for non-owners (server.ts), Manage's own respawn
6
+ * (admin-server.ts), and on the client (cosmetic; the server re-checks) Dex edits, Network packet blocking, the
7
+ * Claude tab, the hot-swap sound and the run_luau hint (op "access", `facts.access` of op "status").
8
+ *
9
+ * The owner's model: two channels, prod (the single prod branch) and dev (every other branch). The tools work:
10
+ * 1. under dev RULES (private, reserved and Studio servers on a dev branch with a dev build): every dev, as before;
11
+ * 2. on a PUBLIC server whose branch channel is dev and whose branch an OWNER switched to (kernel 0.3.4+ owner switch:
12
+ * `status().switched` names this branch, and the kernel follows it by dev rules, `status().signedOnly` false,
13
+ * which only its in-memory `ownerBranch` gives a public server): owners only;
14
+ * 3. nowhere else.
15
+ * The rules themselves don't change (signatures, owner-only rollbacks in the kernel, `TypeTorch.channel`, which games
16
+ * split data stores by). Older kernels have no `branchChannel` (before 0.3.9) or no `switched` (before 0.3.4): their
17
+ * public servers stay refused, as before.
18
+ */
19
+ /** Why the dev-only tools are refused (an error code of the dev ops; client.ts and claude-ui.ts have short texts). */
20
+ export type DevRefusal = "dev_branch_only" | "owner_switch_only" | "owners_only";
21
+ /** The server's half of the rule: "dev" (rule 1), "owners" (rule 2) or why not. */
22
+ export type ServerAccess = "dev" | "owners" | "dev_branch_only" | "owner_switch_only";
23
+ /** What op "access" answers (and `facts.access` of op "status" holds: "ok" or the refusal). */
24
+ export type AccessReply = {
25
+ ok: true;
26
+ } | {
27
+ ok: false;
28
+ error: DevRefusal;
29
+ };
30
+ /** What the rule reads from the kernel (ServerKernel has all of it; tests pass stubs). */
31
+ export interface AccessKernel {
32
+ readonly branch: string;
33
+ readonly channel: Channel;
34
+ readonly rules?: Channel;
35
+ readonly branchChannel?: Channel;
36
+ readonly serverType: ServerType;
37
+ status(): Pick<KernelStatus, "branch" | "signedOnly" | "switched">;
38
+ devInfo(player: Player): DevInfo;
39
+ }
40
+ /**
41
+ * A public server's branch was set by an owner switch: the kernel's switch record names this branch (only owners switch
42
+ * or load builds on public servers), the kernel runs that branch now, and it follows it by dev rules (`signedOnly`
43
+ * false: the owner's dev branch). Absent fields (older kernels) mean no.
44
+ */
45
+ export declare function ownerSwitchedHere(kernel: Pick<AccessKernel, "branch">, status: Pick<KernelStatus, "branch" | "signedOnly" | "switched">): boolean;
46
+ /** The server's half: rules dev, else a public dev branch an owner switched to, else refused. */
47
+ export declare function serverAccess(kernel: AccessKernel): ServerAccess;
48
+ /** Whether `player` may use the dev-only tools here (undefined) or why not. `server`: serverAccess, when known. */
49
+ export declare function devAccess(kernel: AccessKernel, player: Player, server?: ServerAccess): DevRefusal | undefined;
50
+ /**
51
+ * The rule for one generation. The server half reads `kernel.status()` on public servers, so it is kept for `ttl`
52
+ * seconds (Claude's long-poll manager asks every second); the player half (the owner check) is asked every time.
53
+ */
54
+ export declare class DevAccess {
55
+ private readonly kernel;
56
+ private readonly ttl;
57
+ private cached?;
58
+ private cachedAt;
59
+ constructor(kernel: AccessKernel, ttl?: number);
60
+ /** The server's half (see serverAccess). */
61
+ server(): ServerAccess;
62
+ /** Some player may use the tools here (rule 1 or 2): e.g. the server keeps the dev machine's session. */
63
+ serverOpen(): boolean;
64
+ /** Rule 2 (a public server an owner switched): owners only; a kept Claude session only while one is paired. */
65
+ ownersOnly(): boolean;
66
+ /** Undefined when `player` may use the tools here, else why not. */
67
+ refusal(player: Player): DevRefusal | undefined;
68
+ allows(player: Player): boolean;
69
+ /** Op "access". */
70
+ reply(player: Player): AccessReply;
71
+ }
@@ -0,0 +1,140 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ local _kernel = TS.import(script, script.Parent.Parent, "kernel")
4
+ local branchChannelOf = _kernel.branchChannelOf
5
+ local devRoleOf = _kernel.devRoleOf
6
+ local rulesOf = _kernel.rulesOf
7
+ --[[
8
+ *
9
+ * Framework 0.4.1: who may use the dev-only devtools on this server. Every such gate asks here: Claude (prompts, live
10
+ * tools, toolbox inserts, keeping the dev machine's session, the long-polls; claude.ts, toolbox-server.ts), Logs >
11
+ * Upload, Dex edits (explorer-server.ts), Modules > State reads for non-owners (server.ts), Manage's own respawn
12
+ * (admin-server.ts), and on the client (cosmetic; the server re-checks) Dex edits, Network packet blocking, the
13
+ * Claude tab, the hot-swap sound and the run_luau hint (op "access", `facts.access` of op "status").
14
+ *
15
+ * The owner's model: two channels, prod (the single prod branch) and dev (every other branch). The tools work:
16
+ * 1. under dev RULES (private, reserved and Studio servers on a dev branch with a dev build): every dev, as before;
17
+ * 2. on a PUBLIC server whose branch channel is dev and whose branch an OWNER switched to (kernel 0.3.4+ owner switch:
18
+ * `status().switched` names this branch, and the kernel follows it by dev rules, `status().signedOnly` false,
19
+ * which only its in-memory `ownerBranch` gives a public server): owners only;
20
+ * 3. nowhere else.
21
+ * The rules themselves don't change (signatures, owner-only rollbacks in the kernel, `TypeTorch.channel`, which games
22
+ * split data stores by). Older kernels have no `branchChannel` (before 0.3.9) or no `switched` (before 0.3.4): their
23
+ * public servers stay refused, as before.
24
+
25
+ ]]
26
+ --* Why the dev-only tools are refused (an error code of the dev ops; client.ts and claude-ui.ts have short texts).
27
+ --* The server's half of the rule: "dev" (rule 1), "owners" (rule 2) or why not.
28
+ --* What op "access" answers (and `facts.access` of op "status" holds: "ok" or the refusal).
29
+ --* What the rule reads from the kernel (ServerKernel has all of it; tests pass stubs).
30
+ --[[
31
+ *
32
+ * A public server's branch was set by an owner switch: the kernel's switch record names this branch (only owners switch
33
+ * or load builds on public servers), the kernel runs that branch now, and it follows it by dev rules (`signedOnly`
34
+ * false: the owner's dev branch). Absent fields (older kernels) mean no.
35
+
36
+ ]]
37
+ local function ownerSwitchedHere(kernel, status)
38
+ local switched = status.switched
39
+ if not (type(switched) == "table") then
40
+ return false
41
+ end
42
+ local _condition = status.signedOnly == false and status.branch == kernel.branch and switched.branch == kernel.branch
43
+ if _condition then
44
+ local _by = switched.by
45
+ _condition = type(_by) == "number"
46
+ end
47
+ return _condition
48
+ end
49
+ --* The server's half: rules dev, else a public dev branch an owner switched to, else refused.
50
+ local function serverAccess(kernel)
51
+ if rulesOf(kernel) == "dev" then
52
+ return "dev"
53
+ end
54
+ if branchChannelOf(kernel) ~= "dev" or kernel.serverType ~= "public" then
55
+ return "dev_branch_only"
56
+ end
57
+ local ok, status = pcall(function()
58
+ return kernel:status()
59
+ end)
60
+ if not ok or not (type(status) == "table") then
61
+ return "owner_switch_only"
62
+ end
63
+ return if ownerSwitchedHere(kernel, status) then "owners" else "owner_switch_only"
64
+ end
65
+ --* Whether `player` may use the dev-only tools here (undefined) or why not. `server`: serverAccess, when known.
66
+ local function devAccess(kernel, player, server)
67
+ if server == nil then
68
+ server = serverAccess(kernel)
69
+ end
70
+ if server == "dev" then
71
+ return nil
72
+ end
73
+ if server ~= "owners" then
74
+ return server
75
+ end
76
+ return if devRoleOf(kernel, player) == "owner" then nil else "owners_only"
77
+ end
78
+ --[[
79
+ *
80
+ * The rule for one generation. The server half reads `kernel.status()` on public servers, so it is kept for `ttl`
81
+ * seconds (Claude's long-poll manager asks every second); the player half (the owner check) is asked every time.
82
+
83
+ ]]
84
+ local DevAccess
85
+ do
86
+ DevAccess = setmetatable({}, {
87
+ __tostring = function()
88
+ return "DevAccess"
89
+ end,
90
+ })
91
+ DevAccess.__index = DevAccess
92
+ function DevAccess.new(...)
93
+ local self = setmetatable({}, DevAccess)
94
+ return self:constructor(...) or self
95
+ end
96
+ function DevAccess:constructor(kernel, ttl)
97
+ if ttl == nil then
98
+ ttl = 1
99
+ end
100
+ self.kernel = kernel
101
+ self.ttl = ttl
102
+ self.cachedAt = -math.huge
103
+ end
104
+ function DevAccess:server()
105
+ local now = os.clock()
106
+ if self.cached == nil or now - self.cachedAt >= self.ttl then
107
+ self.cached = serverAccess(self.kernel)
108
+ self.cachedAt = now
109
+ end
110
+ return self.cached
111
+ end
112
+ function DevAccess:serverOpen()
113
+ local server = self:server()
114
+ return server == "dev" or server == "owners"
115
+ end
116
+ function DevAccess:ownersOnly()
117
+ return self:server() == "owners"
118
+ end
119
+ function DevAccess:refusal(player)
120
+ return devAccess(self.kernel, player, self:server())
121
+ end
122
+ function DevAccess:allows(player)
123
+ return self:refusal(player) == nil
124
+ end
125
+ function DevAccess:reply(player)
126
+ local refusal = self:refusal(player)
127
+ return if refusal == nil then {
128
+ ok = true,
129
+ } else {
130
+ ok = false,
131
+ error = refusal,
132
+ }
133
+ end
134
+ end
135
+ return {
136
+ ownerSwitchedHere = ownerSwitchedHere,
137
+ serverAccess = serverAccess,
138
+ devAccess = devAccess,
139
+ DevAccess = DevAccess,
140
+ }
@@ -1,5 +1,6 @@
1
1
  import { Trove } from "@rbxts/trove";
2
2
  import { type Channel, type Role, type ServerKernel, type ServerType } from "../kernel";
3
+ import { DevAccess } from "./access";
3
4
  /**
4
5
  * Server half of the dev menu's Manage group (plans/10; "Admin" before framework 0.3.2): players (teleport to, bring,
5
6
  * respawn, kick, ban), bans (unban, history) and the live servers of this universe (list, join, new server, shut down,
@@ -116,4 +117,4 @@ export declare function kernelHasFleetStatus(kernel: ServerKernel): boolean;
116
117
  * Registers the "admin.*" dev ops and starts this server's heartbeat in the server list. Everything it connects or
117
118
  * spawns lives in `trove` (the devtools trove, cleaned on swap).
118
119
  */
119
- export declare function registerAdminOps(register: (op: string, handler: (player: Player, payload: unknown) => unknown) => void, kernel: ServerKernel, trove: Trove): void;
120
+ export declare function registerAdminOps(register: (op: string, handler: (player: Player, payload: unknown) => unknown) => void, kernel: ServerKernel, trove: Trove, access?: DevAccess): void;
@@ -9,7 +9,8 @@ local TeleportService = _services.TeleportService
9
9
  local TextService = _services.TextService
10
10
  local _kernel = TS.import(script, script.Parent.Parent, "kernel")
11
11
  local branchChannelOf = _kernel.branchChannelOf
12
- local normalRole = _kernel.normalRole
12
+ local devRoleOf = _kernel.devRoleOf
13
+ local DevAccess = TS.import(script, script.Parent, "access").DevAccess
13
14
  local countBudget = TS.import(script, script.Parent.Parent, "budget").countBudget
14
15
  local RateLimiter = TS.import(script, script.Parent.Parent, "net", "limits").RateLimiter
15
16
  local _ab = TS.import(script, script.Parent, "ab")
@@ -183,7 +184,10 @@ end
183
184
  * spawns lives in `trove` (the devtools trove, cleaned on swap).
184
185
 
185
186
  ]]
186
- local function registerAdminOps(register, kernel, trove)
187
+ local function registerAdminOps(register, kernel, trove, access)
188
+ if access == nil then
189
+ access = DevAccess.new(kernel)
190
+ end
187
191
  local saved = kernel:persist(PERSIST_KEY, function()
188
192
  return {
189
193
  modSeq = 0,
@@ -196,13 +200,7 @@ local function registerAdminOps(register, kernel, trove)
196
200
  local jobLabel = if game.JobId ~= "" then game.JobId else "studio"
197
201
  local jobKey = if game.JobId ~= "" then string.sub((string.gsub(game.JobId, "-", "")), 1, 24) else "studio"
198
202
  local roleOf = function(player)
199
- local ok, info = pcall(function()
200
- return kernel:devInfo(player)
201
- end)
202
- if not ok or not (type(info) == "table") or info.dev ~= true then
203
- return nil
204
- end
205
- return normalRole(info.role)
203
+ return devRoleOf(kernel, player)
206
204
  end
207
205
  local actorOf = function(player)
208
206
  -- The dispatcher checks this too; re-checked so these ops stay dev-only wherever they are registered.
@@ -247,7 +245,7 @@ local function registerAdminOps(register, kernel, trove)
247
245
  return {
248
246
  tp = not isSelf,
249
247
  bring = not isSelf and actor.owner and actor.rank >= targetRank,
250
- respawn = if isSelf then kernel.channel == "dev" or actor.owner else actor.owner and actor.rank >= targetRank,
248
+ respawn = if isSelf then actor.owner or access:allows(actor.player) else actor.owner and actor.rank >= targetRank,
251
249
  kick = not isSelf and actor.owner and actor.rank > targetRank,
252
250
  ban = not isSelf and actor.owner and actor.rank > targetRank,
253
251
  }
@@ -326,7 +324,7 @@ local function registerAdminOps(register, kernel, trove)
326
324
  return DataStoreService:GetDataStore(MOD_STORE):SetAsync(key, entry)
327
325
  end)
328
326
  if not ok then
329
- warn("[src/devtools/admin-server.ts:394]", `[manage] durable record {key} failed: {err}`)
327
+ warn("[src/devtools/admin-server.ts:393]", `[manage] durable record {key} failed: {err}`)
330
328
  end
331
329
  end)
332
330
  end
@@ -952,7 +950,7 @@ local function registerAdminOps(register, kernel, trove)
952
950
  end)
953
951
  if not ok then
954
952
  failed += 1
955
- warn("[src/devtools/admin-server.ts:756]", `[manage] A/B publish failed: {err}`)
953
+ warn("[src/devtools/admin-server.ts:755]", `[manage] A/B publish failed: {err}`)
956
954
  end
957
955
  end
958
956
  for branch, group in groups do
@@ -1183,7 +1181,7 @@ local function registerAdminOps(register, kernel, trove)
1183
1181
  return nil
1184
1182
  end
1185
1183
  backoff(entry)
1186
- warn("[src/devtools/admin-server.ts:885]", `[manage] migrate: {target.Name} failed ({result.Name}): {message}`)
1184
+ warn("[src/devtools/admin-server.ts:884]", `[manage] migrate: {target.Name} failed ({result.Name}): {message}`)
1187
1185
  end)
1188
1186
  trove:connect(Players.PlayerRemoving, function(leaving)
1189
1187
  local _leaving = leaving
@@ -1269,7 +1267,7 @@ local function registerAdminOps(register, kernel, trove)
1269
1267
  return TeleportService:TeleportAsync(game.PlaceId, batch, options)
1270
1268
  end)
1271
1269
  if not ok then
1272
- warn("[src/devtools/admin-server.ts:925]", `[manage] migrate: teleport of {#batch} failed: {err}`)
1270
+ warn("[src/devtools/admin-server.ts:924]", `[manage] migrate: teleport of {#batch} failed: {err}`)
1273
1271
  for _, target in batch do
1274
1272
  local entry = attempts[target]
1275
1273
  if entry then