Skip to content

Commit 4ab9b99

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/tool-call-audit-log
# Conflicts: # apps/cloud/src/routeTree.gen.ts # apps/host-cloudflare/web/routeTree.gen.ts # apps/host-selfhost/web/routeTree.gen.ts # packages/app/src/routeTree.gen.ts # packages/react/src/routes/routeTree.gen.ts
2 parents 6b9fafa + 98d606b commit 4ab9b99

77 files changed

Lines changed: 3685 additions & 1152 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@executor-js/cloud": patch
3+
---
4+
5+
An admin who resumes a paused execution from a different MCP session (for example after the client reconnects) keeps workspace-write access. The forwarded resume now carries the requester's access to the session that owns the execution, so a pending `addServer`, `addSpec`, or similar write no longer fails with `org_write_denied`.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@executor-js/cloud": patch
3+
---
4+
5+
Require authenticator verification for organization settings while preserving shared workspace reads and API-key access.

‎apps/cloud/docs/platform-logs.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Platform logs
2+
3+
Apply `apps/cloud/logpush.json` to the account's `workers_trace_events` Logpush
4+
job as the JSON body of `PUT /accounts/{account_id}/logpush/jobs/{job_id}`.
5+
This updates output fields without changing the destination or event selection.
6+
Wrangler's `logpush: true` enables export for the Worker; it does not manage
7+
the account-level job configuration.
8+
9+
The field list retains outcomes, logs, exceptions, timings and script metadata.
10+
It excludes `Event`, which contains request URLs and other source-event data.
11+
Worker observability query redaction is separate from Logpush output selection.
12+
Redacted request paths and HTTP statuses remain available in application traces.
13+
14+
After applying, read back the job's output options and check newly delivered
15+
records at the destination. Allow for configuration propagation and in-flight
16+
batches. Verify that `Event` is absent and retained diagnostic fields still arrive.
17+
This does not remove historical records or sanitize arbitrary console messages.

‎apps/cloud/logpush.json‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"output_options": {
3+
"output_type": "ndjson",
4+
"timestamp_format": "rfc3339",
5+
"field_names": [
6+
"CPUTimeMs",
7+
"DispatchNamespace",
8+
"Entrypoint",
9+
"EventTimestampMs",
10+
"EventType",
11+
"Exceptions",
12+
"Logs",
13+
"Outcome",
14+
"ScriptName",
15+
"ScriptTags",
16+
"ScriptVersion",
17+
"WallTimeMs"
18+
]
19+
}
20+
}

‎apps/cloud/src/account/account-api.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { env } from "cloudflare:workers";
2+
import { ADMIN_MFA_COOKIE, readAdminMfaProof } from "../auth/admin-mfa-proof";
13
import { HttpRouter, HttpServerRequest } from "effect/unstable/http";
2-
import { Effect, Layer } from "effect";
4+
import { Clock, Effect, Layer } from "effect";
35

46
import {
57
AccountProvider,
@@ -75,6 +77,16 @@ const AccountProviderMiddleware = HttpRouter.middleware<{
7577
// session is `""` (vs `SessionAuthLive`, which keeps the inbound cookie).
7678
const session: Session | null = resolved ? sessionFromSealed(resolved, "") : null;
7779

80+
const proof = resolved
81+
? yield* readAdminMfaProof(
82+
env.WORKOS_COOKIE_PASSWORD,
83+
{ userId: resolved.userId, sessionId: resolved.sessionId },
84+
"verified",
85+
request.cookies[ADMIN_MFA_COOKIE],
86+
yield* Clock.currentTimeMillis,
87+
)
88+
: null;
89+
7890
// Built inside the request body so the WorkOS account service closes
7991
// over the per-request `UserStoreService` (postgres socket) supplied by
8092
// the combined request-scoped layer. `local` keeps that promise: the
@@ -84,7 +96,7 @@ const AccountProviderMiddleware = HttpRouter.middleware<{
8496
AccountProvider.asEffect(),
8597
workosAccountProvider.pipe(
8698
Layer.provide(ApiKeyService.WorkOS),
87-
Layer.provide(Layer.succeed(AccountCaller)({ session })),
99+
Layer.provide(Layer.succeed(AccountCaller)({ session, adminVerified: proof !== null })),
88100
),
89101
{ local: true },
90102
);

‎apps/cloud/src/account/org-api-key-revoke.node.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ const providerWith = (accountId: string) => {
196196
stubDirectory,
197197
stubApiKeys,
198198
stubAutumn,
199-
Layer.succeed(AccountCaller)({ session: session(accountId) }),
199+
Layer.succeed(AccountCaller)({ session: session(accountId), adminVerified: false }),
200200
),
201201
),
202202
),

