@irtio/cli 0.1.0 → 0.3.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/dist/index.js CHANGED
@@ -15,20 +15,41 @@ commands:
15
15
  [--room <code>] [--url <ws://\u2026>] check the built-in invariants
16
16
  [--cheat] [--key <projectKey>] --cheat sends illegal writes and expects
17
17
  [--trace <path>] corrections; --trace dumps every frame
18
+ [--misprediction-max <units>] fail the run if one correction snaps a
19
+ [--snaps-max <n>] prediction further than this, if more than
20
+ [--corrections-max <perSec>] <n> corrections fall outside the resim
21
+ window, or above this correction rate/bot
18
22
 
19
23
  login [--url <control>] sign in to a control plane via the browser
20
24
  whoami [--url <control>] print the identity the stored credential
21
25
  resolves to, and the control URL asked
22
26
  deploy [--allow-breaking] [--project <id>] bundle, classify the schema change against the
23
27
  [--url <control>] [--room <file>] last deployment, and upload \u2014 refuses breaking
24
- changes unless a migration covers them
28
+ [--strategy drain|migrate] changes unless a migration covers them;
29
+ migrate moves LIVE rooms onto the new version
30
+ now (drain, the default, lets them finish)
31
+ deploy --static <dir> [--project <id>] upload a BUILT client directory to the project's
32
+ [--label <label>] [--url <control>] static site and print the playable link
33
+ (<label>.irt-serve.com; no build step)
25
34
  migrate create <name> scaffold irtio/migrations/<n>_<name>.ts with the
26
35
  live breaking changes quoted in its header
27
- logs [--follow] [--since <cursor>] tail a project's logs
28
- [--project <id>] [--url <control>]
36
+ rollback <version> [--project <id>] re-promote deployment <version> and bring rooms
37
+ [--url <control>] that migrated past it back to their
38
+ pre-migration state \u2014 DISCARDS what they wrote
39
+ since their migration
40
+ logs [--follow] [--since <cursor>] tail a project's logs, oldest line first;
41
+ [--room <id>] [--project <id>] --room narrows to a single room's stream
42
+ [--url <control>]
29
43
  rooms [--project <id>] [--url <control>] list a project's rooms (rows not reported for
30
44
  longer than the control plane's retention
31
45
  window, 30 days by default, are pruned)
