@irtio/cli 0.6.0 → 0.7.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.
Files changed (28) hide show
  1. package/dist/api-keys-UTLYMZYN.js +222 -0
  2. package/dist/api.js +1 -1
  3. package/dist/{chunk-3HQMVCYA.js → chunk-BQBOBFBO.js} +4 -4
  4. package/dist/{chunk-RNAH5T4W.js → chunk-IDF46P7R.js} +2 -0
  5. package/dist/{chunk-DKWG7MGO.js → chunk-JL235KIE.js} +1 -1
  6. package/dist/{chunk-ZD4ND6X6.js → chunk-OCVALOGK.js} +1 -1
  7. package/dist/{chunk-RQSJZWQC.js → chunk-WFMRNGO5.js} +134 -105
  8. package/dist/{delete-project-VENS2B44.js → delete-project-MXUYNGAO.js} +2 -2
  9. package/dist/deploy.d.ts +3 -1
  10. package/dist/deploy.js +163 -5
  11. package/dist/{dev-QM26ONKS.js → dev-AUZ4OLA3.js} +139 -10
  12. package/dist/index.js +39 -15
  13. package/dist/init.js +2 -2
  14. package/dist/{keys-JHLMEGRA.js → keys-NXRIBJZP.js} +3 -3
  15. package/dist/{leaderboard-SYPSBPS3.js → leaderboard-ZBJBKETM.js} +65 -11
  16. package/dist/{login-2M73HBZT.js → login-3RVN5PPN.js} +2 -2
  17. package/dist/{logs-2W7CPZO5.js → logs-AT7G6YRH.js} +3 -3
  18. package/dist/{migrate-T3DZJREY.js → migrate-FMXTRVUV.js} +3 -3
  19. package/dist/{ratings-VG32WFDG.js → ratings-XBLX2MUW.js} +3 -3
  20. package/dist/{rollback-SO74MVZV.js → rollback-LI4TDLQA.js} +3 -3
  21. package/dist/{rooms-VI33P4RA.js → rooms-52Q5KBUS.js} +179 -20
  22. package/dist/simulate.d.ts +115 -10
  23. package/dist/simulate.js +270 -36
  24. package/dist/{static-deploy-KOWFKWZA.js → static-deploy-7UCYINJB.js} +5 -5
  25. package/dist/{status-HF3ZEKB7.js → status-JZGKH2P6.js} +3 -3
  26. package/dist/{usage-4G23QXCH.js → usage-7S447INI.js} +3 -3
  27. package/dist/{whoami-KTMTQNHM.js → whoami-UFSWPWK6.js} +2 -2
  28. package/package.json +7 -7
package/dist/deploy.js CHANGED
@@ -1,15 +1,15 @@
1
1
  import {
2
2
  logEnsured,
3
3
  runStaticDeploy
4
- } from "./chunk-3HQMVCYA.js";
4
+ } from "./chunk-BQBOBFBO.js";
5
5
  import {
6
6
  clientImportsRoom
7
7
  } from "./chunk-TQU6345E.js";
8
- import "./chunk-RQSJZWQC.js";
8
+ import "./chunk-WFMRNGO5.js";
9
9
  import {
10
10
  ensureProject,
11
11
  readProjectConfig
12
- } from "./chunk-DKWG7MGO.js";
12
+ } from "./chunk-JL235KIE.js";
13
13
  import {
14
14
  bundleRoom
15
15
  } from "./chunk-OTSFRVJN.js";
@@ -17,12 +17,12 @@ import {
17
17
  HelpRequested,
18
18
  helpFor,
19
19
  helpRequested
20
- } from "./chunk-ZD4ND6X6.js";
20
+ } from "./chunk-OCVALOGK.js";
21
21
  import {
22
22
  ApiClientError,
23
23
  createApiClient,
24
24
  isLoginRequired
25
- } from "./chunk-RNAH5T4W.js";
25
+ } from "./chunk-IDF46P7R.js";
26
26
  import {
27
27
  resolveControlUrlForUser
28
28
  } from "./chunk-UPHQM6NZ.js";
@@ -37,6 +37,7 @@ import { DEFAULT_ROOM_TYPE, ROOM_TYPE_RE } from "@irtio/protocol";
37
37
  import { diffSchemas, schemaFromCanonical } from "@irtio/schema";
38
38
  import pc from "picocolors";
39
39
  var ROOM_CANDIDATES = ["irtio/room.ts", "irtio/room.js", "room.ts"];
