room-kit 1.0.2 → 1.0.6

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.
@@ -0,0 +1,177 @@
1
+ # harness-rules.toml — policy for what the AI agent can do in this project.
2
+ # Commit this file to your repository. harness-hat reads it but never pushes
3
+ # changes back during workspace sync.
4
+ #
5
+ # Agents/LLMs are not permitted to edit this file directly. Harness Hat monitors
6
+ # this policy file and will notify the user if an agent attempts to modify it.
7
+ #
8
+ # Preferred place for *human/LLM instructions*:
9
+ # llm_instructions = """
10
+ # Environment:
11
+ # - You are operating inside a Linux Docker container managed by harness-hat.
12
+ # - Your workspace is mounted into the container; edits usually persist to the host.
13
+ # - Do not assume host tools, credentials, or services are directly available in the container.
14
+ #
15
+ # Host-side commands:
16
+ # - Use `hostdo ...` when you need host-side build/package tooling such as cargo,
17
+ # npm, pnpm, yarn, go, make, pytest, or similar commands.
18
+ # - Examples: `hostdo cargo test`, `hostdo npm install`, `hostdo go test ./...`.
19
+ # - Only use `hostdo --image <docker-image> ...` when the user explicitly asks
20
+ # you to run against a Docker image or containerized runner.
21
+ # - Use `hostdo --timeout <seconds> ...` when the user explicitly asks for a
22
+ # longer or shorter host-side command timeout, or when a command exits before
23
+ # finishing and clearly needs more time than the default rule allows.
24
+ # - Examples: `hostdo --timeout 120 cargo test`,
25
+ # `hostdo --timeout 1800 npm run build`.
26
+ # - `hostdo --image` runs a command in a short-lived Docker runner instead of
27
+ # directly on the host.
28
+ # - Examples: `hostdo --image node:20 npm test`,
29
+ # `hostdo --image rust:1.88 cargo test`.
30
+ # - `hostdo` requests are policy checked against the `[hostdo]` rules below and may
31
+ # prompt the developer.
32
+ # - Prefer existing auto-approved `hostdo` commands or `hostdo.command_aliases`
33
+ # before asking for a new host command approval.
34
+ #
35
+ # Subagents:
36
+ # - Run `agentctl list` first to see the configured subagent profiles available
37
+ # in this environment. Use the profile name from the first column; do not
38
+ # guess hardcoded names such as `codex`, `claude`, or `qwen`.
39
+ # - Use `agentctl spawn <profile> [--name <child>]` to start a same-workspace
40
+ # subagent from one of those configured container profiles.
41
+ # - After spawning, give the child a task with
42
+ # `agentctl send <child> "task prompt" --enter`.
43
+ # - A typical sequence is `agentctl list`, `agentctl spawn <profile> --name review`,
44
+ # `agentctl status review`, `agentctl tail review --rows 30`, then
45
+ # `agentctl send review "inspect the failing test" --enter`.
46
+ # - Use `agentctl spawn-many <profile> <count> --prefix <name>` for larger
47
+ # batches; launches are paced by `[agentctl].spawn_delay_ms` and never below
48
+ # 100ms between spawn requests.
49
+ # - Codex subagent launches additionally wait for the previous Codex launch to
50
+ # fail MCP startup, clear MCP startup, sit waiting without MCP diagnostics for
51
+ # a short stable window, or reach the 35s diagnostic timeout.
52
+ # - `[agentctl].max_subagents` caps live descendants under a single top-level
53
+ # agent, including subagents, sub-subagents, and deeper descendants.
54
+ # - Use `agentctl status <child>`, `agentctl tail <child> --rows 30`,
55
+ # `agentctl tail <child> --all`, `agentctl send <child> "text" --enter`,
56
+ # `agentctl send <child> --key enter`, and `agentctl stop <child>` to inspect
57
+ # and control direct child agents.
58
+ # - If `agentctl list` reports `image-missing`, the profile exists but its
59
+ # Docker image must be built or pulled before `agentctl spawn` will work.
60
+ # - Subagent names are scoped to the parent that created them; duplicate names
61
+ # may exist elsewhere in the tree.
62
+ #
63
+ # Container lifecycle:
64
+ # - Use `killme` only when the user explicitly asks you to end this container.
65
+ # """
66
+ #
67
+ # ── Hostdo (host-side command execution) ─────────────────────────────────────
68
+ #
69
+ # default_policy: what happens when a command doesn't match any rule below.
70
+ # auto — run without prompting (use with caution)
71
+ # prompt — ask the developer in the TUI (default)
72
+ # deny — reject silently
73
+ #
74
+ # Passthrough command (exact argv match, auto-approved):
75
+ # [[hostdo.commands]]
76
+ # argv = ["cargo", "test"] # run inside container with `hostdo cargo test`
77
+ # cwd = "$WORKSPACE" # execution cwd only, not part of approval matching
78
+ # timeout_secs = 60
79
+ # approval_mode = "auto"
80
+ #
81
+ # Short-lived Docker runner (exact argv + image match, auto-approved):
82
+ # [[hostdo.commands]]
83
+ # argv = ["npm", "test"] # run with `hostdo --image node:20 npm test`
84
+ # image = "node:20"
85
+ # cwd = "$WORKSPACE"
86
+ # timeout_secs = 60
87
+ # approval_mode = "auto"
88
+ #
89
+ # Command alias (agent sends `hostdo tests`, expands server-side):
90
+ # [hostdo.command_aliases]
91
+ # tests = "cargo test" # run inside container with `hostdo test`
92
+ # build = { cmd = "cargo build --release", cwd = "$WORKSPACE" }
93
+ #
94
+ # $WORKSPACE = workspace path on the host.
95
+ #
96
+ # ── Agentctl helper defaults ────────────────────────────────────────────────
97
+ #
98
+ # `agentctl spawn` and `spawn-many` read this value from the workspace rules file
99
+ # when `--delay-ms` is omitted. The effective delay is clamped to at least 100ms.
100
+ # `max_subagents` limits live descendants under a single top-level agent.
101
+ # [agentctl]
102
+ # spawn_delay_ms = 500
103
+ # max_subagents = 10
104
+
105
+ # ── Network (HTTP/HTTPS proxy policy) ────────────────────────────────────────
106
+ #
107
+ # Coder-style network rules. Deny matches win over allow matches; if no rule
108
+ # matches, request is prompted.
109
+ # Rule format:
110
+ # method=GET,POST domain=api.example.com path=/v1/*,/health port=443,8443
111
+ #
112
+ # Use [network].allowlist for permanent allows and [network].denylist for
113
+ # permanent denies.
114
+ #
115
+ # Domain matching:
116
+ # - `domain=example.com` exact only
117
+ # - `domain=*.example.com` subdomains only (not the apex)
118
+ #
119
+ # Path matching:
120
+ # - exact (`/v1/users`)
121
+ # - wildcard (`/v1/*`)
122
+ #
123
+ # Port matching:
124
+ # - omitted port matches normal HTTP/HTTPS requests on any port
125
+ # - CONNECT allow rules without `port=` only auto-approve port 443
126
+ # - raw CONNECT to other ports requires an explicit `port=...` allow rule
127
+
128
+ version = 1
129
+
130
+ [agentctl]
131
+ spawn_delay_ms = 500
132
+ max_subagents = 10
133
+
134
+ [hostdo]
135
+ default_policy = "prompt"
136
+ commands = []
137
+
138
+ [hostdo.command_aliases.yarn_test]
139
+ cmd = "yarn test"
140
+ cwd = "$WORKSPACE"
141
+
142
+ [hostdo.command_aliases.install]
143
+ cmd = "npm install"
144
+ cwd = "$WORKSPACE"
145
+
146
+ [hostdo.command_aliases.pnpm_test]
147
+ cmd = "pnpm test"
148
+ cwd = "$WORKSPACE"
149
+
150
+ [hostdo.command_aliases.build]
151
+ cmd = "npm run build"
152
+ cwd = "$WORKSPACE"
153
+
154
+ [hostdo.command_aliases.lint]
155
+ cmd = "npm run lint"
156
+ cwd = "$WORKSPACE"
157
+
158
+ [hostdo.command_aliases.test]
159
+ cmd = "npm run test"
160
+ cwd = "$WORKSPACE"
161
+
162
+ [hostdo.command_aliases.bun_test]
163
+ cmd = "bun test"
164
+ cwd = "$WORKSPACE"
165
+
166
+ [hostdo.command_aliases.dev]
167
+ cmd = "npm run dev"
168
+ cwd = "$WORKSPACE"
169
+
170
+ [network]
171
+ allowlist = [
172
+ "domain=raw.githubusercontent.com",
173
+ "domain=github.com",
174
+ "domain=chatgpt.com",
175
+ "domain=ab.chatgpt.com",
176
+ "domain=registry.npmjs.org",
177
+ ]
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlycliches/room-kit",
3
- "version": "1.0.2",
3
+ "version": "1.0.5",
4
4
  "description": "Type-safe channel primitives for Socket.IO events, requests, streams, and room membership.",
