sentinelayer-cli 0.42.0 → 0.44.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.
@@ -13,6 +13,12 @@ import { eventMatchesAgent } from "../session/listener.js";
13
13
  import { isSessionControlEvent } from "../session/control-events.js";
14
14
  import { buildObservations } from "../session/recall/observations.js";
15
15
  import { createMemoryService } from "../engram/index.js";
16
+ import { assertCanonicalAgentId, canonicalAgentId, withAgentAdmission } from "../session/admission-auth.js";
17
+ import {
18
+ SESSION_REACTION_TYPES,
19
+ isSessionReactionType,
20
+ submitSessionReaction,
21
+ } from "../session/reactions.js";
16
22
  import {
17
23
  createSessionMessageAction,
18
24
  listSessionMessageActions,
@@ -44,9 +50,12 @@ const SESSION_MESSAGE_ACTION_TYPES = new Set([
44
50
  "reply",
45
51
  "like",
46
52
  "dislike",
53
+ "unlike",
54
+ "undislike",
47
55
  "disregard",
48
56
  "view",
49
57
  ]);
58
+ const SESSION_REACT_TYPES = Object.freeze(["ack", ...SESSION_REACTION_TYPES]);
50
59
  const SESSION_MESSAGE_ACTION_ALIASES = new Map([["comment", "reply"]]);
51
60
 
52
61
  function normalizeString(value) {
@@ -701,6 +710,25 @@ async function runSessionAction({
701
710
  const normalizedActionType = normalizeSessionMessageActionType(actionType || input.actionType || input.action_type);
702
711
  const target = requireSessionActionTarget(input);
703
712
  const normalizedNote = truncateText(note || input.note || input.message || input.text || "").text;
713
+ if (isSessionReactionType(normalizedActionType)) {
714
+ // The same path as the native CLI: canonical fail-closed admission, a fresh
715
+ // operation key per new intent (an explicit idempotencyKey is a retry), and a
716
+ // truthful outcome. The raw agent id is passed so a non-canonical spelling is
717
+ // refused rather than silently aliased.
718
+ return submitSessionReaction({
719
+ surface: "mcp",
720
+ sessionId,
721
+ agentId: normalizeString(input.agentId || input.agent_id || input.agent),
722
+ reaction: normalizedActionType,
723
+ ...target,
724
+ note: normalizedNote,
725
+ idempotencyKey: normalizeString(input.idempotencyKey || input.idempotency_key),
726
+ targetPath,
727
+ timeoutMs: normalizePositiveInteger(input.timeoutMs || input.timeout_ms, 15_000),
728
+ dryRun: Boolean(input.dryRun || input.dry_run),
729
+ createSessionMessageActionFn,
730
+ });
731
+ }
704
732
  const idempotencyKey =
705
733
  normalizeString(input.idempotencyKey || input.idempotency_key) ||
706
734
  defaultActionIdempotencyKey({
@@ -858,6 +886,47 @@ async function persistSessionEvent({
858
886
  };
859
887
  }
860
888
 
889
+ // Local MCP tools that act AS an agent: post, raise attention, react, reply, act on a
890
+ // message, lease files, or advance that agent's read cursor. Each runs inside the
891
+ // agent's admission scope, the same canonical path native `sl session` actor commands
892
+ // use. A live stored admission is the only credential its requests carry; an expired,
893
+ // malformed or mis-bound one is refused before any request; a credential the API
894
+ // refuses (401/403) is final. With no stored admission a tool behaves as before.
895
+ export const SESSION_MCP_ACTOR_TOOLS = Object.freeze([
896
+ "poll_inbox",
897
+ "read_history",
898
+ "send_message",
899
+ "attention_request",
900
+ "session_action",
901
+ "session_react",
902
+ "session_reply",
903
+ "session_lock",
904
+ "session_unlock",
905
+ ]);
906
+
907
+ function actorAgentId(toolName, input = {}) {
908
+ const raw =
909
+ normalizeString(input.agentId || input.agent_id || input.agent) ||
910
+ (toolName === "read_history" ? normalizeString(process.env.SENTINELAYER_AGENT_ID) : "");
911
+ if (!raw) return "";
912
+ // One canonical identity for authorisation and execution (see admission-auth.js).
913
+ assertCanonicalAgentId(raw, "agentId");
914
+ return canonicalAgentId(raw);
915
+ }
916
+
917
+ function routeActorToolsThroughAdmission(handlers) {
918
+ for (const name of SESSION_MCP_ACTOR_TOOLS) {
919
+ const run = handlers[name];
920
+ handlers[name] = async (input = {}) => {
921
+ const agentId = actorAgentId(name, input);
922
+ // No agent named: the tool's own validation reports it (read_history reads as the user).
923
+ if (!agentId) return run(input);
924
+ return withAgentAdmission(requireSessionId(input), agentId, () => run(input));
925
+ };
926
+ }
927
+ return handlers;
928
+ }
929
+
861
930
  export function createSessionMcpToolHandlers({
862
931
  targetPath = process.cwd(),
863
932
  pollSessionEventsFn = pollSessionEvents,
@@ -897,7 +966,7 @@ export function createSessionMcpToolHandlers({
897
966
  verified: false,
898
967
  };
899
968
  };
900
- return {
969
+ const handlers = {
901
970
  async poll_inbox(input = {}) {
902
971
  const sessionId = requireSessionId(input);
903
972
  const agentId = requireAgentId(input);
@@ -1105,8 +1174,8 @@ export function createSessionMcpToolHandlers({
1105
1174
 
1106
1175
  async session_react(input = {}) {
1107
1176
  const reaction = normalizeSessionMessageActionType(input.reaction || input.actionType || input.action_type);
1108
- if (!["ack", "like", "dislike"].includes(reaction)) {
1109
- throw new Error("reaction must be one of: ack, like, dislike.");
1177
+ if (!SESSION_REACT_TYPES.includes(reaction)) {
1178
+ throw new Error(`reaction must be one of: ${SESSION_REACT_TYPES.join(", ")}.`);
1110
1179
  }
1111
1180
  return runSessionAction({
1112
1181
  input,
@@ -1267,6 +1336,7 @@ export function createSessionMcpToolHandlers({
1267
1336
  });
1268
1337
  },
1269
1338
  };
1339
+ return routeActorToolsThroughAdmission(handlers);
1270
1340
  }
1271
1341
 
1272
1342
  export const SESSION_MCP_TOOLS = Object.freeze([
@@ -1419,7 +1489,7 @@ export const SESSION_MCP_TOOLS = Object.freeze([
1419
1489
  name: "session_action",
1420
1490
  title: "Record Senti Session Action",
1421
1491
  description:
1422
- "Record a low-noise message action such as ack, working_on, disregard, like, dislike, or reply; view advances the monotonic read cursor instead of appending an action.",
1492
+ "Record a low-noise message action such as ack, working_on, disregard, like, dislike, unlike, undislike, or reply; view advances the monotonic read cursor instead of appending an action.",
1423
1493
  inputSchema: {
1424
1494
  type: "object",
1425
1495
  additionalProperties: false,
@@ -1442,7 +1512,7 @@ export const SESSION_MCP_TOOLS = Object.freeze([
1442
1512
  name: "session_react",
1443
1513
  title: "React To Senti Message",
1444
1514
  description:
1445
- "Acknowledge or react to a target session event with ack, like, or dislike.",
1515
+ "Acknowledge or react to a target session event with ack, like, or dislike, or retract your own like/dislike with unlike/undislike. Reactions report an outcome and an operation key; resend that key as idempotencyKey only to retry the same intent.",
1446
1516
  inputSchema: {
1447
1517
  type: "object",
1448
1518
  additionalProperties: false,
@@ -1450,7 +1520,7 @@ export const SESSION_MCP_TOOLS = Object.freeze([
1450
1520
  properties: {
1451
1521
  sessionId: { type: "string", minLength: 1 },
1452
1522
  agentId: { type: "string", minLength: 1 },
1453
- reaction: { type: "string", enum: ["ack", "like", "dislike"] },
1523
+ reaction: { type: "string", enum: [...SESSION_REACT_TYPES] },
1454
1524
  targetSequenceId: { type: "integer", minimum: 1 },
1455
1525
  targetCursor: { type: "string" },
1456
1526
  targetActionId: { type: "string" },
@@ -0,0 +1,284 @@
1
+ // Owner/delegator controls for the Agent Admission Contract.
2
+ //
3
+ // Agent-side key generation, approval polling, proof-of-possession claim, and
4
+ // credential storage remain in admission.js. This module is deliberately the
5
+ // human control plane only: inspect requests, approve/deny them, or revoke a
6
+ // live grant. Every mutation uses the same session CSRF + idempotency contract
7
+ // as the existing request/claim path.
8
+
9
+ import process from "node:process";
10
+
11
+ import { requestJson, requestJsonMutation } from "../auth/http.js";
12
+ import { resolveActiveAuthSession } from "../auth/service.js";
13
+ import { hasStoredAdmissionCredentials } from "./admission.js";
14
+ import { currentAdmittedAgent } from "./admission-auth.js";
15
+ import {
16
+ SESSION_MUTATION_ORIGIN,
17
+ createSessionMutationHeaders,
18
+ createSessionMutationIdempotencyKey,
19
+ } from "./invitations.js";
20
+
21
+ /**
22
+ * Approve, deny, revoke and mode changes are the room owner's decisions, made with the
23
+ * owner's own session. They are unavailable while this process is an agent context:
24
+ * SENTINELAYER_AGENT_ID is set, an agent admission scope is in force, or this machine
25
+ * stores agent admission credentials. Checked before any credential lookup or request.
26
+ */
27
+ export async function assertOwnerAccessContext({ env = process.env, homeDir } = {}) {
28
+ let why = "";
29
+ if (normalizeString(env.SENTINELAYER_AGENT_ID)) {
30
+ why = "SENTINELAYER_AGENT_ID is set";
31
+ } else if (currentAdmittedAgent()) {
32
+ why = "an agent admission is in use";
33
+ } else if (await hasStoredAdmissionCredentials({ homeDir })) {
34
+ why = "agent admission credentials are stored on this machine";
35
+ }
36
+ if (why) {
37
+ throw new Error(
38
+ `Access approve, deny, revoke and mode are owner actions and are unavailable in an agent context (${why}). ` +
39
+ `Use the room's web dashboard, or run the command from an environment without agent credentials.`,
40
+ );
41
+ }
42
+ }
43
+
44
+ export const ADMISSION_STATUSES = Object.freeze([
45
+ "pending",
46
+ "approved",
47
+ "active",
48
+ "denied",
49
+ "cancelled",
50
+ "expired",
51
+ "revoked",
52
+ "stopped",
53
+ ]);
54
+
55
+ const ROUTES = Object.freeze({
56
+ decision: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/decision",
57
+ revoke: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/revoke",
58
+ mode: "POST /api/v1/sessions/{session_id}/admission-mode",
59
+ });
60
+
61
+ function normalizeString(value) {
62
+ return String(value ?? "").trim();
63
+ }
64
+
65
+ function required(value, label) {
66
+ const normalized = normalizeString(value);
67
+ if (!normalized) throw new Error(`${label} is required.`);
68
+ return normalized;
69
+ }
70
+
71
+ async function authContext(targetPath, resolveAuthSession) {
72
+ const auth = await resolveAuthSession({
73
+ cwd: targetPath,
74
+ env: process.env,
75
+ autoRotate: false,
76
+ });
77
+ if (!auth?.token || !auth?.apiUrl) {
78
+ throw new Error("Not authenticated. Run `sl auth login` first.");
79
+ }
80
+ return {
81
+ token: auth.token,
82
+ apiUrl: normalizeString(auth.apiUrl).replace(/\/+$/, ""),
83
+ };
84
+ }
85
+
86
+ function admissionsUrl(apiUrl, sessionId, suffix = "") {
87
+ return `${apiUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/admissions${suffix}`;
88
+ }
89
+
90
+ function boundedIdempotencyKey(value, operation) {
91
+ const explicit = normalizeString(value);
92
+ if (explicit.length > 128) throw new Error("--idempotency-key must be at most 128 characters.");
93
+ return explicit || createSessionMutationIdempotencyKey(operation);
94
+ }
95
+
96
+ async function mutateAdmission(
97
+ sessionId,
98
+ suffix,
99
+ routeId,
100
+ body,
101
+ {
102
+ targetPath,
103
+ idempotencyKey,
104
+ operationName,
105
+ origin,
106
+ resolveAuthSession,
107
+ requestMutation,
108
+ },
109
+ ) {
110
+ const key = boundedIdempotencyKey(idempotencyKey, operationName);
111
+ const auth = await authContext(targetPath, resolveAuthSession);
112
+ const result = await requestMutation(admissionsUrl(auth.apiUrl, sessionId, suffix), {
113
+ method: "POST",
114
+ operationName,
115
+ idempotencyKey: key,
116
+ headers: createSessionMutationHeaders({
117
+ bearerToken: auth.token,
118
+ sessionId,
119
+ routeId,
120
+ idempotencyKey: key,
121
+ origin,
122
+ }),
123
+ body,
124
+ });
125
+ return { idempotencyKey: key, result };
126
+ }
127
+
128
+ export async function listSessionAdmissions(
129
+ sessionId,
130
+ {
131
+ status = "",
132
+ targetPath = process.cwd(),
133
+ resolveAuthSession = resolveActiveAuthSession,
134
+ requestRead = requestJson,
135
+ } = {},
136
+ ) {
137
+ const sid = required(sessionId, "session id");
138
+ const normalizedStatus = normalizeString(status).toLowerCase();
139
+ if (normalizedStatus && !ADMISSION_STATUSES.includes(normalizedStatus)) {
140
+ throw new Error(
141
+ `--status must be one of: ${ADMISSION_STATUSES.join(", ")}.`,
142
+ );
143
+ }
144
+ const auth = await authContext(targetPath, resolveAuthSession);
145
+ const query = normalizedStatus ? `?status=${encodeURIComponent(normalizedStatus)}` : "";
146
+ return requestRead(`${admissionsUrl(auth.apiUrl, sid)}${query}`, {
147
+ method: "GET",
148
+ headers: { Authorization: `Bearer ${auth.token}` },
149
+ });
150
+ }
151
+
152
+ export async function getSessionAdmission(
153
+ sessionId,
154
+ admissionId,
155
+ {
156
+ targetPath = process.cwd(),
157
+ resolveAuthSession = resolveActiveAuthSession,
158
+ requestRead = requestJson,
159
+ } = {},
160
+ ) {
161
+ const sid = required(sessionId, "session id");
162
+ const aid = required(admissionId, "admission id");
163
+ const auth = await authContext(targetPath, resolveAuthSession);
164
+ return requestRead(admissionsUrl(auth.apiUrl, sid, `/${encodeURIComponent(aid)}`), {
165
+ method: "GET",
166
+ headers: { Authorization: `Bearer ${auth.token}` },
167
+ });
168
+ }
169
+
170
+ export async function decideSessionAdmission(
171
+ sessionId,
172
+ admissionId,
173
+ {
174
+ decision,
175
+ grantedActions,
176
+ ttlSeconds,
177
+ note,
178
+ targetPath = process.cwd(),
179
+ idempotencyKey = "",
180
+ origin = SESSION_MUTATION_ORIGIN,
181
+ resolveAuthSession = resolveActiveAuthSession,
182
+ requestMutation = requestJsonMutation,
183
+ } = {},
184
+ ) {
185
+ const sid = required(sessionId, "session id");
186
+ const aid = required(admissionId, "admission id");
187
+ const normalizedDecision = normalizeString(decision).toLowerCase();
188
+ if (!["approve", "deny"].includes(normalizedDecision)) {
189
+ throw new Error("decision must be approve or deny.");
190
+ }
191
+ if (normalizedDecision === "deny" && (grantedActions !== undefined || ttlSeconds !== undefined)) {
192
+ throw new Error("A denied admission cannot include --scope or --ttl.");
193
+ }
194
+ const body = { decision: normalizedDecision };
195
+ if (grantedActions !== undefined) body.grantedActions = grantedActions;
196
+ if (ttlSeconds !== undefined) body.ttlSeconds = ttlSeconds;
197
+ const normalizedNote = normalizeString(note);
198
+ if (normalizedNote) body.note = normalizedNote;
199
+ await assertOwnerAccessContext();
200
+ return mutateAdmission(
201
+ sid,
202
+ `/${encodeURIComponent(aid)}/decision`,
203
+ ROUTES.decision,
204
+ body,
205
+ {
206
+ targetPath,
207
+ idempotencyKey,
208
+ operationName: `session.admission_${normalizedDecision}`,
209
+ origin,
210
+ resolveAuthSession,
211
+ requestMutation,
212
+ },
213
+ );
214
+ }
215
+
216
+ export async function revokeSessionAdmission(
217
+ sessionId,
218
+ admissionId,
219
+ {
220
+ reason,
221
+ targetPath = process.cwd(),
222
+ idempotencyKey = "",
223
+ origin = SESSION_MUTATION_ORIGIN,
224
+ resolveAuthSession = resolveActiveAuthSession,
225
+ requestMutation = requestJsonMutation,
226
+ } = {},
227
+ ) {
228
+ const sid = required(sessionId, "session id");
229
+ const aid = required(admissionId, "admission id");
230
+ const normalizedReason = normalizeString(reason);
231
+ await assertOwnerAccessContext();
232
+ return mutateAdmission(
233
+ sid,
234
+ `/${encodeURIComponent(aid)}/revoke`,
235
+ ROUTES.revoke,
236
+ normalizedReason ? { reason: normalizedReason } : {},
237
+ {
238
+ targetPath,
239
+ idempotencyKey,
240
+ operationName: "session.admission_revoke",
241
+ origin,
242
+ resolveAuthSession,
243
+ requestMutation,
244
+ },
245
+ );
246
+ }
247
+
248
+ export async function setSessionAdmissionMode(
249
+ sessionId,
250
+ mode,
251
+ {
252
+ targetPath = process.cwd(),
253
+ idempotencyKey = "",
254
+ origin = SESSION_MUTATION_ORIGIN,
255
+ resolveAuthSession = resolveActiveAuthSession,
256
+ requestMutation = requestJsonMutation,
257
+ } = {},
258
+ ) {
259
+ const sid = required(sessionId, "session id");
260
+ const normalizedMode = normalizeString(mode).toLowerCase();
261
+ if (!["legacy", "required"].includes(normalizedMode)) {
262
+ throw new Error("mode must be legacy or required.");
263
+ }
264
+ await assertOwnerAccessContext();
265
+ const key = boundedIdempotencyKey(idempotencyKey, "session.admission_mode");
266
+ const auth = await authContext(targetPath, resolveAuthSession);
267
+ const result = await requestMutation(
268
+ `${auth.apiUrl}/api/v1/sessions/${encodeURIComponent(sid)}/admission-mode`,
269
+ {
270
+ method: "POST",
271
+ operationName: "session.admission_mode",
272
+ idempotencyKey: key,
273
+ headers: createSessionMutationHeaders({
274
+ bearerToken: auth.token,
275
+ sessionId: sid,
276
+ routeId: ROUTES.mode,
277
+ idempotencyKey: key,
278
+ origin,
279
+ }),
280
+ body: { mode: normalizedMode },
281
+ },
282
+ );
283
+ return { idempotencyKey: key, result };
284
+ }
@@ -153,13 +153,20 @@ export const SESSION_AGENT_PRINCIPAL = Object.freeze({
153
153
  "ticket release": "actor",
154
154
  "ticket submit": "actor",
155
155
  "ticket events": "actor",
156
+ "access mode": "control",
157
+ "access request": "control",
158
+ "access list": "control",
159
+ "access status": "control",
160
+ "access approve": "control",
161
+ "access deny": "control",
162
+ "access revoke": "control",
156
163
  kill: "target",
157
164
  "stop-listener": "target",
158
165
  leave: "target",
159
166
  "guard-uninstall": "target",
160
167
  join: "control",
161
168
  });
162
- const SESSION_COMMAND_GROUPS = new Set(["checkpoint", "ticket"]);
169
+ const SESSION_COMMAND_GROUPS = new Set(["access", "checkpoint", "ticket"]);
163
170
 
164
171
  async function implicitAgent(sessionId, targetPath, env) {
165
172
  // The same identity the command itself resolves when --agent is omitted
@@ -63,7 +63,8 @@ const ROUTE = {
63
63
  claim: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/claim",
64
64
  cancel: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/cancel",
65
65
  };
66
- const TERMINAL = new Set(["denied", "cancelled", "expired", "revoked"]);
66
+ // Final admission statuses: polling stops, nothing is claimed, local request state is cleared.
67
+ const TERMINAL = new Set(["denied", "cancelled", "expired", "revoked", "stopped"]);
67
68
  const TTL_MIN_SECONDS = 300;
68
69
  const TTL_MAX_SECONDS = 86_400;
69
70
 
@@ -242,6 +243,34 @@ export function admissionCredentialPath(sessionId, agentId, { homeDir } = {}) {
242
243
  );
243
244
  }
244
245
 
246
+ /**
247
+ * Whether this machine stores ANY agent admission credential (usable or not, any
248
+ * session): `<home>/.sentinelayer/agents/<agent>/admissions/<session>.json`. Agent keys
249
+ * alone do not count. An unreadable directory counts as present, never as absence.
250
+ */
251
+ export async function hasStoredAdmissionCredentials({ homeDir } = {}) {
252
+ const agentsDir = path.join(sentinelayerHome(homeDir), "agents");
253
+ let agents;
254
+ try {
255
+ agents = await fsp.readdir(agentsDir, { withFileTypes: true });
256
+ } catch (error) {
257
+ if (error && error.code === "ENOENT") return false;
258
+ return true;
259
+ }
260
+ for (const agent of agents) {
261
+ if (!agent.isDirectory()) continue;
262
+ let files;
263
+ try {
264
+ files = await fsp.readdir(path.join(agentsDir, agent.name, "admissions"));
265
+ } catch (error) {
266
+ if (error && error.code === "ENOENT") continue;
267
+ return true;
268
+ }
269
+ if (files.some((name) => name.endsWith(".json"))) return true;
270
+ }
271
+ return false;
272
+ }
273
+
245
274
  /**
246
275
  * What this machine holds for (session, agent):
247
276
  * { state: "none" } -- never admitted here: the legacy path applies
@@ -471,12 +500,39 @@ export async function runAdmissionJoin(
471
500
  headers: { Authorization: `Bearer ${auth.token}` },
472
501
  });
473
502
  state.status = polled.status;
474
- if (polled.status === "approved" || TERMINAL.has(polled.status) || polled.status === "active") break;
475
- onPending({ admissionId: state.admissionId, approveUrl: polled.approveUrl || state.approveUrl });
503
+ // Identity readiness is a server capability: a server that gates the claim on
504
+ // AIdenID identity evidence reports `identityReady` (true or false) on every
505
+ // approved poll. A server that does not report it issues the claim challenge on
506
+ // approval, so the claim proceeds as soon as the admission is approved.
507
+ const reportsIdentityReadiness =
508
+ polled.status === "approved" && Object.prototype.hasOwnProperty.call(polled, "identityReady");
509
+ const claimable =
510
+ polled.status === "approved" && (!reportsIdentityReadiness || polled.identityReady === true);
511
+ if (reportsIdentityReadiness) {
512
+ const claimReady = claimable && polled?.claim?.domain === CLAIM_DOMAIN && polled?.claim?.preimage;
513
+ if (polled.identityReady === true && !claimReady) {
514
+ throw new Error("Identity-ready admission did not include a valid claim challenge.");
515
+ }
516
+ if (polled.identityReady !== true && polled?.claim) {
517
+ throw new Error("Admission exposed a claim challenge before AIdenID identity evidence was ready.");
518
+ }
519
+ }
520
+ if (claimable || TERMINAL.has(polled.status) || polled.status === "active") break;
521
+ const phase = polled.status === "approved" ? "identity" : "approval";
522
+ onPending({
523
+ admissionId: state.admissionId,
524
+ approveUrl: polled.approveUrl || state.approveUrl,
525
+ phase,
526
+ identityReady: polled.identityReady === true,
527
+ identity: polled.identity || null,
528
+ });
476
529
  if (now() >= deadline) {
477
530
  await fsp.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`);
478
531
  return {
479
- status: "pending",
532
+ status: polled.status === "approved" ? "approved" : "pending",
533
+ phase,
534
+ identityReady: polled.identityReady === true,
535
+ identity: polled.identity || null,
480
536
  admissionId: state.admissionId,
481
537
  approveUrl: polled.approveUrl || state.approveUrl,
482
538
  waited: true,
@@ -14,6 +14,8 @@ const QUIET_SESSION_ACTION_TYPES = new Set([
14
14
  "dislike",
15
15
  "disregard",
16
16
  "like",
17
+ "undislike",
18
+ "unlike",
17
19
  "view",
18
20
  ]);
19
21