40
+ var SCHEMA_CANDIDATES = ["irtio/schema.ts", "irtio/schema.js"];
40
41
  var MIGRATIONS_DIR = "irtio/migrations";
41
42
  var ROOM_TYPES_DIR = "irtio/rooms";
42
43
  var USAGE = `usage: irtio deploy [options]
@@ -145,12 +146,20 @@ function findRoomEntry(cwd, room) {
145
146
  }
146
147
  return void 0;
147
148
  }
149
+ function findSchemaEntry(cwd) {
150
+ for (const candidate of SCHEMA_CANDIDATES) {
151
+ const file = path.resolve(cwd, candidate);
152
+ if (existsSync(file)) return file;
153
+ }
154
+ return void 0;
155
+ }
148
156
  function resolveEntry(cwd, room) {
149
157
  const entry = findRoomEntry(cwd, room);
150
158
  if (entry !== void 0) return entry;
151
159
  throw new Error(
152
160
  `irtio deploy: no room file found in ${cwd}
153
161
  looked for: ${ROOM_CANDIDATES.join(", ")}
162
+ or ${SCHEMA_CANDIDATES.join(" / ")}, to deploy a schema with no room code
154
163
  pass --room <file> if it lives elsewhere`
155
164
  );
156
165
  }
@@ -235,12 +244,160 @@ async function findMigrationFile(cwd, version) {
235
244
  return match ? path.join(dir, match) : void 0;
236
245
  }
237
246
  var DEPLOY_BUILD_DIR = path.join(".irtio", "deploy");
247
+ function isSchemaOnly(cwd, room) {
248
+ if (room !== void 0) return false;
249
+ if (findRoomEntry(cwd, void 0) !== void 0) return false;
250
+ if (existsSync(path.resolve(cwd, ROOM_TYPES_DIR))) return false;
251
+ return findSchemaEntry(cwd) !== void 0;
252
+ }
253
+ async function loadDeployedSchema(entry, outDir, irtioPackages) {
254
+ const bundled = await bundleRoom({
255
+ entry,
256
+ outDir,
257
+ verify: false,
258
+ ...irtioPackages !== void 0 ? { irtioPackages } : {}
259
+ });
260
+ const mod = await import(pathToFileURL(bundled.file).href);
261
+ const schema = mod.schema ?? mod.default;
262
+ if (!schema || typeof schema.canonical !== "string" || typeof schema.hash !== "string") {
263
+ throw new Error(
264
+ `irtio deploy: ${path.basename(entry)} does not export a schema
265
+ it must export the value defineSchema(...) returned, as \`schema\` or as the default`
266
+ );
267
+ }
268
+ return schema;
269
+ }
270
+ async function runSchemaOnlyDeploy(options, cwd, log, ask, controlUrl, config) {
271
+ const entry = findSchemaEntry(cwd);
272
+ const outDir = path.join(cwd, DEPLOY_BUILD_DIR);
273
+ await mkdir(outDir, { recursive: true });
274
+ const schema = await loadDeployedSchema(entry, outDir, options.irtioPackages);
275
+ log(pc.green(`bundled ${path.relative(cwd, entry)} (schema ${schema.hash.slice(0, 12)})`));
276
+ const client = options.client ?? await createApiClient(controlUrl);
277
+ const projectId = options.project ?? config.project ?? schema.project;
278
+ if (projectId === void 0) {
279
+ throw new Error(
280
+ 'irtio deploy: no project id \u2014 pass --project, add "project" to irtio.json, or set it in defineSchema(...)'
281
+ );
282
+ }
283
+ const ensured = await ensureProject(client, projectId, {
284
+ cwd,
285
+ config,
286
+ command: "irtio deploy",
287
+ ...options.name !== void 0 ? { name: options.name } : {}
288
+ });
289
+ logEnsured(log, projectId, ensured);
290
+ const deployments = await client.get(`/v1/projects/${projectId}/deployments`);
291
+ const newestOf = (list) => list.reduce(
292
+ (best, d) => best === void 0 || d.version > best.version ? d : best,
293
+ void 0
294
+ );
295
+ const previous = newestOf(deployments.filter((d) => d.rolledBackAt == null));
296
+ const newest = newestOf(deployments);
297
+ const nextVersion = (newest?.version ?? 0) + 1;
298
+ let allowBreaking = options.allowBreaking ?? false;
299
+ let migrationFile;
300
+ if (previous !== void 0) {
301
+ const changes = diffSchemas(schemaFromCanonical(previous.schemaJson), schema);
302
+ const breaking = changes.filter((c) => c.kind === "breaking");
303
+ const additive = changes.filter((c) => c.kind === "additive");
304
+ if (breaking.length > 0) {
305
+ if (!allowBreaking) {
306
+ const hint = "run: irtio migrate create <name>";
307
+ printBreaking(log, breaking, hint);
308
+ throw new DeployRefusedError({ changes: breaking, hint });
309
+ }
310
+ migrationFile = await findMigrationFile(cwd, nextVersion);
311
+ if (migrationFile === void 0) {
312
+ const hint = `--allow-breaking needs a migration for v${nextVersion}. Run: irtio migrate create <name>`;
313
+ printBreaking(log, breaking, hint);
314
+ throw new DeployRefusedError({ changes: breaking, hint });
315
+ }
316
+ log(pc.yellow(`${breaking.length} breaking change(s), proceeding with --allow-breaking:`));
317
+ for (const change of breaking) log(` ${change.message}`);
318
+ } else {
319
+ allowBreaking = false;
320
+ }
321
+ if (additive.length > 0) {
322
+ log(pc.cyan(`${additive.length} additive change(s):`));
323
+ for (const change of additive) log(` ${change.message}`);
324
+ }
325
+ if (changes.length === 0) log(pc.dim("no schema changes"));
326
+ } else {
327
+ log(pc.dim("no previous deployment, so this is the first deploy for this project"));
328
+ }
329
+ let migrationKey;
330
+ if (migrationFile !== void 0) {
331
+ const migrationBundle = await bundleRoom({
332
+ entry: migrationFile,
333
+ outDir,
334
+ verify: false,
335
+ ...options.irtioPackages !== void 0 ? { irtioPackages: options.irtioPackages } : {}
336
+ });
337
+ const uploaded = await client.postBytes(
338
+ `/v1/projects/${projectId}/artifacts`,
339
+ new Uint8Array(await readFile(migrationBundle.file))
340
+ );
341
+ migrationKey = uploaded.key;
342
+ log(pc.green(`bundled and uploaded migration ${path.basename(migrationFile)}`));
343
+ }
344
+ let origin;
345
+ if (previous === void 0) {
346
+ const answer = (await ask("production origin (localhost is always allowed) \u2014 leave blank to skip: ")).trim();
347
+ if (answer.length > 0) origin = answer;
348
+ }
349
+ let created;
350
+ try {
351
+ created = await client.post(`/v1/projects/${projectId}/deployments`, {
352
+ // No `bundleHash` and no `bundleKey`: that absence IS the schema-only deploy, and it is
353
+ // what makes control write a row `isRelayOnly` reads as relay-only.
354
+ schemaJson: schema.canonical,
355
+ schemaHash: schema.hash,
356
+ ...migrationKey !== void 0 ? { migrationKey } : {},
357
+ ...allowBreaking ? { allowBreaking: true } : {},
358
+ ...origin !== void 0 ? { origin } : {}
359
+ });
360
+ } catch (err) {
361
+ if (err instanceof ApiClientError && err.code === "E_BREAKING_SCHEMA" && err.changes) {
362
+ const changes = err.changes;
363
+ const hint = err.hint ?? "run: irtio migrate create <name>";
364
+ printBreaking(log, changes, hint);
365
+ throw new DeployRefusedError({ changes, hint });
366
+ }
367
+ if (isLoginRequired(err)) throw err;
368
+ throw err;
369
+ }
370
+ await rm(outDir, { recursive: true, force: true }).catch(() => {
371
+ });
372
+ log(pc.green(`deployed v${created.version}`));
373
+ log(pc.dim("schema-only deploy: this project runs as a relay tenant"));
374
+ log(pc.dim(`reachable at ${created.url}`));
375
+ const result = {
376
+ project: projectId,
377
+ version: created.version,
378
+ url: created.url,
379
+ draining: created.draining,
380
+ ...created.applied !== void 0 ? { applied: created.applied } : {}
381
+ };
382
+ if (config.static === void 0 || options.noStatic === true) return result;
383
+ const site = await runStaticDeploy({
384
+ cwd,
385
+ ...options.config !== void 0 ? { config: options.config } : {},
386
+ ...options.controlUrl !== void 0 ? { controlUrl: options.controlUrl } : {},
387
+ ...options.client !== void 0 ? { client: options.client } : {},
388
+ log
389
+ });
390
+ return { ...result, site };
391
+ }
238
392
  async function runDeploy(options = {}) {
239
393
  const cwd = path.resolve(options.cwd ?? process.cwd());
240
394
  const log = options.log ?? ((line) => console.log(line));
241
395
  const ask = options.ask ?? defaultAsk;
242
396
  const controlUrl = await resolveControlUrlForUser(options.controlUrl);
243
397
  const config = await readProjectConfig(cwd, "irtio deploy", options.config);
398
+ if (isSchemaOnly(cwd, options.room)) {
399
+ return runSchemaOnlyDeploy(options, cwd, log, ask, controlUrl, config);
400
+ }
244
401
  const roomTypes = findRoomTypes(cwd, options.room);
245
402
  const entry = roomTypes.default;
246
403
  await warnIfClientImportsRoom(cwd, entry, config, log);
@@ -562,6 +719,7 @@ export {
562
719
  deploy,
563
720
  findRoomEntry,
564
721
  findRoomTypes,
722
+ findSchemaEntry,
565
723
  parseDeployArgs,
566
724
  runDeploy
567
725
  };
@@ -23,10 +23,10 @@ import {
23
23
  ratingValueProblem,
24
24
  saveKey,
25
25
  verifyAssertion
26
- } from "./chunk-RQSJZWQC.js";
26
+ } from "./chunk-WFMRNGO5.js";
27
27
  import {
28
28
  readProjectConfig
29
- } from "./chunk-DKWG7MGO.js";
29
+ } from "./chunk-JL235KIE.js";
30
30
  import {
31
31
  BundleError,
32
32
  bundleRoom
@@ -35,8 +35,8 @@ import {
35
35
  HelpRequested,
36
36
  helpFor,
37
37
  helpRequested
38
- } from "./chunk-ZD4ND6X6.js";
39
- import "./chunk-RNAH5T4W.js";
38
+ } from "./chunk-OCVALOGK.js";
39
+ import "./chunk-IDF46P7R.js";
40
40
  import "./chunk-UPHQM6NZ.js";
