Skip to content

Commit 9270caf

Browse files
committed
chore(webapp): cut the watch service and route comments to their invariants
1 parent 42749c9 commit 9270caf

33 files changed

Lines changed: 374 additions & 1102 deletions

apps/webapp/app/routes/api.v1.dashboard-agent.alerts.$channelId.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,7 @@ import { authenticateUatOrApiRequest } from "~/services/uatRoutePreamble.server"
77

88
/**
99
* `DELETE /api/v1/dashboard-agent/alerts/:channelId` — stop alerting this channel
10-
* when a watch fires. Same semantics as the email's unsubscribe link.
11-
*
12-
* The channel is looked up scoped to the chat's project, so a channel id from
13-
* another project 404s rather than being touched.
10+
* when a watch fires. The channel is looked up scoped to the chat's project.
1411
*/
1512

1613
const ParamsSchema = z.object({ channelId: z.string().min(1) });
@@ -34,7 +31,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
3431
return json({ error: "Not allowed", code: "forbidden_client" }, { status: 403 });
3532
}
3633
const userId = authentication.userActor.userId;
37-
// The turn's environment scope the authority for the chat's project below.
34+
// The turn's environment scope is the authority for the chat's project below.
3835
const environmentId = authentication.userActor.environmentId;
3936
if (!environmentId) {
4037
return json(

apps/webapp/app/routes/api.v1.dashboard-agent.alerts.ts

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,8 @@ import { authenticateUatOrApiRequest } from "~/services/uatRoutePreamble.server"
1818
import { CreateAlertChannelService } from "~/v3/services/alerts/createAlertChannel.server";
1919

2020
/**
21-
* `GET /api/v1/dashboard-agent/alerts` — what alerts this chat's project sends
22-
* when a watch fires.
23-
* `POST /api/v1/dashboard-agent/alerts` — subscribe the user's email to them.
24-
*
25-
* Accepts only the dashboard agent's delegated user-actor token, like the watches
26-
* endpoint: POST creates something that later mails a person. The environment comes
27-
* from the token, not the body, via `resolveAgentAlertContext`.
28-
*
29-
* The feature-flag gate is enforced here and again at delivery, and its denial
30-
* carries a machine-readable `reason` so the agent can say why.
21+
* `GET` lists this chat's project's watch alerts; `POST` subscribes the user's email. Only
22+
* the agent's delegated user-actor token is accepted, and the environment comes from it.
3123
*/
3224

3325
const ListQuerySchema = z.object({
@@ -39,16 +31,13 @@ const ListQuerySchema = z.object({
3931
const CreateBodySchema = z.object({
4032
chatId: z.string().min(1),
4133
channel: z.literal("email"),
42-
/** Omit it: it defaults to, and may only be, the authenticated user's account email. */
34+
/** May only be the authenticated user's own account email. */
4335
email: z.string().email().optional(),
4436
environmentId: z.string().min(1).optional(),
4537
projectRef: z.string().min(1).optional(),
4638
});
4739

48-
/**
49-
* Shared preamble: a dashboard-agent token plus the environment scope it was minted
50-
* with. That scope is the authority here, so a token without one is unusable.
51-
*/
40+
/** A token without an environment scope is unusable here. */
5241
async function authenticate(
5342
request: Request
5443
): Promise<{ userId: string; environmentId: string } | { error: Response }> {
@@ -68,7 +57,7 @@ async function authenticate(
6857
return { userId: actor.userId, environmentId: actor.environmentId };
6958
}
7059

71-
/** Context failures: a mismatched claim is the caller's error, the rest are 404s. */
60+
/** A mismatched claim is the caller's error, the rest are 404s. */
7261
function contextStatus(code: AgentAlertContextError) {
7362
return code === "environment_mismatch" ? 400 : 404;
7463
}
@@ -165,9 +154,8 @@ export async function action({ request }: ActionFunctionArgs) {
165154
return json({ error: "Alerts are not available here", code: gate.reason }, { status: 403 });
166155
}
167156

168-
// The agent may only subscribe the signed-in user's own account email, so a model
169-
// can't be talked into mailing a watch to someone else. Read off the primary, not
170-
// the replica: this is the identity the subscription is pinned to.
157+
// Only the signed-in user's own account email may be subscribed. Read off the
158+
// primary: this is the identity the subscription is pinned to.
171159
const user = await prisma.user.findFirst({ where: { id: userId }, select: { email: true } });
172160
if (!user) {
173161
return json({ error: "User not found", code: "invalid_request" }, { status: 404 });
@@ -190,8 +178,7 @@ export async function action({ request }: ActionFunctionArgs) {
190178
name: `Watch alerts for ${email}`,
191179
alertTypes: [DASHBOARD_AGENT_WATCH_ALERT_TYPE],
192180
environmentTypes: [environment.type],
193-
// Stable per (email, project): asking twice re-enables the existing
194-
// subscription instead of stacking duplicate channels.
181+
// Stable per (email, project), so asking twice re-enables one channel.
195182
deduplicationKey: `dashboard-agent-watch:${email}`,
196183
channel: { type: "EMAIL", email },
197184
});

apps/webapp/app/routes/api.v1.dashboard-agent.watches.$watchId.check.ts

Lines changed: 11 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -16,30 +16,13 @@ import {
1616
} from "~/services/dashboardAgentWatchToken.server";
1717

1818
/**
19-
* `POST /api/v1/dashboard-agent/watches/:watchId/check` — private per-watch check.
20-
* The watcher task calls it once per tick with the watch's token.
21-
*
22-
* Security model: the token only names a watch; the row is the authority on
23-
* lifecycle and on the immutable project/environment/user snapshot; the user is
24-
* re-authorized against that snapshot on every call, and a revoked user gets the
25-
* watch cancelled here before any environment data is read.
26-
*
27-
* The route never transitions the watch to fired/expired and never advances the
28-
* tick counter. The watcher task owns both, so exactly one component decides when
29-
* the user is told and nothing here can fork the tick chain.
30-
*
31-
* It also hands a watch over to its group's batch chain: it makes sure a chain is
32-
* running for the watch's (environment, cadence) group and answers
33-
* `batched: true` when one is, at which point the caller stops rescheduling its
34-
* own per-watch chain.
19+
* Private per-watch check. The token only names a watch; the row is the authority on
20+
* lifecycle and its snapshot, and this route transitions nothing and advances no tick.
3521
*/
3622

3723
const ParamsSchema = z.object({ watchId: z.string().min(1) });
3824

39-
/**
40-
* Best-effort: the verdict is what this route owes its caller, so a chain that
41-
* couldn't be armed returns `false` and the next check tries again.
42-
*/
25+
/** Best-effort: a chain that couldn't be armed returns `false` and is retried next check. */
4326
async function ensureBatchChain(watch: {
4427
id: string;
4528
environmentId: string;
@@ -91,8 +74,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
9174
);
9275
}
9376

94-
// A valid token for a different watch is 403, not 401: the caller is
95-
// authenticated, just not for this resource.
77+
// A valid token for a different watch is 403, not 401.
9678
if (claims.watchId !== watchId) {
9779
return json({ error: "Not allowed for this watch", code: "watch_mismatch" }, { status: 403 });
9880
}
@@ -129,8 +111,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
129111
const now = new Date();
130112
const expired = watch.expiresAt.getTime() <= now.getTime();
131113
if (expired) {
132-
// Past the deadline only the final evaluation is allowed, and only inside the
133-
// grace window the token is valid for.
114+
// Past the deadline only the final evaluation is allowed, inside the token's grace.
134115
const graceEnds = watch.expiresAt.getTime() + WATCH_TOKEN_GRACE_MS;
135116
if (body.final !== true || now.getTime() > graceEnds) {
136117
return json(
@@ -140,11 +121,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
140121
}
141122
}
142123

143-
// Everything below reads or writes a tenant's data, so failures are logged with
144-
// whose tick failed, then rethrown unchanged.
145124
try {
146-
// Re-authorize the initiating user before any environment data is read, so a
147-
// revoked user's tick can't observe anything.
125+
// Re-authorize the initiating user before any environment data is read.
148126
const authorization = await authorizeWatchEnvironment({
149127
userId: watch.userId,
150128
organizationId: watch.organizationId,
@@ -153,8 +131,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
153131
});
154132

155133
if (!authorization.ok) {
156-
// The watch must not survive the access it was created with. Cancellation is
157-
// never notified, so `deliveryStatus` stays `not_required`.
134+
// A watch must not outlive the access it was created with. Never notified.
158135
await cancelWatch(dashboardAgentDb, { id: watchId, reason: "access_revoked" });
159136
return json(
160137
{ error: "Access to this environment was revoked", code: "access_revoked" },
@@ -166,8 +143,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
166143
const outcome = await checkWatch(
167144
watch.spec,
168145
watchCheckDeps(authorization.environment, now),
169-
// `previous` is the last check's facts, off the row. A tick that couldn't read
170-
// anything freezes a streak instead of resetting it.
146+
// A tick that couldn't read anything freezes a streak instead of resetting it.
171147
{ now, since, previous: previousCheckFacts(watch.lastResult) },
172148
(error) =>
173149
logger.error("Dashboard agent watch check failed", {
@@ -180,8 +156,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
180156
})
181157
);
182158

183-
// Recorded even on the final evaluation: `lastResult` is what the notification
184-
// reads. Guarded on `active`, so a concurrent fire/expire wins and this no-ops.
159+
// Recorded even on the final evaluation. Guarded on `active`, so a concurrent
160+
// fire/expire wins and this no-ops.
185161
await recordWatchCheck(dashboardAgentDb, {
186162
id: watchId,
187163
lastResult: {
@@ -194,8 +170,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
194170

195171
const batched = await ensureBatchChain(watch);
196172

197-
// `observed` travels with the verdict: the task writes it onto the row in the
198-
// same statement as the resolution, so no delivery surface re-reads the source.
173+
// `observed` travels with the verdict so no delivery surface re-reads the source.
199174
return json({
200175
result: outcome.result,
201176
facts: outcome.facts,

apps/webapp/app/routes/api.v1.dashboard-agent.watches.$watchId.fired.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,8 @@ import {
1111
import { logger } from "~/services/logger.server";
1212

1313
/**
14-
* `POST /api/v1/dashboard-agent/watches/:watchId/fired` — the watcher task tells
15-
* us a watch fired, so the project's alert channels can be notified.
16-
*
17-
* Same security model as the check endpoint next door: the token only names a watch,
18-
* the row is the authority on whether it fired, and the watch's initiating user is
19-
* re-authorized against the row's immutable project/environment before anything is
20-
* sent, so an alert never outlives the access the watch was created with.
21-
*
22-
* The caller's body is ignored, so a replay can only re-announce what the row says,
23-
* and the alert job is keyed on the watch (`watch-alert:{watchId}`) so the fan-out
24-
* happens at most once.
14+
* The watcher task reports a fired watch. The row is the authority on whether it fired, and
15+
* the initiating user is re-authorized against its snapshot before any alert is sent.
2516
*/
2617

2718
const ParamsSchema = z.object({ watchId: z.string().min(1) });
@@ -68,8 +59,6 @@ export async function action({ request, params }: ActionFunctionArgs) {
6859
);
6960
}
7061

71-
// The row is the first thing that knows whose request this is, so the logging
72-
// boundary starts here. Failures are logged, then rethrown unchanged.
7362
try {
7463
const authorization = await authorizeWatchEnvironment({
7564
userId: watch.userId,
@@ -79,7 +68,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
7968
});
8069

8170
if (!authorization.ok) {
82-
// Not cancelled here (the watch is already terminal) — just silence.
71+
// Not cancelled here: the watch is already terminal.
8372
logger.info("Dashboard agent watch fired, but access was revoked; no alert", { watchId });
8473
return json(
8574
{ error: "Access to this environment was revoked", code: "access_revoked" },

apps/webapp/app/routes/api.v1.dashboard-agent.watches.$watchId.investigate.ts

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,8 @@ import {
1414
import { logger } from "~/services/logger.server";
1515

1616
/**
17-
* `POST /api/v1/dashboard-agent/watches/:watchId/investigate` — the watcher task
18-
* tells us it has delivered the wake for a watch whose creator pre-approved an
19-
* investigation, so the agent can be sent off to actually conduct it.
20-
*
21-
* Same security model as the fired callback next door: the token only names a watch,
22-
* the row is the authority on what happened, and the watch's initiating user is
23-
* re-authorized against the row's immutable project/environment before anything is
24-
* minted, so an investigation never outlives the access the watch was created with.
25-
*
26-
* The caller's body is ignored, so a replay can only re-ask for what the row already
27-
* says, and the agent dedupes on the action's stable id. The consent, the outcome,
28-
* the user and the environment all come off the row, so the model cannot steer them.
17+
* The watcher task reports a delivered wake for a pre-approved investigation. The caller's
18+
* body is ignored: consent, outcome, user and environment all come off the row.
2919
*/
3020

3121
const ParamsSchema = z.object({ watchId: z.string().min(1) });
@@ -64,8 +54,6 @@ export async function action({ request, params }: ActionFunctionArgs) {
6454
return json({ error: "Watch not found", code: "not_found" }, { status: 404 });
6555
}
6656

67-
// The row decides on all three counts: it has resolved, the user consented, and
68-
// the outcome is one the consent covers.
6957
if (!isTerminalWatchStatus(watch.status)) {
7058
return json(
7159
{ error: `This watch is ${watch.status}`, code: "not_resolved", status: watch.status },
@@ -74,7 +62,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
7462
}
7563

7664
if (!watchWantsInvestigation(watch)) {
77-
// Good news, neutral news, or no consent the wake was the whole delivery.
65+
// No consent, or an outcome consent doesn't cover: the wake was the whole delivery.
7866
return json({ ok: true, investigating: false });
7967
}
8068

@@ -95,10 +83,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
9583
);
9684
}
9785

98-
// Best-effort, and never an error to the caller: the wake is already delivered and
99-
// marked by the time we are called, so a failed kick must not make the watcher
100-
// retry. A turn that never starts, or dies mid-investigation, is settled by the
101-
// stale-investigation sweep.
86+
// Never an error to the caller: the wake is already delivered and marked, so a failed
87+
// kick must not make the watcher retry. The stale-investigation sweep settles it.
10288
try {
10389
await kickWatchInvestigation({ watch, environment: authorization.environment });
10490
} catch (error) {

apps/webapp/app/routes/api.v1.dashboard-agent.watches.batch-check.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,8 @@ import {
88
} from "~/services/dashboardAgentWatchToken.server";
99

1010
/**
11-
* `POST /api/v1/dashboard-agent/watches/batch-check` — private batch check. One call
12-
* per (environment, cadence) group per cadence, in place of one call per watch.
13-
*
14-
* The token names the group and the body names the tick inside it.
15-
* `runWatchBatchCheck` does the rest, and documents the security model.
11+
* Private batch check: one call per (environment, cadence) group per cadence. The token
12+
* names the group, the body names the tick. `runWatchBatchCheck` documents the rest.
1613
*/
1714

1815
const BodySchema = z.object({
@@ -55,8 +52,7 @@ export async function action({ request }: ActionFunctionArgs) {
5552
if (!parsedBody.success) return json({ error: "Invalid request body" }, { status: 400 });
5653
const body = parsedBody.data;
5754

58-
// A valid token for a different group is 403, not 401: the caller is
59-
// authenticated, just not for this group.
55+
// A valid token for a different group is 403, not 401.
6056
if (
6157
claims.environmentId !== body.environmentId ||
6258
claims.cadenceMinutes !== body.cadenceMinutes

0 commit comments

Comments
 (0)