5
5
  "exports": "./src/index.ts",
6
6
  "publish": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "room-kit",
3
- "version": "1.0.2",
3
+ "version": "1.0.6",
4
4
  "description": "Type-safe room membership, presence, and realtime messaging for Socket.IO.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/client.ts CHANGED
@@ -17,7 +17,7 @@ import type {
17
17
  RoomListenApi,
18
18
  RoomRpc,
19
19
  RpcClientApi,
20
- } from "./types";
20
+ } from "./types.js";
21
21
 
22
22
  const JOIN_EVENT = "room-kit:join";
23
23
  const LEAVE_EVENT = "room-kit:leave";
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
- export { ClientSafeError } from "./types";
2
- export { createRoomClient } from "./client";
3
- export { defineRoomType } from "./room";
4
- export { serveRoomType } from "./server";
1
+ export { ClientSafeError } from "./types.js";
2
+ export { createRoomClient } from "./client.js";
3
+ export { defineRoomType } from "./room.js";
4
+ export { serveRoomType } from "./server.js";
5
5
 
6
6
  export type {
7
7
  ClientConnectionState,
@@ -29,6 +29,7 @@ export type {
29
29
  RoomServerHandlers,
30
30
  RoomSnapshot,
31
31
  ServerAdmission,
32
+ ServerAdmissionInput,
32
33
  ServerSocketLike,
33
34
  VisibleMemberFor,
34
- } from "./types";
35
+ } from "./types.js";
package/src/room.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { RoomDefinition, RoomSchema } from "./types";
1
+ import type { RoomDefinition, RoomSchema } from "./types.js";
2
2
 
