Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions apps/api/src/rest/routers/website.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ describe("website route GET /", () => {
const payload = (await response.json()) as {
lastOnlineAt: string | null;
availableHumanAgents: Record<string, unknown>[];
privateActor: unknown;
};

expect(response.status).toBe(200);
Expand All @@ -163,6 +164,7 @@ describe("website route GET /", () => {
]);
expect(payload.availableHumanAgents[0]).not.toHaveProperty("email");
expect(payload.lastOnlineAt).toBe("2026-03-03T04:05:06.000Z");
expect(payload.privateActor).toBeNull();
});

it("returns null lastOnlineAt when website-access users have no valid timestamps", async () => {
Expand Down Expand Up @@ -240,6 +242,94 @@ describe("website route GET /", () => {
]);
});

it("returns explicit-actor requirements for unlinked private API keys", async () => {
const { db, website } = createWebsiteContext();
safelyExtractRequestDataMock.mockResolvedValue({
db,
website,
apiKey: {
isTest: false,
keyType: APIKeyType.PRIVATE,
linkedUserId: null,
},
visitorIdHeader: "visitor-1",
});

const { websiteRouter } = await websiteRouterModulePromise;
const response = await websiteRouter.request(
new Request("http://localhost/", {
method: "GET",
})
);
const payload = (await response.json()) as {
privateActor: {
linkedUserId: string | null;
linkedUser: unknown;
requiresExplicitActor: boolean;
} | null;
};

expect(response.status).toBe(200);
expect(payload.privateActor).toEqual({
linkedUserId: null,
linkedUser: null,
requiresExplicitActor: true,
});
});

it("returns linked actor details for linked private API keys", async () => {
const { db, website } = createWebsiteContext();
listWebsiteAccessUsersMock.mockResolvedValue([
createWebsiteAccessUser({
userId: "user-1",
name: "Alice",
lastSeenAt: new Date("2026-03-03T04:05:06.000Z"),
image: "https://cdn.example.com/alice.png",
}),
]);
safelyExtractRequestDataMock.mockResolvedValue({
db,
website,
apiKey: {
isTest: false,
keyType: APIKeyType.PRIVATE,
linkedUserId: "user-1",
},
visitorIdHeader: "visitor-1",
});

const { websiteRouter } = await websiteRouterModulePromise;
const response = await websiteRouter.request(
new Request("http://localhost/", {
method: "GET",
})
);
const payload = (await response.json()) as {
privateActor: {
linkedUserId: string | null;
linkedUser: {
id: string;
name: string | null;
image: string | null;
lastSeenAt: string | null;
} | null;
requiresExplicitActor: boolean;
} | null;
};

expect(response.status).toBe(200);
expect(payload.privateActor).toEqual({
linkedUserId: "user-1",
linkedUser: {
id: "user-1",
name: "Alice",
image: "https://cdn.example.com/alice.png",
lastSeenAt: "2026-03-03T04:05:06.000Z",
},
requiresExplicitActor: false,
});
});

it("lists website team members for private API keys", async () => {
const { db, website } = createWebsiteContext();
listWebsiteAccessUsersMock.mockResolvedValue([
Expand Down
23 changes: 23 additions & 0 deletions apps/api/src/rest/routers/website.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "@api/utils/validate";
import { getMostRecentLastOnlineAt } from "@api/utils/website";
import { normalizeHumanAgentName } from "@cossistant/core";
import { APIKeyType } from "@cossistant/types";
import {
publicWebsiteResponseSchema,
websiteTeamMembersResponseSchema,
Expand Down Expand Up @@ -115,6 +116,27 @@ websiteRouter.openapi(

// iso string indicating support activity - uses most recent lastSeenAt from available human agents
const lastOnlineAt = getMostRecentLastOnlineAt(availableHumanAgents);
const linkedActorUser =
apiKey.keyType === APIKeyType.PRIVATE && apiKey.linkedUserId
? websiteAccessUsers.find((user) => user.userId === apiKey.linkedUserId) ??
null
: null;
const privateActor =
apiKey.keyType === APIKeyType.PRIVATE
? {
linkedUserId: apiKey.linkedUserId ?? null,
linkedUser: linkedActorUser
? {
id: linkedActorUser.userId,
name: normalizeHumanAgentName(linkedActorUser.name),
image: linkedActorUser.image,
lastSeenAt:
linkedActorUser.lastSeenAt?.toISOString() ?? null,
}
: null,
requiresExplicitActor: apiKey.linkedUserId == null,
}
: null;

return c.json(
validateResponse(
Expand All @@ -129,6 +151,7 @@ websiteRouter.openapi(
lastOnlineAt,
availableHumanAgents,
availableAIAgents,
privateActor,
visitor: {
id: visitor.id,
isBlocked: Boolean(visitor.blockedAt),
Expand Down
34 changes: 34 additions & 0 deletions packages/types/src/api/website.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,28 @@ export const AvailableAIAgentSchema = z.object({
}),
});

export const privateWebsiteActorSchema = z
.object({
linkedUserId: z.ulid().nullable().openapi({
description:
"Teammate linked to the private API key, if one is configured.",
example: "01JG000000000000000000001",
}),
linkedUser: availableHumanAgentSchema.nullable().openapi({
description:
"Resolved linked teammate details when the private API key is linked to a website member.",
}),
requiresExplicitActor: z.boolean().openapi({
description:
"Whether actor-aware private routes still require an explicit X-Actor-User-Id header for this key.",
example: true,
}),
})
.openapi({
description:
"Actor metadata for private API keys, used by dashboard clients to decide whether they must select an acting teammate.",
});

/**
* Website information response schema
*/
Expand Down Expand Up @@ -469,6 +491,17 @@ export const publicWebsiteResponseSchema = z.object({
* @fumadocsHref #aiagent
*/
availableAIAgents: z.array(AvailableAIAgentSchema),
/**
* Private API key actor metadata.
*
* Null for public API key requests. Present for private API keys so trusted
* dashboard clients can determine whether the key is already linked to a
* teammate or still needs an explicit actor ID for actor-aware routes.
*/
privateActor: privateWebsiteActorSchema.nullable().openapi({
description:
"Private-key actor metadata used by trusted dashboard clients. Null for public key requests.",
}),
/**
* Current visitor information for the active session.
*
Expand All @@ -485,6 +518,7 @@ export const publicWebsiteResponseSchema = z.object({
export type PublicWebsiteResponse = z.infer<typeof publicWebsiteResponseSchema>;
export type AvailableHumanAgent = z.infer<typeof availableHumanAgentSchema>;
export type AvailableAIAgent = z.infer<typeof AvailableAIAgentSchema>;
export type PrivateWebsiteActor = z.infer<typeof privateWebsiteActorSchema>;
export type HumanAgent = AvailableHumanAgent;
export type AIAgent = AvailableAIAgent;

Expand Down