46
+ keys jwt-secret [--project <id>] mint a JWT signing secret (printed ONCE);
47
+ [retire] [--url <control>] retire finishes a rotation
48
+ keys jwt-mint --secret <s> --sub <player> sign a test token locally with the documented
49
+ [--room <id>] [--role <r>] [--ttl <sec>] recipe \u2014 the secret never leaves this machine
50
+ rooms saves <room> list a room's save generations, newest first
51
+ rooms restore <room> --save <id> bring a room back from a save \u2014 DISCARDS its
52
+ current state; stops the tenant if it is live
32
53
  `;
33
54
  switch (command) {
34
55
  case "init": {
@@ -37,7 +58,7 @@ switch (command) {
37
58
  break;
38
59
  }
39
60
  case "dev": {
40
- const { dev } = await import("./dev-BFWFWJ4J.js");
61
+ const { dev } = await import("./dev-LOSTB4CD.js");
41
62
  await dev(args);
42
63
  break;
43
64
  }
@@ -47,32 +68,47 @@ switch (command) {
47
68
  break;
48
69
  }
49
70
  case "login": {
50
- const { login } = await import("./login-US2YT5ZB.js");
71
+ const { login } = await import("./login-OV2EFNTJ.js");
51
72
  await login(args);
52
73
  break;
53
74
  }
54
75
  case "whoami": {
55
- const { whoami } = await import("./whoami-5Q32SXFX.js");
76
+ const { whoami } = await import("./whoami-S73O6KJF.js");
56
77
  await whoami(args);
57
78
  break;
58
79
  }
59
80
  case "deploy": {
60
- const { deploy } = await import("./deploy-4W6AYYEW.js");
81
+ if (args.includes("--static")) {
82
+ const { staticDeploy } = await import("./static-deploy-2LB6H54Y.js");
83
+ await staticDeploy(args);
84
+ break;
85
+ }
86
+ const { deploy } = await import("./deploy-6OM3UYNL.js");
61
87
  await deploy(args);
62
88
  break;
63
89
  }
64
90
  case "migrate": {
65
- const { migrate } = await import("./migrate-FQIDF747.js");
91
+ const { migrate } = await import("./migrate-3R6VRW4J.js");
66
92
  await migrate(args);
67
93
  break;
68
94
  }
95
+ case "rollback": {
96
+ const { rollback } = await import("./rollback-CLQVYFHW.js");
97
+ await rollback(args);
98
+ break;
99
+ }
100
+ case "keys": {
101
+ const { keys } = await import("./keys-KEKO3EJ6.js");
102
+ await keys(args);
103
+ break;
104
+ }
69
105
  case "logs": {
70
- const { logs } = await import("./logs-6GLBO3TP.js");
106
+ const { logs } = await import("./logs-5OUDPAIX.js");
71
107
  await logs(args);
72
108
  break;
73
109
  }
74
110
  case "rooms": {
75
- const { rooms } = await import("./rooms-JXT3PRHN.js");
111
+ const { rooms } = await import("./rooms-OJ3JLYHD.js");
76
112
  await rooms(args);
77
113
  break;
78
114
  }
package/dist/init.d.ts CHANGED
@@ -19,12 +19,15 @@ interface InitArgs {
19
19
  readonly dir?: string;
20
20
  /** Set by `--tick` / `--event`; absent means "ask". */
21
21
  readonly mode?: RoomMode;
22
+ /** `--physics`: a Rapier room with a shared world-builder and client prediction (D22). */
23
+ readonly physics?: boolean;
22
24
  }
23
25
  interface RunInitOptions {
24
26
  /** Where the project lives. Defaults to `process.cwd()`. */
25
27
  readonly cwd?: string;
26
28
  readonly dir?: string;
27
29
  readonly mode?: RoomMode;
30
+ readonly physics?: boolean;
28
31
  /**
29
32
  * Asks the one question. Injected so tests never touch a TTY; the default reads stdin when it
30
33
  * is a TTY and otherwise takes the default answer rather than hanging in a pipeline.
package/dist/init.js CHANGED
@@ -17,6 +17,9 @@ function parseInitArgs(args) {
17
17
  const parsed = {};
18
18
  for (const arg of args) {
19
19
  switch (arg) {
20
+ case "--physics":
21
+ parsed.physics = true;
22
+ break;
20
23
  case "--tick":
21
24
  case "--event": {
22
25
  const mode = arg === "--tick" ? "tick" : "event";
@@ -36,6 +39,9 @@ function parseInitArgs(args) {
36
39
  }
37
40
  }
38
41
  }
42
+ if (parsed.physics && parsed.mode === "event") {
43
+ throw new Error("irtio init: --physics needs tick mode \u2014 the world steps on the fixed tick");
44
+ }
39
45
  return parsed;
40
46
  }
41
47
  function generateProjectId() {
@@ -138,6 +144,205 @@ ${config}
138
144
  ${tick}});
139
145
  `;
140
146
  }