‎apps/cloud/src/account/workos-account-service.ts‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
// the same `WorkOSClient.authenticateSealedSession` the rest of cloud uses.
3232
export class AccountCaller extends Context.Service<
3333
AccountCaller,
34-
{ readonly session: Session | null }
34+
{ readonly session: Session | null; readonly adminVerified?: boolean }
3535
>()("@executor-js/cloud/AccountCaller") {}
3636

3737
// ---------------------------------------------------------------------------
@@ -146,6 +146,16 @@ export const workosAccountProvider: Layer.Layer<
146146
const requireAdmin = (org: { readonly memberRole: "admin" | "member" }) =>
147147
org.memberRole === "admin" ? Effect.void : Effect.fail(new AccountForbidden());
148148

149+
// Settings mutations require MFA; shared member reads and key management do not.
150+
const requireVerifiedSettings = (org: { readonly memberRole: "admin" | "member" }) =>
151+
Effect.gen(function* () {
152+
yield* requireAdmin(org);
153+
if (caller.adminVerified !== true)
154+
return yield* new AccountForbidden({
155+
message: "Verify your identity to change organization settings.",
156+
});
157+
});
158+
149159
// Ownership check so an admin can't mutate a membership id from another
150160
// org: the id must name a row the mirror holds for THIS org (any status —
151161
// revoking a pending invite is a delete too). One point read on the
@@ -390,7 +400,7 @@ export const workosAccountProvider: Layer.Layer<
390400
inviteMember: (headers, body) =>
391401
Effect.gen(function* () {
392402
const { org } = yield* requireOrganization(headers);
393-
yield* requireAdmin(org);
403+
yield* requireVerifiedSettings(org);
394404
yield* reserveMemberSlot(org.id);
395405
const invitation = yield* workos
396406
.sendInvitation({
@@ -422,7 +432,7 @@ export const workosAccountProvider: Layer.Layer<
422432
removeMember: (headers, membershipId) =>
423433
Effect.gen(function* () {
424434
const { org } = yield* requireOrganization(headers);
425-
yield* requireAdmin(org);
435+
yield* requireVerifiedSettings(org);
426436
const membership = yield* assertMembershipInOrg(org.id, membershipId);
427437
yield* workos
428438
.deleteOrgMembership(membershipId)
@@ -453,7 +463,7 @@ export const workosAccountProvider: Layer.Layer<
453463
updateMemberRole: (headers, membershipId, roleSlug) =>
454464
Effect.gen(function* () {
455465
const { org } = yield* requireOrganization(headers);
456-
yield* requireAdmin(org);
466+
yield* requireVerifiedSettings(org);
457467
yield* assertMembershipInOrg(org.id, membershipId);
458468
const updated = yield* workos
459469
.updateOrgMembershipRole(membershipId, roleSlug)
@@ -467,7 +477,7 @@ export const workosAccountProvider: Layer.Layer<
467477
updateOrgName: (headers, name) =>
468478
Effect.gen(function* () {
469479
const { org } = yield* requireOrganization(headers);
470-
yield* requireAdmin(org);
480+
yield* requireVerifiedSettings(org);
471481
const updated = yield* workos
472482
.updateOrganization(org.id, name)
473483
.pipe(Effect.catchTag("WorkOSError", toAccountError));

‎apps/cloud/src/api/router.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { UserStoreService } from "../auth/context";
1111
import { WorkOsMirror } from "../auth/workos-mirror";
1212
import { DbService } from "../db/db";
1313
import { makeAccountApiLive } from "../account/account-api";
14+
import { AdminMfaRoutes } from "../auth/admin-mfa-routes";
1415

1516
import { AutumnRoutesLive } from "../extensions/billing/route";
1617
import { CloudDocsLive } from "../extensions/docs";
@@ -41,6 +42,7 @@ export const makeApiLive = (
4142
Layer.provide(requestScopedMiddleware(requestScopedLive).layer),
4243
);
4344
return Layer.mergeAll(
45+
AdminMfaRoutes.pipe(Layer.provide(requestScopedMiddleware(requestScopedLive).layer)),
4446
makeNonProtectedApiLive(requestScopedLive),
4547
makeOrgApiLive(requestScopedLive),
4648
makeAccountApiLive(requestScopedLive),
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import { Effect } from "effect";
3+
import { SignJWT } from "jose";
4+
import { readAdminMfaProof, signAdminMfaProof } from "./admin-mfa-proof";
5+
6+
const secret = "a-test-only-cookie-password-of-32-characters";
7+
const identity = { userId: "user_test", sessionId: "session_test" };
8+
const now = 1_800_000_000_000;
9+
const proof = {
10+
mode: "challenge" as const,
11+
factorId: "factor_test",
12+
challengeId: "challenge_test",
13+
exp: now / 1000 + 900,
14+
};
15+
const signed = signAdminMfaProof(secret, identity, "verified", proof, now);
16+
17+
describe("admin verification cookie", () => {
18+
it.effect("accepts a valid proof for the same user and session", () =>
19+
Effect.gen(function* () {
20+
const token = yield* signed;
21+
expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toEqual(proof);
22+
}),
23+
);
24+
25+
it.effect("refuses missing, modified, and unsigned cookies", () =>
26+
Effect.gen(function* () {
27+
const token = yield* signed;
28+
const parts = token.split(".");
29+
const unsigned = `${btoa('{"alg":"none"}')}.${parts[1]}.`;
30+
for (const value of [
31+
undefined,
32+
"",
33+
"bad.cookie",
34+
`${token.slice(0, 50)}x${token.slice(51)}`,
35+
unsigned,
36+
]) {
37+
expect(yield* readAdminMfaProof(secret, identity, "verified", value, now)).toBeNull();
38+
}
39+
}),
40+
);
41+
42+
it.effect("refuses another session, another user, and another signing key", () =>
43+
Effect.gen(function* () {
44+
const token = yield* signed;
45+
for (const other of [
46+
{ ...identity, userId: "other" },
47+
{ ...identity, sessionId: "other" },
48+
]) {
49+
expect(yield* readAdminMfaProof(secret, other, "verified", token, now)).toBeNull();
50+
}
51+
expect(
52+
yield* readAdminMfaProof(`${secret}-rotated`, identity, "verified", token, now),
53+
).toBeNull();
54+
}),
55+
);
56+
57+
it.effect("cannot promote an unfinished challenge to verified access", () =>
58+
Effect.gen(function* () {
59+
const token = yield* signAdminMfaProof(
60+
secret,
61+
identity,
62+
"challenge",
63+
{ ...proof, mode: "enroll", exp: now / 1000 + 300 },
64+
now,
65+
);
66+
expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toBeNull();
67+
expect(
68+
yield* readAdminMfaProof(secret, identity, "challenge", token, now + 299_000),
69+
).not.toBeNull();
70+
expect(
71+
yield* readAdminMfaProof(secret, identity, "challenge", token, now + 300_000),
72+
).toBeNull();
73+
}),
74+
);
75+
76+
it.effect("honors the signed expiration and refuses a future-issued cookie", () =>
77+
Effect.gen(function* () {
78+
const token = yield* signed;
79+
expect(
80+
yield* readAdminMfaProof(secret, identity, "verified", token, now + 899_000),
81+
).not.toBeNull();
82+
expect(
83+
yield* readAdminMfaProof(secret, identity, "verified", token, now + 900_000),
84+
).toBeNull();
85+
expect(
86+
yield* readAdminMfaProof(secret, identity, "verified", token, now - 10_000),
87+
).toBeNull();
88+
}),
89+
);
90+
91+
it.effect("keeps the verified session unlocked beyond the former fifteen-minute window", () =>
92+
Effect.gen(function* () {
93+
const token = yield* signAdminMfaProof(
94+
secret,
95+
identity,
96+
"verified",
97+
{
98+
...proof,
99+
exp: now / 1000 + 7 * 86400,
100+
},
101+
now,
102+
);
103+
expect(
104+
yield* readAdminMfaProof(secret, identity, "verified", token, now + 3600_000),
105+
).not.toBeNull();
106+
expect(
107+
yield* readAdminMfaProof(secret, identity, "verified", token, now + 7 * 86400_000),
108+
).toBeNull();
109+
}),
110+
);
111+
112+
it.effect("caps token age even when the supplied expiration is longer", () =>
113+
Effect.gen(function* () {
114+
const token = yield* signAdminMfaProof(
115+
secret,
116+
identity,
117+
"verified",
118+
{ ...proof, exp: now / 1000 + 8 * 86400 },
119+
now,
120+
);
121+
expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toBeNull();
122+
expect(
123+
yield* readAdminMfaProof(secret, identity, "verified", token, now + 901_000),
124+
).toBeNull();
125+
}),
126+
);
127+
128+
it.effect("rejects a signed cookie with missing issued-at or another algorithm", () =>
129+
Effect.gen(function* () {
130+
for (const algorithm of ["HS256", "HS384"]) {
131+
const jwt = new SignJWT({ ...proof })
132+
.setProtectedHeader({ alg: algorithm })
133+
.setIssuer("executor:admin-mfa:verified")
134+
.setSubject(identity.userId)
135+
.setAudience(identity.sessionId);
136+
// HS256 lacks iat; HS384 is otherwise valid but outside the allowlist.
137+
if (algorithm === "HS384") jwt.setIssuedAt(now / 1000);
138+
const token = yield* Effect.promise(() => jwt.sign(new TextEncoder().encode(secret)));
139+
expect(yield* readAdminMfaProof(secret, identity, "verified", token, now)).toBeNull();
140+
}
141+
}),
142+
);
143+
});

0 commit comments

Comments
 (0)