3
3
  /**
4
4
  * Defines a room type using a type-only schema and runtime options.
package/src/server.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  EventEmitApi,
4
4
  EventMetaFor,
5
5
  JoinRequest,
6
+ MemberProfileFor,
6
7
  PresenceFor,
7
8
  PresenceListQuery,
8
9
  PresencePageFor,
@@ -10,6 +11,7 @@ import type {
10
11
  RoomMemberSnapshot,
11
12
  RoomDefinition,
12
13
  RoomEvents,
14
+ RoomProfileFor,
13
15
  RoomServerAdapter,
14
16
  RoomServerBroadcastApi,
15
17
  RoomServerHandle,
@@ -18,10 +20,11 @@ import type {
18
20
  RoomSnapshot,
19
21
  ServerSocketLike,
20
22
  ServerStateFor,
23
+ ServerAdmission,
21
24
  PresenceValueFor,
22
25
  VisibleMemberFor,
23
- } from "./types";
24
- import { ClientSafeError } from "./types";
26
+ } from "./types.js";
27
+ import { ClientSafeError } from "./types.js";
25
28
 
26
29
  const JOIN_EVENT = "room-kit:join";
27
30
  const LEAVE_EVENT = "room-kit:leave";
@@ -178,6 +181,7 @@ type NamespaceState = {
178
181
  type AuthCacheEntry<TAuth> = {
179
182
  pending?: Promise<TAuth>;
180
183
  value?: TAuth;
184
+ hasValue?: boolean;
181
185
  };
182
186
 
183
187
  const namespaceStates = new WeakMap<object, NamespaceState>();
@@ -247,11 +251,12 @@ export function serveRoomType<TRoom extends RoomDefinition<any>, TAuth = unknown
247
251
  roomProfile: undefined,
248
252
  serverState: initialState,
249
253
  });
250
- const admission = await handlers.admit(frame.payload as JoinRequest<TRoom>, provisional);
251
- assertMatchingRoomIds(
254
+ const admission = await resolveAdmission(
255
+ socket,
256
+ handlers,
252
257
  requestedRoomId,
253
- admission.roomId,
254
- (admission.roomProfile as { roomId: string }).roomId,
258
+ frame.payload as JoinRequest<TRoom>,
259
+ provisional,
255
260
  );
256
261
 
257
262
  const roomState: RoomState<TRoom> = roomCollection.get(admission.roomId) ?? {
@@ -650,7 +655,7 @@ async function resolveSocketAuth<TAuth>(
650
655
  return entry.pending;
651
656
  }
652
657
 
653
- if (entry.value === undefined) {
658
+ if (!entry.hasValue) {
654
659
  const pending = Promise.resolve(handlers.onAuth?.(socket) as TAuth)
655
660
  .then((auth) => {
656
661
  if (auth === false) {
@@ -660,6 +665,7 @@ async function resolveSocketAuth<TAuth>(
660
665
 
661
666
  const current = authCache.get(socket) ?? {};
662
667
  current.value = auth;
668
+ current.hasValue = true;
663
669
  current.pending = undefined;
664
670
  authCache.set(socket, current);
665
671
  return auth;
@@ -692,6 +698,26 @@ async function resolveSocketAuth<TAuth>(
692
698
  handleRejectedAuth(authCache, socket, decision);
693
699
  }
694
700
 
701
+ async function resolveAdmission<TRoom extends RoomDefinition<any>, TAuth>(
702
+ socket: ServerSocketLike,
703
+ handlers: RoomServerHandlers<TRoom, TAuth>,
704
+ joinRoomId: string,
705
+ join: JoinRequest<TRoom>,
706
+ ctx: RoomServerContext<TRoom, TAuth>,
707
+ ): Promise<ServerAdmission<TRoom>> {
708
+ const admission = handlers.admit ? await Promise.resolve(handlers.admit(join, ctx)) : {};
709
+ const roomId = admission.roomId ?? joinRoomId;
710
+ const roomProfile = admission.roomProfile ?? ({ roomId } as RoomProfileFor<TRoom>);
711
+ assertMatchingRoomIds(joinRoomId, roomId, (roomProfile as { roomId: string }).roomId);
712
+
713
+ return {
714
+ roomId,
715
+ memberId: admission.memberId ?? socket.id,
716
+ memberProfile: admission.memberProfile ?? ({} as MemberProfileFor<TRoom>),
717
+ roomProfile,
718
+ };
719
+ }
720
+
695
721
  function handleRejectedAuth<TAuth>(
696
722
  authCache: WeakMap<ServerSocketLike, AuthCacheEntry<TAuth>>,
697
723
  socket: ServerSocketLike,
package/src/types.ts CHANGED
@@ -381,7 +381,12 @@ export type EventListenApi<TRoom extends RoomDefinition<any>> = {
381
381
  };
382
382
 
383
383
  /**
384
- * Successful admission payload returned by `handlers.admit`.
384
+ * Successful admission payload used by the runtime after admission is
385
+ * normalized.
386
+ *
387
+ * The server persists these values as the membership record for the socket.
388
+ * `roomId`, `memberId`, `memberProfile`, and `roomProfile` must all be
389
+ * present in the finalized admission record.
385
390
  */