41
41
 
42
42
  // src/dev.ts
@@ -187,9 +187,11 @@ import {
187
187
  isMailboxAlarm,
188
188
  isOutboxAlarm,
189
189
  isRelayHash8,
190
+ isTypedMsg,
190
191
  isVoiceMsg,
191
192
  relaySchema as relaySchema2,
192
193
  schemaPayload,
194
+ typedMsgFromClientOk,
193
195
  withBuiltins
194
196
  } from "@irtio/protocol";
195
197
  import { inspectState } from "@irtio/runtime";
@@ -402,10 +404,12 @@ function relayForward(room, peer, type, payload) {
402
404
  }
403
405
  let target;
404
406
  let body;
407
+ let typed;
405
408
  try {
406
409
  const msg = decodeMsg(payload);
407
410
  target = msg.target;
408
411
  body = msg.payload;
412
+ typed = msg.typed;
409
413
  } catch {
410
414
  return { ok: false, reason: "malformed MSG" };
411
415
  }
@@ -415,7 +419,11 @@ function relayForward(room, peer, type, payload) {
415
419
  if (target.kind === "voice") return { ok: true, dropped: true };
416
420
  const out = encodeFrame(
417
421
  FrameType.MSG,
418
- encodeMsg({ target: { kind: "client", clientId: peer.clientId }, payload: body })
422
+ encodeMsg({
423
+ target: { kind: "client", clientId: peer.clientId },
424
+ payload: body,
425
+ ...typed ? { typed } : {}
426
+ })
419
427
  );
420
428
  for (const other of room.peers()) {
421
429
  if (other === peer || other.state !== "joined") continue;
@@ -3008,9 +3016,29 @@ var SupervisorImpl = class {
3008
3016
  *
3009
3017
  * They go into a ring under the reserved id `__tenant` (D38), so the whole existing pipeline
3010
3018
  * carries them with no new route and no new frame.
3019
+ *
3020
+ * bug #53 (D67-g, which recorded the gap rather than closing it): on a **sharded** tenant every
3021
+ * ring line is tagged `[shard n]`. Every shard of a project ships its `__tenant` ring under the
3022
+ * same project and the same reserved room id, `log_events` has no shard column, and control
3023
+ * drops the frame's shard before the insert, so without the tag `irtio logs` shows two shards'
3024
+ * boot lines interleaved by time and calls them one restart. The tag is written here, at the
3025
+ * source, because this process is the only thing that knows which shard it is without being
3026
+ * told, and one string in the message beats a schema change on an append-only table.
3027
+ *
3028
+ * It is the mirror of `roomLog` below, and for the same reason: a ring line carries whatever its
3029
+ * stream's identity does not. A room's ring is keyed by the room, so its lines stay bare and the
3030
+ * console gets the `[room]` prefix; the tenant ring is keyed by `__tenant` alone, so its lines
3031
+ * carry the shard and the console stays bare — each shard is its own VM with its own console
3032
+ * file, which already says which shard is talking.
3033
+ *
3034
+ * A single-shard tenant (every tenant that exists today) takes the same bare `args` it always
3035
+ * did, byte for byte: `single-shard-pin.test.ts` holds that.
3011
3036
  */
3012
3037
  log(level, ...args) {
3013
- this.tenantLogs.push(level, args);
3038
+ this.tenantLogs.push(
3039
+ level,
3040
+ this.isSharded() ? [`[shard ${this.config.shard ?? 0}]`, ...args] : args
3041
+ );
3014
3042
  this.emit(level, args);
3015
3043
  }
3016
3044
  /** The console/config sink, with no ring behind it. */
@@ -3047,9 +3075,20 @@ var SupervisorImpl = class {
3047
3075
  * shape, so a dev tenant's existing snapshots stay readable.
3048
3076
  */
3049
3077
  /**
3050
- * The read-only admin API the host agent polls from outside the VM (week 5). It exists only
3051
- * when `adminToken` is configured, and every route is a projection of `SupervisorAdmin` — the
3052
- * agent must not be able to *change* anything about a tenant it is shipping telemetry for.
3078
+ * The admin API the host agent polls from outside the VM (week 5). It exists only when
3079
+ * `adminToken` is configured.
3080
+ *
3081
+ * Its original rule was that every route is a projection of `SupervisorAdmin` and the agent must
3082
+ * not be able to *change* anything about a tenant it is shipping telemetry for. That rule now
3083
+ * has five exceptions, each added deliberately and each documented at its own `case`:
3084
+ * `/admin/resume` (D31, the post-restore wall clock), `/admin/deploy` (the in-place deploy),
3085
+ * `/admin/caps` (D48), `/admin/identity-keys` (D53), `/admin/bus/deliver` (D67-e) and
3086
+ * `/admin/rooms/evict` (M6 lane A, D68-d). The surviving half of the rule is the part that
3087
+ * matters and it is unchanged: every one of them is a **push from control**, arriving over the
3088
+ * fleet socket and relayed by the agent with the derived admin token. None of them is something
3089
+ * a guest, a tenant or anything else that can reach this port may originate.
3090
+ *
3091
+ * `/admin/rooms/evict` is the one that disconnects players, so it is the one to read carefully.
3053
3092
  */
3054
3093
  serveAdmin(req, res) {
3055
3094
  const token = this.config.adminToken;
@@ -3317,6 +3356,55 @@ var SupervisorImpl = class {
3317
3356
  });
3318
3357
  return true;
3319
3358
  }
3359
+ // ---- M6 lane A: rooms API ----
3360
+ // D68-d: the FIFTH write on this surface, and the only one that disconnects players. A
3361
+ // `DELETE ?force=true` on the rooms API reached control, control authorized it, and this is
3362
+ // control telling the guest to let the room go before the objects are removed underneath it.
3363
+ //
3364
+ // Same `adminToken` gate as every other route here, which is the whole access story: the
3365
+ // token is derived per project from the agent's own fleet token and never leaves the box, so
3366
+ // a tenant cannot forge it, and the frame that triggers this is `ControlToAgent` — a
3367
+ // direction a guest cannot speak on the fleet socket at all.
3368
+ //
3369
+ // Answers `{ outcome }`, never an error for "not here": a room this tenant is not holding is
3370
+ // `absent`, and control deletes the family either way.
3371
+ case "/admin/rooms/evict": {
3372
+ if (req.method !== "POST") {
3373
+ res.writeHead(405, { "content-type": "application/json" });
3374
+ res.end('{"error":"POST only"}');
3375
+ return true;
3376
+ }
3377
+ let body = "";
3378
+ req.on("data", (d) => {
3379
+ body += d.toString();
3380
+ });
3381
+ req.on("end", () => {
3382
+ void (async () => {
3383
+ let parsed;
3384
+ try {
3385
+ parsed = JSON.parse(body || "{}");
3386
+ } catch {
3387
+ res.writeHead(400, { "content-type": "application/json" });
3388
+ res.end('{"error":"body is not JSON"}');
3389
+ return;
3390
+ }
3391
+ if (typeof parsed.roomId !== "string" || parsed.roomId.length === 0) {
3392
+ res.writeHead(400, { "content-type": "application/json" });
3393
+ res.end('{"error":"roomId must be a non-empty string"}');
3394
+ return;
3395
+ }
3396
+ try {
3397
+ json({ outcome: await this.evictRoom(parsed.roomId) });
3398
+ } catch (err) {
3399
+ this.log("error", "admin room evict failed", err);
3400
+ res.writeHead(500, { "content-type": "application/json" });
3401
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
3402
+ }
3403
+ })();
3404
+ });
3405
+ return true;
3406
+ }
3407
+ // ---- end M6 lane A ----
3320
3408
  case "/admin/caps": {
3321
3409
  if (req.method === "GET") {
3322
3410
  const state = this.caps.current();
@@ -4403,7 +4491,7 @@ var SupervisorImpl = class {
4403
4491
  message: `${call.playerId} is not a client of room ${room.id}; leaderboard submits are only accepted for players currently in the room`
4404
4492
  };
4405
4493
  }
4406
- await this.requireLeaderboard().submit(call.board, call.playerId, call.score);
4494
+ await this.requireLeaderboard().submit(call.board, call.playerId, call.score, call.bucket);
4407
4495
  return { ok: true };
4408
4496
  }
4409
4497
  case "ratingReport": {
@@ -5106,6 +5194,43 @@ var SupervisorImpl = class {
5106
5194
  });
5107
5195
  this.roomLog(room, "info", `room closed: ${message}`);
5108
5196
  }
5197
+ // ---- M6 lane A: rooms API ----
5198
+ /**
5199
+ * M6 lane A (D68-d): stop one room and remove its stored keys, because control is about to
5200
+ * delete the rest of its family.
5201
+ *
5202
+ * Answers `'absent'` when this tenant is not holding the room, which is not a failure and is
5203
+ * most of the real cases: the room went to sleep, or this tenant restarted, and control's row
5204
+ * was a poll behind. Control deletes the family either way.
5205
+ *
5206
+ * **The race this exists to close, and why the `await` is the fix.** `closeRoom` already deletes
5207
+ * the live key and the alarm sidecar, and refuses to run twice. What it cannot do on its own is
5208
+ * stop a hibernation that is *already in flight*: by then `hibernateRoom` has read the worker's
5209
+ * snapshot and is inside `putSnapshotWithRetry`, so its `store.put` lands after `closeRoom`'s
5210
+ * `store.delete` and after control's `deleteRoomFamily` — and the room is resurrected as a live
5211
+ * key nothing will ever delete again, because its row is gone, so the reaper cannot see it and
5212
+ * `irtio rooms` cannot list it. Awaiting `room.transition` first removes that window: every path
5213
+ * that can write a snapshot (`hibernateRoom`, `hibernateRelay`, `migrateRoom`, `wakeRoom`)
5214
+ * publishes itself there and settles before the deletes run, and no NEW one can start, because
5215
+ * they all early-return on a state that is not `running` and `closeRoom` has set `closed`.
5216
+ *
5217
+ * The two deletes are awaited, unlike `closeRoom`'s own fire-and-forget pair, for the same
5218
+ * reason: control lists the bucket the instant this answers.
5219
+ *
5220
+ * `flushAll` (tenant idle) needs no guard of its own: `closeRoom` removes the record from the
5221
+ * registry and the flush walks the registry.
5222
+ */
5223
+ async evictRoom(roomId) {
5224
+ const room = this.registry.get(roomId);
5225
+ if (room === void 0) return "absent";
5226
+ await room.transition.catch(() => void 0);
5227
+ this.closeRoom(room, "E_ROOM_DELETED", `room ${roomId} was deleted by an operator`);
5228
+ const key = this.storeKey(room);
5229
+ await this.store.delete(key).catch(() => void 0);
5230
+ await this.store.delete(alarmsKey(key)).catch(() => void 0);
5231
+ return "evicted";
5232
+ }
5233
+ // ---- end M6 lane A ----
5109
5234
  // -------------------------------------------------------------------------
5110
5235
  // Joining
5111
5236
  // -------------------------------------------------------------------------
@@ -5351,6 +5476,9 @@ var SupervisorImpl = class {
5351
5476
  this.handleVoiceFrame(room, session, payload.subarray(1));
5352
5477
  return;
5353
5478
  }
5479
+ if (type === FrameType3.MSG && isTypedMsg(payload) && !typedMsgFromClientOk(payload)) {
5480
+ return;
5481
+ }
5354
5482
  if (room.relay) {
5355
5483
  this.relayFrame(room, session, type, payload);
5356
5484
  return;
@@ -5735,6 +5863,7 @@ var SupervisorImpl = class {
5735
5863
  room.metrics.ticks = stats.ticks;
5736
5864
  room.metrics.maxTickMs = stats.maxTickMs;
5737
5865
  room.metrics.overruns = stats.overruns;
5866
+ room.metrics.messagesDropped = stats.messagesDropped;
5738
5867
  if (msg.heap !== void 0) {
5739
5868
  this.recordHeap(room, msg.heap.usedBytes, msg.heap.limitBytes);
5740
5869
  }
package/dist/index.js CHANGED
@@ -99,9 +99,21 @@ commands:
99
99
  [retire] [--url <control>] retire finishes a rotation
100
100
  keys jwt-mint --secret <s> --sub <player> sign a test token locally with the documented
101
101
  [--room <id>] [--role <r>] [--ttl <sec>] recipe \u2014 the secret never leaves this machine
102
+ rooms [--active|--idle] list a project's rooms, with the retention
103
+ [--idle-longer-than <d>] window in force and when each will be deleted
104
+ rooms get <room> one room in full: type, size class, how many
105
+ saves it holds, how big its state is, deadline
106
+ rooms set <room> --retention <d|forever|clear> override the retention window for THIS room
102
107
  rooms saves <room> list a room's save generations, newest first
103
108
  rooms restore <room> --save <id> bring a room back from a save \u2014 DISCARDS its
104
109
  current state; stops the tenant if it is live
110
+ rooms delete <room> [--force] delete a room's stored state. Refused while it
111
+ is awake unless --force, which disconnects its
112
+ clients first
113
+ api-keys mint --scopes <list> [--label <t>] create a scoped API key for this project and
114
+ print it once. Scopes: rooms:read, rooms:write
115
+ api-keys list this project's keys, revoked ones included
116
+ api-keys revoke <id> stop a key working; the row stays as a record
105
117
 
106
118
  every command takes --help (or -h) and prints its own usage, which is the authority: the lines
107
119
  above are a map, and the command's own help is generated next to the code that parses it.
@@ -112,7 +124,8 @@ default location (same as setting IRT_CREDENTIALS_FILE). Sign in once with
112
124
  commands \u2014 the pattern for agents and sandboxes that cannot reach the home directory.
113
125
 
114
126
  every command that reads the project file takes -c/--config <file> to read a different one
115
- (dev, deploy, deploy --static, migrate create, rollback, logs, rooms, status, keys). Default irtio.json;
127
+ (dev, deploy, deploy --static, migrate create, rollback, logs, rooms, status, keys, api-keys).
128
+ Default irtio.json;
116
129
  the named file must exist. That is the test.irtio.json / prod.irtio.json pattern.
117
130
  `;
118
131
  switch (command) {
@@ -122,7 +135,7 @@ switch (command) {
122
135
  break;
123
136
  }
124
137
  case "dev": {
125
- const { dev } = await import("./dev-QM26ONKS.js");
138
+ const { dev } = await import("./dev-AUZ4OLA3.js");
126
139
  await dev(args);
127
140
  break;
128
141
  }
@@ -132,18 +145,18 @@ switch (command) {
132
145
  break;
133
146
  }
134
147
  case "login": {
135
- const { login } = await import("./login-2M73HBZT.js");
148
+ const { login } = await import("./login-3RVN5PPN.js");
136
149
  await login(args);
137
150
  break;
138
151
  }
139
152
  case "whoami": {
140
- const { whoami } = await import("./whoami-KTMTQNHM.js");
153
+ const { whoami } = await import("./whoami-UFSWPWK6.js");
141
154
  await whoami(args);
142
155
  break;
143
156
  }
144
157
  case "deploy": {
145
158
  if (args.includes("--static")) {
146
- const { staticDeploy } = await import("./static-deploy-KOWFKWZA.js");
159
+ const { staticDeploy } = await import("./static-deploy-7UCYINJB.js");
147
160
  await staticDeploy(args);
148
161
  break;
149
162
  }
@@ -152,52 +165,63 @@ switch (command) {
152
165
  break;
153
166
  }
154
167
  case "migrate": {
155
- const { migrate } = await import("./migrate-T3DZJREY.js");
168
+ const { migrate } = await import("./migrate-FMXTRVUV.js");
156
169
  await migrate(args);
157
170
  break;
158
171
  }
159
172
  case "rollback": {
160
- const { rollback } = await import("./rollback-SO74MVZV.js");
173
+ const { rollback } = await import("./rollback-LI4TDLQA.js");
161
174
  await rollback(args);
162
175
  break;
163
176
  }
164
177
  case "keys": {
165
- const { keys } = await import("./keys-JHLMEGRA.js");
178
+ const { keys } = await import("./keys-NXRIBJZP.js");
166
179
  await keys(args);
167
180
  break;
168
181
  }
182
+ // ---- M6 lane A: rooms API ----
183
+ // Deliberately not folded into `keys` above: that one manages the HMAC secret a game signs its
184
+ // own player tokens with, and this one manages a credential a developer's backend authenticates
185
+ // to irtio with. Two ideas, two names, so "rotate my signing secret" is never one typo away
186
+ // from "revoke my cron job's key".
187
+ case "api-keys": {
188
+ const { apiKeys } = await import("./api-keys-UTLYMZYN.js");
189
+ await apiKeys(args);
190
+ break;
191
+ }
192
+ // ---- end M6 lane A ----
169
193
  case "logs": {
170
- const { logs } = await import("./logs-2W7CPZO5.js");
194
+ const { logs } = await import("./logs-AT7G6YRH.js");
171
195
  await logs(args);
172
196
  break;
173
197
  }
174
198
  case "rooms": {
175
- const { rooms } = await import("./rooms-VI33P4RA.js");
199
+ const { rooms } = await import("./rooms-52Q5KBUS.js");
176
200
  await rooms(args);
177
201
  break;
178
202
  }
179
203
  case "status": {
180
- const { status } = await import("./status-HF3ZEKB7.js");
204
+ const { status } = await import("./status-JZGKH2P6.js");
181
205
  await status(args);
182
206
  break;
183
207
  }
184
208
  case "delete-project": {
185
- const { deleteProject } = await import("./delete-project-VENS2B44.js");
209
+ const { deleteProject } = await import("./delete-project-MXUYNGAO.js");
186
210
  await deleteProject(args);
187
211
  break;
188
212
  }
189
213
  case "usage": {
190
- const { usage } = await import("./usage-4G23QXCH.js");
214
+ const { usage } = await import("./usage-7S447INI.js");
191
215
  await usage(args);
192
216
  break;
193
217
  }
194
218
  case "ratings": {
195
- const { ratings } = await import("./ratings-VG32WFDG.js");
219
+ const { ratings } = await import("./ratings-XBLX2MUW.js");
196
220
  await ratings(args);
197
221
  break;
198
222
  }
199
223
  case "leaderboard": {
200
- const { leaderboard } = await import("./leaderboard-SYPSBPS3.js");
224
+ const { leaderboard } = await import("./leaderboard-ZBJBKETM.js");
201
225
  await leaderboard(args);
202
226
  break;
203
227
  }
package/dist/init.js CHANGED
@@ -2,8 +2,8 @@ import {
2
2
  HelpRequested,
3
3
  helpFor,
4
4
  helpRequested
5
- } from "./chunk-ZD4ND6X6.js";
6
- import "./chunk-RNAH5T4W.js";
5
+ } from "./chunk-OCVALOGK.js";
6
+ import "./chunk-IDF46P7R.js";
7
7
  import "./chunk-UPHQM6NZ.js";
8
8
 
9
9
  // src/init.ts
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  readProjectConfig
3
- } from "./chunk-DKWG7MGO.js";
3
+ } from "./chunk-JL235KIE.js";
4
4
  import {
5
5
  helpRequested
6
- } from "./chunk-ZD4ND6X6.js";
6
+ } from "./chunk-OCVALOGK.js";
7
7
  import {
8
8
  createApiClient,
9
9
  isLoginRequired
10
- } from "./chunk-RNAH5T4W.js";
10
+ } from "./chunk-IDF46P7R.js";
11
11
  import {
12
12
  resolveControlUrlForUser
13
13
  } from "./chunk-UPHQM6NZ.js";