147
+ function physicsSchemaTemplate(project) {
148
+ return `/**
149
+ * Shared facts about the data. Both sides import this file \u2014 the room to run on, your game
150
+ * to draw from \u2014 so it also carries the project id, which is the public key: domain-locked, never
151
+ * secret, safe in client code.
152
+ */
153
+
154
+ import { defineSchema, entity, f32, str } from '@irtio/schema';
155
+
156
+ import { rpc } from './rpc.js';
157
+
158
+ export const schema = defineSchema(
159
+ {
160
+ // One ball per connected client. The \`physics\` option is the whole idea: the simulation
161
+ // owns the body fields (position and velocity \u2014 read-only everywhere else, synced from the
162
+ // world every tick), and the owner writes only the intents (\`ax\`/\`az\`, a steering axis).
163
+ // Map the velocity channels too: corrections can only rebase what the schema carries, and a
164
+ // predicted body drifts without them.
165
+ players: entity(
166
+ { x: f32, y: f32, z: f32, vx: f32, vy: f32, vz: f32, ax: f32, az: f32, name: str(24) },
167
+ {
168
+ physics: {
169
+ body: { x: 'x', y: 'y', z: 'z', vx: 'vx', vy: 'vy', vz: 'vz' },
170
+ intents: ['ax', 'az'],
171
+ },
172
+ },
173
+ ),
174
+ },
175
+ {
176
+ project: '${project}',
177
+ rpc,
178
+ },
179
+ );
180
+ `;
181
+ }
182
+ var WORLD_TEMPLATE = `/**
183
+ * The shared world-builder (D22): one module, imported by BOTH sides. The room's physics config
184
+ * uses these exports to build the authoritative world; the client passes the same exports to
185
+ * \`joinRoom({ physics })\` and predicts against an identical local world. "Same code both
186
+ * sides" is the geometry contract \u2014 this file's name is only the convention \`irtio init\`
187
+ * follows, not a requirement.
188
+ *
189
+ * Two rules keep it honest:
190
+ * - **Pure over synced inputs.** No \`Math.random()\`, no clock, no module state \u2014 put seeds
191
+ * and level parameters in synced state, so both sides build bit-identical worlds
192
+ * (\`irtio simulate\` builds it twice and compares).
193
+ * - **Never import room.ts from here or from client code.** Room code is server-only; shared
194
+ * code lives in modules like this one. (\`irtio deploy\` warns if the client's import graph
195
+ * reaches the room file.)
196
+ */
197
+
198
+ import type RAPIER from '@dimforge/rapier3d-compat';
199
+
200
+ type Rapier = typeof RAPIER;
201
+ type World = RAPIER.World;
202
+ type Body = RAPIER.RigidBody;
203
+
204
+ export const gravity = { x: 0, y: -9.81, z: 0 } as const;
205
+
206
+ export const ARENA_HALF = 20;
207
+ export const BALL_RADIUS = 0.5;
208
+ /** Horizontal impulse per tick at full stick. */
209
+ export const NUDGE = 0.4;
210
+
211
+ /** Static geometry: a floor and four walls. */
212
+ export function setup(world: World, rapier: Rapier): void {
213
+ const floor = world.createRigidBody(rapier.RigidBodyDesc.fixed().setTranslation(0, -0.5, 0));
214
+ world.createCollider(rapier.ColliderDesc.cuboid(ARENA_HALF + 2, 0.5, ARENA_HALF + 2), floor);
215
+ for (const [x, z] of [
216
+ [ARENA_HALF + 1, 0],
217
+ [-(ARENA_HALF + 1), 0],
218
+ [0, ARENA_HALF + 1],
219
+ [0, -(ARENA_HALF + 1)],
220
+ ] as const) {
221
+ const wall = world.createRigidBody(rapier.RigidBodyDesc.fixed().setTranslation(x, 5, z));
222
+ world.createCollider(
223
+ rapier.ColliderDesc.cuboid(x === 0 ? ARENA_HALF + 2 : 1, 6, z === 0 ? ARENA_HALF + 2 : 1),
224
+ wall,
225
+ );
226
+ }
227
+ }
228
+
229
+ /** How one instance becomes a rigid body \u2014 shape and material only; \`add()\` places it. */
230
+ export const bodies = {
231
+ players: (rapier: Rapier) => ({
232
+ body: rapier.RigidBodyDesc.dynamic().lockRotations(),
233
+ colliders: [rapier.ColliderDesc.ball(BALL_RADIUS).setRestitution(0.3)],
234
+ }),
235
+ };
236
+
237
+ /** Intent \u2192 force, once per step. The room's tick() and the client's predictor both call this. */
238
+ export const intents = {
239
+ players: (body: Body, player: { ax: number; az: number }): void => {
240
+ if (player.ax === 0 && player.az === 0) return;
241
+ body.applyImpulse({ x: player.ax * NUDGE, y: 0, z: player.az * NUDGE }, true);
242
+ },
243
+ };
244
+ `;
245
+ function physicsRoomTemplate() {
246
+ return `/**
247
+ * Behavior. Server-only: \`irtio dev\` and \`irtio deploy\` bundle this file, and it never
248
+ * reaches the browser. The world's geometry, shapes and intent handling come from the shared
249
+ * world module, so the client predicts with exactly the code the server runs.
250
+ */
251
+
252
+ import { defineRoom } from '@irtio/server';
253
+
254
+ import { schema } from './schema.js';
255
+ import { bodies, gravity, intents, setup } from './world.js';
256
+
257
+ export default defineRoom(schema, {
258
+ mode: 'tick',
259
+ tickRate: ${DEFAULT_TICK_RATE},
260
+
261
+ physics: {
262
+ engine: 'rapier3d',
263
+ gravity: { ...gravity },
264
+ setup(world, rapier) {
265
+ setup(world, rapier);
266
+ },
267
+ bodies,
268
+ },
269
+
270
+ onJoin(state, ctx) {
271
+ if (ctx.reconnecting || state.players.has(ctx.clientId)) return;
272
+ // Body fields are the spawn pose: add() places the body. Deterministic spawn spots, spread
273
+ // apart \u2014 overlapping spawns resolve against each other, and another player's body is not
274
+ // in your local prediction world.
275
+ state.players.add(
276
+ ctx.clientId,
277
+ {
278
+ x: (state.players.size % 5) * 6 - 12,
279
+ y: 4,
280
+ z: Math.floor(state.players.size / 5) * 6 - 6,
281
+ vx: 0,
282
+ vy: 0,
283
+ vz: 0,
284
+ ax: 0,
285
+ az: 0,
286
+ name: ctx.name || 'anon',
287
+ },
288
+ { owner: ctx.clientId },
289
+ );
290
+ },
291
+
292
+ onLeave(state, ctx) {
293
+ state.players.remove(ctx.clientId);
294
+ },
295
+
296
+ // Intents are the only thing a client can write on a physics entity; clamp them here.
297
+ validate: {
298
+ players: (_prev, next) => ({
299
+ ...next,
300
+ ax: Math.max(-1, Math.min(1, next.ax)),
301
+ az: Math.max(-1, Math.min(1, next.az)),
302
+ }),
303
+ },
304
+
305
+ tick(state, _dt, room) {
306
+ for (const [id, player] of state.players) {
307
+ const body = room.physics.body('players', id);
308
+ if (body) intents.players(body, player);
309
+ }
310
+ },
311
+ });
312
+ `;
313
+ }
314
+ var PHYSICS_ROOM_TEST_TEMPLATE = `/**
315
+ * The smoke test \`irtio init --physics\` scaffolds. The engine's WASM needs one await before
316
+ * any room exists \u2014 the worker host does it in production, the test does it here.
317
+ */
318
+
319
+ import { beforeAll, expect, test } from 'vitest';
320
+
321
+ import { initPhysics, testRoom } from '@irtio/testing';
322
+ import '@irtio/testing/matchers';
323
+
324
+ import room from './room.js';
325
+
326
+ beforeAll(async () => {
327
+ await initPhysics();
328
+ });
329
+
330
+ test('a player joins, falls to the floor, and steers with an intent', async () => {
331
+ const t = await testRoom(room);
332
+ const [a] = await t.join(1);
333
+ await t.tick(60);
334
+
335
+ const ball = t.state.players.get(a!.me);
336
+ expect(ball).toBeDefined();
337
+ expect(ball!.y).toBeLessThan(1); // it fell and came to rest on the floor
338
+
339
+ const before = ball!.x;
340
+ a!.state.players.get(a!.me)!.ax = 1;
341
+ a!.flush();
342
+ await t.tick(20);
343
+ expect(t.state.players.get(a!.me)!.x).toBeGreaterThan(before); // the intent moved it
344
+ });
345
+ `;
141
346
  var ROOM_TEST_TEMPLATE = `/**
142
347
  * The smoke test \`irtio init\` scaffolds: two clients join, one writes a field it owns, and every
143
348
  * view agrees with the server. \`testRoom\` runs the real room and real client semantics in
@@ -211,7 +416,7 @@ async function defaultAsk(question) {
211
416
  rl.close();
212
417
  }
213
418
  }
214
- async function updatePackageJson(file) {
419
+ async function updatePackageJson(file, wanted) {
215
420
  const source = await readFile(file, "utf8");
216
421
  let parsed;
217
422
  try {
@@ -221,7 +426,7 @@ async function updatePackageJson(file) {
221
426
  }
222
427
  const deps = { ...parsed.dependencies ?? {} };
223
428
  let changed = false;
224
- for (const name of INIT_DEPENDENCIES) {
429
+ for (const name of wanted) {
225
430
  if (deps[name] === void 0) {
226
431
  deps[name] = "^0.0.0";
227
432
  changed = true;
@@ -240,8 +445,11 @@ async function runInit(options = {}) {
240
445
  const dir = path.resolve(cwd, options.dir ?? ".");
241
446
  const log = options.log ?? ((line) => console.log(line));
242
447
  const ask = options.ask ?? defaultAsk;
448
+ const physics = options.physics === true;
243
449
  let mode = options.mode;
244
- if (mode === void 0) {
450
+ if (physics) {
451
+ mode = "tick";
452
+ } else if (mode === void 0) {
245
453
  const answer = (await ask("does anything move without a player doing something? (y/n) ")).trim().toLowerCase();
246
454
  mode = answer.startsWith("n") ? "event" : "tick";
247
455
  }
@@ -255,7 +463,15 @@ async function runInit(options = {}) {
255
463
  } catch {
256
464
  }
257
465
  }
258
- const files = [
466
+ const files = physics ? [
467
+ ["irtio/schema.ts", physicsSchemaTemplate(project)],
468
+ ["irtio/rpc.ts", RPC_TEMPLATE],
469
+ ["irtio/world.ts", WORLD_TEMPLATE],
470
+ ["irtio/room.ts", physicsRoomTemplate()],
471
+ ["irtio/room.test.ts", PHYSICS_ROOM_TEST_TEMPLATE],
472
+ ["irtio.json", `${JSON.stringify({ project }, null, 2)}
473
+ `]
474
+ ] : [
259
475
  ["irtio/schema.ts", schemaTemplate(project)],
260
476
  ["irtio/rpc.ts", RPC_TEMPLATE],
261
477
  ["irtio/room.ts", roomTemplate(mode)],
@@ -275,15 +491,26 @@ async function runInit(options = {}) {
275
491
  await writeFile(file, contents, "utf8");
276
492
  created.push(relative);
277
493
  }
494
+ const wantedDeps = physics ? [...INIT_DEPENDENCIES, "@dimforge/rapier3d-compat"] : [...INIT_DEPENDENCIES];
278
495
  const packageFile = path.join(dir, "package.json");
279
- const packageJson = existsSync(packageFile) ? await updatePackageJson(packageFile) : "absent";
496
+ const packageJson = existsSync(packageFile) ? await updatePackageJson(packageFile, wantedDeps) : "absent";
280
497
  if (created.length > 0) log(pc.green(`created ${created.join(", ")}`));
281
498
  if (skipped.length > 0) {
282
499
  log(pc.yellow(`kept ${skipped.join(", ")} (already there \u2014 nothing was overwritten)`));
283
500
  }
284
- log(pc.dim(`project ${project} \xB7 ${mode} mode`));
285
- const installCommand = packageJson === "absent" ? `npm i ${INIT_DEPENDENCIES.join(" ")}` : "npm install # the four irtio packages were added to your package.json";
501
+ log(pc.dim(`project ${project} \xB7 ${mode} mode${physics ? " \xB7 physics" : ""}`));
502
+ const installCommand = packageJson === "absent" ? `npm i ${wantedDeps.join(" ")}` : "npm install # the irtio packages were added to your package.json";
286
503
  for (const line of nextSteps(installCommand)) log(line);
504
+ if (physics) {
505
+ log(" physics: the world lives in irtio/world.ts, imported by BOTH sides. The client");
506
+ log(" predicts its own body with the same code \u2014 pass it to joinRoom:");
507
+ log("");
508
+ log(pc.cyan(" import * as world from './irtio/world.js';"));
509
+ log(pc.cyan(" const room = await joinRoom(schema, { physics: world });"));
510
+ log("");
511
+ log(" draw from room.render (predicted + interpolated), write intents via room.state.");
512
+ log("");
513
+ }
287
514
  return { dir, project, mode, created, skipped, packageJson };
288
515
  }
289
516
  async function init(args) {
@@ -291,7 +518,8 @@ async function init(args) {
291
518
  const parsed = parseInitArgs(args);
292
519
  await runInit({
293
520
  ...parsed.dir !== void 0 ? { dir: parsed.dir } : {},
294
- ...parsed.mode !== void 0 ? { mode: parsed.mode } : {}
521
+ ...parsed.mode !== void 0 ? { mode: parsed.mode } : {},
522
+ ...parsed.physics !== void 0 ? { physics: parsed.physics } : {}
295
523
  });
296
524
  } catch (err) {
297
525
  console.error(pc.red(err instanceof Error ? err.message : String(err)));
@@ -0,0 +1,174 @@
1
+ import {
2
+ createApiClient,
3
+ isLoginRequired
4
+ } from "./chunk-I37DLT7K.js";
5
+ import {
6
+ resolveControlUrlForUser
7
+ } from "./chunk-NVUKSP5U.js";
8
+
9
+ // src/keys.ts
10
+ import { existsSync } from "fs";
11
+ import { readFile } from "fs/promises";
12
+ import * as path from "path";
13
+ import pc from "picocolors";
14
+
15
+ // src/jwt.ts
16
+ import { createHmac } from "crypto";
17
+ function signJwt(secret, claims) {
18
+ const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
19
+ const payload = Buffer.from(JSON.stringify(claims)).toString("base64url");
20
+ const signature = createHmac("sha256", secret).update(`${header}.${payload}`).digest("base64url");
21
+ return `${header}.${payload}.${signature}`;
22
+ }
23
+
24
+ // src/keys.ts
25
+ async function readIrtioJsonProject(cwd) {
26
+ const file = path.join(cwd, "irtio.json");
27
+ if (!existsSync(file)) return void 0;
28
+ let parsed;
29
+ try {
30
+ parsed = JSON.parse(await readFile(file, "utf8"));
31
+ } catch {
32
+ throw new Error(`irtio keys: ${file} is not valid JSON`);
33
+ }
34
+ return typeof parsed.project === "string" && parsed.project.length > 0 ? parsed.project : void 0;
35
+ }
36
+ function flagValue(args, flag) {
37
+ const i = args.indexOf(flag);
38
+ if (i === -1) return void 0;
39
+ const value = args[i + 1];
40
+ if (value === void 0) throw new Error(`irtio keys: ${flag} needs a value`);
41
+ return value;
42
+ }
43
+ async function resolveProject(args) {
44
+ const project = flagValue(args, "--project") ?? await readIrtioJsonProject(process.cwd());
45
+ if (project === void 0) {
46
+ throw new Error(
47
+ "irtio keys: no project id \u2014 pass --project or run this from a project with irtio.json"
48
+ );
49
+ }
50
+ return project;
51
+ }
52
+ var DEFAULT_ISSUER = "main";
53
+ async function runJwtSecret(options) {
54
+ const log = options.log ?? ((line) => console.log(line));
55
+ const controlUrl = await resolveControlUrlForUser(options.controlUrl);
56
+ const client = options.client ?? await createApiClient(controlUrl);
57
+ const issuer = options.issuer ?? DEFAULT_ISSUER;
58
+ const result = await client.post(`/v1/projects/${options.project}/jwt-secret`, {
59
+ issuer
60
+ });
61
+ log(pc.green(`minted a JWT signing secret for ${options.project} (issuer ${result.issuer})`));
62
+ log("");
63
+ log(` ${pc.bold(result.secret)}`);
64
+ log("");
65
+ log(pc.yellow("this is the only time you will see it \u2014 store it on your server now"));
66
+ if (result.active > 1) {
67
+ log(
68
+ pc.dim(
69
+ "two secrets are now active: tokens signed with either verify. Move your server to this one, then run `irtio keys jwt-secret retire` to finish the rotation."
70
+ )
71
+ );
72
+ }
73
+ log(
74
+ pc.dim(
75
+ "the tenant picks the secret up at its next placement (a stop and start), not mid-flight"
76
+ )
77
+ );
78
+ return result;
79
+ }
80
+ async function runJwtSecretRetire(options) {
81
+ const log = options.log ?? ((line) => console.log(line));
82
+ const controlUrl = await resolveControlUrlForUser(options.controlUrl);
83
+ const client = options.client ?? await createApiClient(controlUrl);
84
+ const result = await client.post(
85
+ `/v1/projects/${options.project}/jwt-secret/retire`,
86
+ { issuer: options.issuer ?? DEFAULT_ISSUER }
87
+ );
88
+ log(pc.green(`retired the oldest active secret; ${result.active} remain(s) active`));
89
+ log(pc.dim("tokens signed with the retired secret stop verifying at the next placement"));
90
+ }
91
+ async function keys(args, deps = {}) {
92
+ const log = deps.log ?? ((line) => console.log(line));
93
+ const errorLog = deps.errorLog ?? ((line) => console.error(line));
94
+ try {
95
+ const sub = args[0];
96
+ const rest = args.slice(1);
97
+ if (sub === "jwt-secret" && rest[0] === "retire") {
98
+ const project = await resolveProject(rest);
99
+ const url = flagValue(rest, "--url");
100
+ const issuer = flagValue(rest, "--issuer");
101
+ await runJwtSecretRetire({
102
+ project,
103
+ log,
104
+ ...issuer !== void 0 ? { issuer } : {},
105
+ ...url !== void 0 ? { controlUrl: url } : {},
106
+ ...deps.client !== void 0 ? { client: deps.client } : {}
107
+ });
108
+ return;
109
+ }
110
+ if (sub === "jwt-secret") {
111
+ const project = await resolveProject(rest);
112
+ const url = flagValue(rest, "--url");
113
+ const issuer = flagValue(rest, "--issuer");
114
+ await runJwtSecret({
115
+ project,
116
+ log,
117
+ ...issuer !== void 0 ? { issuer } : {},
118
+ ...url !== void 0 ? { controlUrl: url } : {},
119
+ ...deps.client !== void 0 ? { client: deps.client } : {}
120
+ });
121
+ return;
122
+ }
123
+ if (sub === "jwt-mint") {
124
+ const secret = flagValue(rest, "--secret");
125
+ const subject = flagValue(rest, "--sub");
126
+ if (secret === void 0 || subject === void 0) {
127
+ throw new Error("irtio keys jwt-mint: --secret <secret> and --sub <playerId> are required");
128
+ }
129
+ const ttl = Number(flagValue(rest, "--ttl") ?? 3600);
130
+ if (!Number.isFinite(ttl) || ttl <= 0) {
131
+ throw new Error("irtio keys jwt-mint: --ttl must be a positive number of seconds");
132
+ }
133
+ const room = flagValue(rest, "--room");
134
+ const role = flagValue(rest, "--role");
135
+ const iss = flagValue(rest, "--iss") ?? DEFAULT_ISSUER;
136
+ const project = flagValue(rest, "--project") ?? await readIrtioJsonProject(process.cwd());
137
+ if (project === void 0) {
138
+ throw new Error(
139
+ "irtio keys jwt-mint: --project is required (aud must name the project the token is for)"
140
+ );
141
+ }
142
+ const now = Math.floor(Date.now() / 1e3);
143
+ const token = signJwt(secret, {
144
+ iss,
145
+ aud: project,
146
+ sub: subject,
147
+ iat: now,
148
+ exp: now + Math.floor(ttl),
149
+ ...room !== void 0 ? { roomId: room } : {},
150
+ ...role !== void 0 ? { role } : {}
151
+ });
152
+ log(token);
153
+ return;
154
+ }
155
+ throw new Error(
156
+ "irtio keys: unknown subcommand \u2014 try `keys jwt-secret`, `keys jwt-secret retire`, or `keys jwt-mint`"
157
+ );
158
+ } catch (err) {
159
+ if (isLoginRequired(err)) {
160
+ errorLog(pc.red("not logged in"));
161
+ errorLog("run: irtio login");
162
+ process.exitCode = 1;
163
+ return;
164
+ }
165
+ errorLog(pc.red(err instanceof Error ? err.message : String(err)));
166
+ process.exitCode = 1;
167
+ }
168
+ }
169
+ export {
170
+ DEFAULT_ISSUER,
171
+ keys,
172
+ runJwtSecret,
173
+ runJwtSecretRetire
174
+ };
@@ -2,7 +2,7 @@ import {
2
2
  credentialsPath,
3
3
  resolveControlUrl,
4
4
  writeCredential
5
- } from "./chunk-UYN5PWLT.js";
5
+ } from "./chunk-NVUKSP5U.js";
6
6
 
7
7
  // src/login.ts
8
8
  import { spawn } from "child_process";
@@ -49,6 +49,11 @@ async function runLogin(options = {}) {
49
49
  const openBrowser = options.openBrowser ?? defaultOpenBrowser;
50
50
  const timeoutMs = options.timeoutMs ?? LOGIN_TIMEOUT_MS;
51
51
  const state = randomBytes(8).toString("hex");
52
+ const allowedOrigin = new URL(controlUrl).origin;
53
+ const corsHeaders = {
54
+ "access-control-allow-origin": allowedOrigin,
55
+ vary: "origin"
56
+ };
52
57
  return new Promise((resolve, reject) => {
53
58
  let settled = false;
54
59
  const finish = (fn) => {
@@ -64,7 +69,23 @@ async function runLogin(options = {}) {
64
69
  res.writeHead(403).end();
65
70
  return;
66
71
  }
67
- if (req.method !== "POST" || req.url?.split("?")[0] !== "/token") {
72
+ if (req.url?.split("?")[0] !== "/token") {
73
+ res.writeHead(404).end();
74
+ return;
75
+ }
76
+ if (req.method === "OPTIONS") {
77
+ const headers = {
78
+ ...corsHeaders,
79
+ "access-control-allow-methods": "POST",
80
+ "access-control-allow-headers": "content-type"
81
+ };
82
+ if (req.headers["access-control-request-private-network"] === "true") {
83
+ headers["access-control-allow-private-network"] = "true";
84
+ }
85
+ res.writeHead(204, headers).end();
86
+ return;
87
+ }
88
+ if (req.method !== "POST") {
68
89
  res.writeHead(404).end();
69
90
  return;
70
91
  }
@@ -72,7 +93,7 @@ async function runLogin(options = {}) {
72
93
  const body = await readBody(req);
73
94
  const parsed = JSON.parse(body);
74
95
  if (typeof parsed.token !== "string" || typeof parsed.expiresAt !== "string") {
75
- res.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: "expected { token, expiresAt }" }));
96
+ res.writeHead(400, { "content-type": "application/json", ...corsHeaders }).end(JSON.stringify({ error: "expected { token, expiresAt }" }));
76
97
  return;
77
98
  }
78
99
  const credential = {
@@ -81,10 +102,10 @@ async function runLogin(options = {}) {
81
102
  ...typeof parsed.email === "string" && parsed.email !== "" ? { email: parsed.email } : {}
82
103
  };
83
104
  const file = await writeCredential(controlUrl, credential);
84
- res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true }));
105
+ res.writeHead(200, { "content-type": "application/json", ...corsHeaders }).end(JSON.stringify({ ok: true }));
85
106
  finish(() => resolve({ controlUrl, email: credential.email, file }));
86
107
  } catch (err) {
87
- res.writeHead(500).end();
108
+ res.writeHead(500, corsHeaders).end();
88
109
  finish(() => reject(err instanceof Error ? err : new Error(String(err))));
89
110
  }
90
111
  })();