386
391
  export type ServerAdmission<TRoom extends RoomDefinition<any>> = {
387
392
  roomId: string;
@@ -390,6 +395,24 @@ export type ServerAdmission<TRoom extends RoomDefinition<any>> = {
390
395
  roomProfile: RoomProfileFor<TRoom>;
391
396
  };
392
397
 
398
+ /**
399
+ * Partial admission payload accepted from `handlers.admit`.
400
+ *
401
+ * Handlers may return only the fields they want to override. The runtime fills
402
+ * missing values with:
403
+ *
404
+ * - `roomId`: the join request room id
405
+ * - `memberId`: the connected socket id
406
+ * - `memberProfile`: an empty object
407
+ * - `roomProfile`: an object with the resolved `roomId`
408
+ *
409
+ * The runtime still validates that any supplied `roomId` matches the join
410
+ * request and that any supplied `roomProfile.roomId` matches the resolved room
411
+ * id.
412
+ *
413
+ */
414
+ export type ServerAdmissionInput<TRoom extends RoomDefinition<any>> = Partial<ServerAdmission<TRoom>>;
415
+
393
416
  /**
394
417
  * Optional decision result returned by `revalidateAuth`.
395
418
  *
@@ -407,10 +430,6 @@ export type AuthRevalidationDecision<TAuth> =
407
430
  message?: string;
408
431
  };
409
432
 
410
- type IsUnknown<T> = unknown extends T
411
- ? ([T] extends [unknown] ? true : false)
412
- : false;
413
-
414
433
  type RoomServerHandlersCommon<TRoom extends RoomDefinition<any>, TAuth> = {
415
434
  initState?(join: JoinRequest<TRoom>): Promise<ServerStateFor<TRoom>> | ServerStateFor<TRoom>;
416
435
  /**
@@ -428,7 +447,13 @@ type RoomServerHandlersCommon<TRoom extends RoomDefinition<any>, TAuth> = {
428
447
  * `{ kind: "reject", message }` to reject the operation.
429
448
  */
430
449
  revalidateAuth?(socket: ServerSocketLike, auth: TAuth): Promise<AuthRevalidationDecision<TAuth> | void> | AuthRevalidationDecision<TAuth> | void;
431
- admit(join: JoinRequest<TRoom>, ctx: RoomServerContext<TRoom, TAuth>): Promise<ServerAdmission<TRoom>> | ServerAdmission<TRoom>;
450
+ /**
451
+ * Optional join admission hook.
452
+ *
453
+ * Return only the pieces you want to override. Missing fields are filled
454
+ * by the runtime.
455
+ */
456
+ admit?(join: JoinRequest<TRoom>, ctx: RoomServerContext<TRoom, TAuth>): Promise<ServerAdmissionInput<TRoom>> | ServerAdmissionInput<TRoom>;
432
457
  /**
433
458
  * Called once when a server socket disconnects.
434
459
  *
@@ -459,19 +484,21 @@ type RoomServerHandlersCommon<TRoom extends RoomDefinition<any>, TAuth> = {
459
484
  /**
460
485
  * Server handler contract for a room type.
461
486
  *
462
- * `onAuth` is required when `TAuth` is explicitly typed to a non-`unknown`
463
- * shape, and optional otherwise.
487
+ * `onAuth` and `admit` are both optional.
464
488
  *
465
- * Returning `false` rejects the socket before any room state is initialized.
489
+ * When `onAuth` is omitted, the socket is accepted with an `undefined` auth
490
+ * value. Returning `false` from `onAuth` still rejects the socket before room
491
+ * initialization.
492
+ *
493
+ * When `admit` is omitted, the runtime derives the admission record from the
494
+ * join request and socket id. When `admit` is present, it may return a partial
495
+ * admission and the server will fill in any missing fields as described by
496
+ * `ServerAdmissionInput`.
466
497
  */
467
498
  export type RoomServerHandlers<TRoom extends RoomDefinition<any>, TAuth = unknown> =
468
- IsUnknown<TAuth> extends true
469
- ? RoomServerHandlersCommon<TRoom, TAuth> & {
470
- onAuth?(socket: ServerSocketLike): Promise<TAuth | false> | TAuth | false;
471
- }
472
- : RoomServerHandlersCommon<TRoom, TAuth> & {
473
- onAuth(socket: ServerSocketLike): Promise<TAuth | false> | TAuth | false;
474
- };
499
+ RoomServerHandlersCommon<TRoom, TAuth> & {
500
+ onAuth?(socket: ServerSocketLike): Promise<TAuth | false> | TAuth | false;
501
+ };
475
502
 
476
503
  /**
477
504
  * Context provided to server handlers (`admit`, `rpc`, `events`, join/leave).
package/test/room.spec.ts CHANGED
@@ -875,6 +875,77 @@ describe("room kit", () => {
875
875
  connection.close();
876
876
  });
877
877
 
878
+ it("uses default auth and admission when handlers are omitted", async () => {
879
+ const namespace = new MockNamespace();
880
+ const roomType = createRoomType("default-server-config", "list");
881
+ const { client, connection, stop } = createClientPair(namespace, "alice-socket", roomType, {});
882
+
883
+ const joined = await client.join({
884
+ roomId: "room-1",
885
+ roomKey: "shared-key",
886
+ userId: "alice",
887
+ userName: "Ada",
888
+ });
889
+
890
+ expect(joined.memberId).toBe("alice-socket");
891
+ expect(joined.roomProfile).toMatchObject({
892
+ roomId: "room-1",
893
+ });
894
+ expect(stop.room("room-1")).toMatchObject({
895
+ roomId: "room-1",
896
+ members: [
897
+ {
898
+ memberId: "alice-socket",
899
+ memberProfile: {},
900
+ },
901
+ ],
902
+ });
903
+
904
+ stop.cleanup();
905
+ connection.close();
906
+ });
907
+
908
+ it("fills in omitted admission fields", async () => {
909
+ const namespace = new MockNamespace();
910
+ const roomType = createRoomType("partial-admission", "list");
911
+ const handlers: RoomServerHandlers<typeof roomType> = {
912
+ admit: (join) => ({
913
+ memberProfile: {
914
+ userId: join.userId,
915
+ userName: join.userName,
916
+ },
917
+ }),
918
+ };
919
+ const { client, connection, stop } = createClientPair(namespace, "alice-socket", roomType, handlers);
920
+
921
+ const joined = await client.join({
922
+ roomId: "room-1",
923
+ roomKey: "shared-key",
924
+ userId: "alice",
925
+ userName: "Ada",
926
+ });
927
+
928
+ expect(joined.memberId).toBe("alice-socket");
929
+ expect(joined.roomProfile).toMatchObject({
930
+ roomId: "room-1",
931
+ });
932
+ expect(stop.room("room-1")).toMatchObject({
933
+ roomId: "room-1",
934
+ members: [
935
+ {
936
+ memberId: "alice-socket",
937
+ memberProfile: {
938
+ userId: "alice",
939
+ userName: "Ada",
940
+ },
941
+ },
942
+ ],
943
+ });
944
+
945
+ stop.cleanup();
946
+ connection.close();
947
+ });
948
+
878
949
  it("rejects when onAuth fails", async () => {
879
950
  const namespace = new MockNamespace();
880
951
  const roomType = createRoomType("reject-auth", "list");
package/tsconfig.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "compilerOptions": {
3
3
  "target": "esnext",
4
- "module": "esnext",
5
- "moduleResolution": "node",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
6
  "lib": ["ESNext"],
7
7
  "rootDir": "./src",
8
8
  "outDir": "./dist",