stack/packages/shared/src/interface/admin-interface.ts
mantrakp04 2558a63a81 feat: implement two-phase review flow for config updates
- Introduced a new API route for committing changes after user review, allowing the agent to keep the sandbox alive for inspection before finalizing updates.
- Enhanced the existing applyConfigUpdate function to transition to an awaiting review state, storing the diff for user visibility.
- Added progress tracking and stage reporting for the config agent run, improving user feedback during the update process.
- Updated the dashboard to reflect the new review stages and provide a more interactive experience for managing configuration changes.

Co-Authored-By: mantra <mantra@stack-auth.com>
2026-06-25 17:12:42 -07:00

1212 lines
40 KiB
TypeScript

import * as yup from "yup";
import { KnownErrors } from "../known-errors";
import { branchConfigSourceSchema, type RestrictedReason } from "../schema-fields";
import { AccessToken, InternalSession, RefreshToken } from "../sessions";
import type { MoneyAmount } from "../utils/currency-constants";
import type { Json } from "../utils/json";
import { Result } from "../utils/results";
import { urlString } from "../utils/urls";
import type { PlanUsageResponse } from "./plan-usage";
import type { AnalyticsClickmapDevice, AnalyticsClickmapKind, AnalyticsClickmapResponse, AnalyticsClickmapTokenResponse, MetricsResponse, MetricsUserCounts, UserActivityResponse } from "./admin-metrics";
import type { AnalyticsQueryOptions, AnalyticsQueryResponse } from "./crud/analytics";
import { EmailOutboxCrud } from "./crud/email-outbox";
import { InternalEmailsCrud } from "./crud/emails";
import { InternalApiKeysCrud } from "./crud/internal-api-keys";
import { ProjectPermissionDefinitionsCrud } from "./crud/project-permissions";
import { ProjectsCrud } from "./crud/projects";
import type {
AdminGetSessionReplayResponse,
AdminGetSessionReplayAllEventsResponse,
AdminGetSessionReplayChunkEventsResponse,
AdminListSessionReplayChunksOptions,
AdminListSessionReplayChunksResponse,
AdminListSessionReplaysOptions,
AdminListSessionReplaysResponse
} from "./crud/session-replays";
import { SvixTokenCrud } from "./crud/svix-token";
import { TeamPermissionDefinitionsCrud } from "./crud/team-permissions";
import type { Transaction, TransactionType } from "./crud/transactions";
import { ServerAuthApplicationOptions, HexclaveServerInterface } from "./server-interface";
export type { PlanUsageResponse } from "./plan-usage";
type BranchConfigSourceApi = yup.InferType<typeof branchConfigSourceSchema>;
export type ChatContent = Array<
| { type: "text", text: string }
| { type: "tool-call", toolName: string, toolCallId: string, args: any, argsText: string, result: any }
>;
export type AdminAuthApplicationOptions = ServerAuthApplicationOptions &(
| {
superSecretAdminKey: string,
}
| {
projectOwnerSession: InternalSession | (() => Promise<string | null>),
}
);
export type InternalApiKeyCreateCrudRequest = {
has_publishable_client_key: boolean,
has_secret_server_key: boolean,
has_super_secret_admin_key: boolean,
expires_at_millis: number,
description: string,
};
export type InternalApiKeyCreateCrudResponse = InternalApiKeysCrud["Admin"]["Read"] & {
publishable_client_key?: string,
secret_server_key?: string,
super_secret_admin_key?: string,
};
export class HexclaveAdminInterface extends HexclaveServerInterface {
constructor(public readonly options: AdminAuthApplicationOptions) {
super(options);
}
public async sendAdminRequest(path: string, options: RequestInit, session: InternalSession | null, requestType: "admin" = "admin") {
return await this.sendServerRequest(
path,
{
...options,
headers: {
// Hexclave rebrand: emit x-hexclave-* request header; the backend proxy dual-accepts both names.
"x-hexclave-super-secret-admin-key": "superSecretAdminKey" in this.options ? this.options.superSecretAdminKey : "",
...options.headers,
},
},
session,
requestType,
);
}
protected async sendAdminRequestAndCatchKnownError<E extends typeof KnownErrors[keyof KnownErrors]>(
path: string,
requestOptions: RequestInit,
tokenStoreOrNull: InternalSession | null,
errorsToCatch: readonly E[],
): Promise<Result<
Response & {
usedTokens: {
accessToken: AccessToken,
refreshToken: RefreshToken | null,
} | null,
},
InstanceType<E>
>> {
try {
return Result.ok(await this.sendAdminRequest(path, requestOptions, tokenStoreOrNull));
} catch (e) {
for (const errorType of errorsToCatch) {
if (errorType.isInstance(e)) {
return Result.error(e as InstanceType<E>);
}
}
throw e;
}
}
async getProject(): Promise<ProjectsCrud["Admin"]["Read"]> {
const response = await this.sendAdminRequest(
"/internal/projects/current",
{
method: "GET",
},
null,
);
return await response.json();
}
async updateProject(update: ProjectsCrud["Admin"]["Update"]): Promise<ProjectsCrud["Admin"]["Read"]> {
const response = await this.sendAdminRequest(
"/internal/projects/current",
{
method: "PATCH",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(update),
},
null,
);
return await response.json();
}
async createInternalApiKey(
options: InternalApiKeyCreateCrudRequest,
): Promise<InternalApiKeyCreateCrudResponse> {
const response = await this.sendAdminRequest(
"/internal/api-keys",
{
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(options),
},
null,
);
return await response.json();
}
async listInternalApiKeys(): Promise<InternalApiKeysCrud["Admin"]["Read"][]> {
const response = await this.sendAdminRequest("/internal/api-keys", {}, null);
const result = await response.json() as InternalApiKeysCrud["Admin"]["List"];
return result.items;
}
async revokeInternalApiKeyById(id: string) {
await this.sendAdminRequest(
`/internal/api-keys/${id}`, {
method: "PATCH",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
revoked: true,
}),
},
null,
);
}
async getInternalApiKey(id: string, session: InternalSession): Promise<InternalApiKeysCrud["Admin"]["Read"]> {
const response = await this.sendAdminRequest(`/internal/api-keys/${id}`, {}, session);
return await response.json();
}
async listInternalEmailTemplates(): Promise<{ id: string, display_name: string, theme_id?: string, tsx_source: string }[]> {
const response = await this.sendAdminRequest(`/internal/email-templates`, {}, null);
const result = await response.json() as { templates: { id: string, display_name: string, theme_id?: string, tsx_source: string }[] };
return result.templates;
}
async listInternalEmailDrafts(): Promise<{ id: string, display_name: string, theme_id?: string | undefined | false, tsx_source: string, sent_at_millis?: number | null }[]> {
const response = await this.sendAdminRequest(`/internal/email-drafts`, {}, null);
const result = await response.json() as { drafts: { id: string, display_name: string, theme_id?: string | undefined | false, tsx_source: string, sent_at_millis?: number | null }[] };
return result.drafts;
}
async createEmailDraft(options: { display_name?: string, theme_id?: string | false, tsx_source?: string }): Promise<{ id: string }> {
const response = await this.sendAdminRequest(
`/internal/email-drafts`,
{
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(options),
},
null,
);
return await response.json();
}
async updateEmailDraft(id: string, data: { display_name?: string, theme_id?: string | null | false, tsx_source?: string, sent_at_millis?: number | null }): Promise<void> {
await this.sendAdminRequest(
`/internal/email-drafts/${id}`,
{
method: "PATCH",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(data),
},
null,
);
}
async deleteEmailDraft(id: string): Promise<void> {
await this.sendAdminRequest(
`/internal/email-drafts/${id}`,
{
method: "DELETE",
},
null,
);
}
async listEmailThemes(): Promise<{ id: string, display_name: string }[]> {
const response = await this.sendAdminRequest(`/internal/email-themes`, {}, null);
const result = await response.json() as { themes: { id: string, display_name: string }[] };
return result.themes;
}
// Team permission definitions methods
async listTeamPermissionDefinitions(): Promise<TeamPermissionDefinitionsCrud['Admin']['Read'][]> {
const response = await this.sendAdminRequest(`/team-permission-definitions`, {}, null);
const result = await response.json() as TeamPermissionDefinitionsCrud['Admin']['List'];
return result.items;
}
async listTeamPermissionDefinitionsPaginated(
options: { limit: number, cursor?: string, query?: string },
): Promise<{ items: TeamPermissionDefinitionsCrud['Admin']['Read'][], nextCursor: string | null }> {
const params = new URLSearchParams();
params.set("limit", String(options.limit));
if (options.cursor) params.set("cursor", options.cursor);
if (options.query) params.set("query", options.query);
const response = await this.sendAdminRequest(`/team-permission-definitions?${params.toString()}`, {}, null);
const result = await response.json() as TeamPermissionDefinitionsCrud['Admin']['List'];
return {
items: result.items,
nextCursor: result.pagination?.next_cursor ?? null,
};
}
async createTeamPermissionDefinition(data: TeamPermissionDefinitionsCrud['Admin']['Create']): Promise<TeamPermissionDefinitionsCrud['Admin']['Read']> {
const response = await this.sendAdminRequest(
"/team-permission-definitions",
{
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(data),
},
null,
);
return await response.json();
}
async updateTeamPermissionDefinition(permissionId: string, data: TeamPermissionDefinitionsCrud['Admin']['Update']): Promise<TeamPermissionDefinitionsCrud['Admin']['Read']> {
const response = await this.sendAdminRequest(
`/team-permission-definitions/${permissionId}`,
{
method: "PATCH",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(data),
},
null,
);
return await response.json();
}
async deleteTeamPermissionDefinition(permissionId: string): Promise<void> {
await this.sendAdminRequest(
`/team-permission-definitions/${permissionId}`,
{ method: "DELETE" },
null,
);
}
async listProjectPermissionDefinitions(): Promise<ProjectPermissionDefinitionsCrud['Admin']['Read'][]> {
const response = await this.sendAdminRequest(`/project-permission-definitions`, {}, null);
const result = await response.json() as ProjectPermissionDefinitionsCrud['Admin']['List'];
return result.items;
}
async createProjectPermissionDefinition(data: ProjectPermissionDefinitionsCrud['Admin']['Create']): Promise<ProjectPermissionDefinitionsCrud['Admin']['Read']> {
const response = await this.sendAdminRequest(
"/project-permission-definitions",
{
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(data),
},
null,
);
return await response.json();
}
async updateProjectPermissionDefinition(permissionId: string, data: ProjectPermissionDefinitionsCrud['Admin']['Update']): Promise<ProjectPermissionDefinitionsCrud['Admin']['Read']> {
const response = await this.sendAdminRequest(
`/project-permission-definitions/${permissionId}`,
{
method: "PATCH",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(data),
},
null,
);
return await response.json();
}
async deleteProjectPermissionDefinition(permissionId: string): Promise<void> {
await this.sendAdminRequest(
`/project-permission-definitions/${permissionId}`,
{ method: "DELETE" },
null,
);
}
async getSvixToken(): Promise<SvixTokenCrud["Admin"]["Read"]> {
const response = await this.sendAdminRequest(
"/webhooks/svix-token",
{
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({}),
},
null,
);
return await response.json();
}
async deleteProject(): Promise<void> {
await this.sendAdminRequest(
"/internal/projects/current",
{
method: "DELETE",
},
null,
);
}
async getMetrics(
includeAnonymous: boolean = false,
filters?: {
country_code?: string,
referrer?: string,
browser?: string,
os?: string,
device?: string,
since?: string,
until?: string,
},
): Promise<MetricsResponse> {
const params = new URLSearchParams();
if (includeAnonymous) {
params.append('include_anonymous', 'true');
}
if (filters?.country_code) params.append('filter_country_code', filters.country_code);
if (filters?.referrer) params.append('filter_referrer', filters.referrer);
if (filters?.browser) params.append('filter_browser', filters.browser);
if (filters?.os) params.append('filter_os', filters.os);
if (filters?.device) params.append('filter_device', filters.device);
if (filters?.since) params.append('filter_since', filters.since);
if (filters?.until) params.append('filter_until', filters.until);
const queryString = params.toString();
const response = await this.sendAdminRequest(
`/internal/metrics${queryString ? `?${queryString}` : ''}`,
{
method: "GET",
},
null,
);
const body = (await response.json()) as MetricsResponse;
// The yup schema's .optional().default(...) fallbacks only run during
// backend response validation, not on this client-side cast — apply them
// here too so the one-release-cycle tolerance for older servers that the
// schema comments promise actually holds for dashboard consumers. The
// Partial views widen the static type (which claims these are always
// defined) to match what an older server can actually send.
const rawBody: Partial<MetricsResponse> = body;
const rawAnalytics: Partial<MetricsResponse["analytics_overview"]> = body.analytics_overview;
return {
...body,
live_users: rawBody.live_users ?? 0,
hourly_users: rawBody.hourly_users ?? [],
hourly_active_users: rawBody.hourly_active_users ?? [],
analytics_overview: {
...body.analytics_overview,
hourly_page_views: rawAnalytics.hourly_page_views ?? [],
hourly_active_users: rawAnalytics.hourly_active_users ?? [],
hourly_visitors: rawAnalytics.hourly_visitors ?? [],
daily_anonymous_visitors_fallback: rawAnalytics.daily_anonymous_visitors_fallback ?? [],
anonymous_visitors_fallback: rawAnalytics.anonymous_visitors_fallback ?? 0,
top_regions: rawAnalytics.top_regions ?? [],
bounce_rate: rawAnalytics.bounce_rate ?? 0,
daily_bounce_rate: rawAnalytics.daily_bounce_rate ?? [],
daily_avg_session_seconds: rawAnalytics.daily_avg_session_seconds ?? [],
top_browsers: rawAnalytics.top_browsers ?? [],
top_operating_systems: rawAnalytics.top_operating_systems ?? [],
top_devices: rawAnalytics.top_devices ?? [],
},
};
}
async getPlanUsage(): Promise<PlanUsageResponse> {
const response = await this.sendAdminRequest(
"/internal/plan-usage",
{
method: "GET",
},
null,
);
return await response.json();
}
async getUserActivity(userId: string): Promise<UserActivityResponse> {
const response = await this.sendAdminRequest(
urlString`/internal/user-activity?user_id=${userId}`,
{
method: "GET",
},
null,
);
return (await response.json()) as UserActivityResponse;
}
async getAnalyticsClickmap(options: {
kind: AnalyticsClickmapKind,
member_user_ids?: string[],
route_path?: string,
route_regex?: string,
url_pattern?: string,
user_id?: string,
replay_id?: string,
device?: AnalyticsClickmapDevice,
viewport_width_min?: number,
viewport_width_max?: number,
sampling?: number,
since: string,
until: string,
}): Promise<AnalyticsClickmapResponse> {
const response = await this.sendAdminRequest(
"/internal/analytics/clickmap",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(options),
},
null,
);
return (await response.json()) as AnalyticsClickmapResponse;
}
async createAnalyticsClickmapToken(options: {
origin: string,
}): Promise<AnalyticsClickmapTokenResponse> {
const response = await this.sendAdminRequest(
"/internal/analytics/clickmap-token",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(options),
},
null,
);
return (await response.json()) as AnalyticsClickmapTokenResponse;
}
async getMetricsUserCounts(): Promise<MetricsUserCounts> {
const response = await this.sendAdminRequest(
"/internal/metrics/user-counts",
{
method: "GET",
},
null,
);
return (await response.json()) as MetricsUserCounts;
}
async sendTestEmail(data: {
recipient_email: string,
email_config: {
host: string,
port: number,
username: string,
password: string,
sender_email: string,
sender_name: string,
},
}): Promise<{ success: boolean, error_message?: string }> {
const response = await this.sendAdminRequest(`/internal/send-test-email`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(data),
}, null);
return await response.json();
}
async sendTestWebhook(data: {
endpoint_id: string,
}): Promise<{ success: boolean, error_message?: string }> {
const response = await this.sendAdminRequest(`/internal/send-test-webhook`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(data),
}, null);
return await response.json();
}
async listSentEmails(): Promise<InternalEmailsCrud["Admin"]["List"]> {
const response = await this.sendAdminRequest("/internal/emails", {
method: "GET",
}, null);
return await response.json();
}
async setupManagedEmailProvider(data: {
subdomain: string,
sender_local_part: string,
}): Promise<{
domain_id: string,
subdomain: string,
sender_local_part: string,
name_server_records: string[],
status: "pending_dns" | "pending_verification" | "verified" | "applied" | "failed",
}> {
const response = await this.sendAdminRequest("/internal/emails/managed-onboarding/setup", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(data),
}, null);
return await response.json();
}
async checkManagedEmailStatus(data: {
domain_id: string,
subdomain: string,
sender_local_part: string,
}): Promise<{ status: "pending_dns" | "pending_verification" | "verified" | "applied" | "failed" }> {
const response = await this.sendAdminRequest("/internal/emails/managed-onboarding/check", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(data),
}, null);
return await response.json();
}
async listManagedEmailDomains(): Promise<{
items: Array<{
domain_id: string,
subdomain: string,
sender_local_part: string,
status: "pending_dns" | "pending_verification" | "verified" | "applied" | "failed",
name_server_records: string[],
}>,
}> {
const response = await this.sendAdminRequest("/internal/emails/managed-onboarding/list", {
method: "GET",
}, null);
return await response.json();
}
async deleteManagedEmailDomain(data: {
resend_domain_id: string,
}): Promise<{ status: "deleted" }> {
const response = await this.sendAdminRequest("/internal/emails/managed-onboarding/delete", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(data),
}, null);
return await response.json();
}
async applyManagedEmailProvider(data: {
domain_id: string,
}): Promise<{ status: "applied" }> {
const response = await this.sendAdminRequest("/internal/emails/managed-onboarding/apply", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(data),
}, null);
return await response.json();
}
async sendSignInInvitationEmail(
email: string,
callbackUrl: string,
): Promise<void> {
await this.sendAdminRequest(
"/internal/send-sign-in-invitation",
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
email,
callback_url: callbackUrl,
}),
},
null,
);
}
async saveChatMessage(threadId: string, message: any): Promise<void> {
await this.sendAdminRequest(
`/internal/ai-chat/${threadId}`,
{
method: "PATCH",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ message }),
},
null,
);
}
async listChatMessages(threadId: string): Promise<{ messages: Array<any> }> {
const response = await this.sendAdminRequest(
`/internal/ai-chat/${threadId}`,
{ method: "GET" },
null,
);
return await response.json();
}
async renderEmailPreview(options: {
themeId?: string | null | false,
themeTsxSource?: string,
templateId?: string,
templateTsxSource?: string,
editableMarkers?: boolean,
editableSource?: 'template' | 'theme' | 'both',
}): Promise<{ html: string, editable_regions?: Record<string, unknown> }> {
const response = await this.sendAdminRequest(`/emails/render-email`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
theme_id: options.themeId,
theme_tsx_source: options.themeTsxSource,
template_id: options.templateId,
template_tsx_source: options.templateTsxSource,
editable_markers: options.editableMarkers,
editable_source: options.editableSource,
}),
}, null);
return await response.json();
}
async rewriteTemplateSourceWithAI(templateTsxSource: string): Promise<{ tsx_source: string }> {
const response = await this.sendAdminRequest(`/internal/rewrite-template-source`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
template_tsx_source: templateTsxSource,
}),
}, null);
return await response.json();
}
async createEmailTheme(displayName: string): Promise<{ id: string }> {
const response = await this.sendAdminRequest(
`/internal/email-themes`,
{
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
display_name: displayName,
}),
},
null,
);
return await response.json();
}
async getEmailTheme(id: string): Promise<{ display_name: string, tsx_source: string }> {
const response = await this.sendAdminRequest(
`/internal/email-themes/${id}`,
{ method: "GET" },
null,
);
return await response.json();
}
async updateEmailTheme(id: string, tsxSource: string): Promise<void> {
await this.sendAdminRequest(
`/internal/email-themes/${id}`,
{
method: "PATCH",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
tsx_source: tsxSource,
}),
},
null,
);
}
async deleteEmailTheme(id: string): Promise<void> {
await this.sendAdminRequest(
`/internal/email-themes/${id}`,
{
method: "DELETE",
},
null,
);
}
async updateEmailTemplate(id: string, tsxSource: string, themeId: string | null | false): Promise<{ rendered_html: string }> {
const response = await this.sendAdminRequest(
`/internal/email-templates/${id}`,
{
method: "PATCH",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ tsx_source: tsxSource, theme_id: themeId }),
},
null,
);
return await response.json();
}
async getConfig(): Promise<{ config_string: string }> {
const response = await this.sendAdminRequest(
`/internal/config`,
{ method: "GET" },
null,
);
return await response.json();
}
async getConfigOverride(level: "project" | "branch" | "environment"): Promise<{ config_string: string }> {
const response = await this.sendAdminRequest(
`/internal/config/override/${level}`,
{ method: "GET" },
null,
);
return await response.json();
}
async setConfigOverride(level: "project" | "branch" | "environment", configOverride: any, source?: BranchConfigSourceApi): Promise<void> {
await this.sendAdminRequest(
`/internal/config/override/${level}`,
{
method: "PUT",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
config_string: JSON.stringify(configOverride),
...(source && { source }),
}),
},
null,
);
}
async updateConfigOverride(level: "project" | "branch" | "environment", configOverrideOverride: any): Promise<void> {
await this.sendAdminRequest(
`/internal/config/override/${level}`,
{
method: "PATCH",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ config_override_string: JSON.stringify(configOverrideOverride) }),
},
null,
);
}
async getPushedConfigSource(): Promise<BranchConfigSourceApi> {
const response = await this.sendAdminRequest(
`/internal/config/source`,
{ method: "GET" },
null,
);
const data = await response.json();
return data.source;
}
async unlinkPushedConfigSource(): Promise<void> {
await this.sendAdminRequest(
`/internal/config/source`,
{ method: "DELETE" },
null,
);
}
/**
* Applies a dashboard config change to the linked GitHub repo by running the
* config agent in a sandbox (server-side). Returns immediately; poll
* `getPushedConfigSource().agent_run` for progress. The GitHub access token is
* the caller's own OAuth token and is used transiently server-side.
*/
async applyConfigViaAgent(options: { configUpdate: any, commitMessage?: string, githubAccessToken: string }): Promise<{ status: "started" | "already-running" }> {
const response = await this.sendAdminRequest(
`/internal/config/github/apply`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
github_access_token: options.githubAccessToken,
config_update_string: JSON.stringify(options.configUpdate),
...(options.commitMessage ? { commit_message: options.commitMessage } : {}),
}),
},
null,
);
return await response.json();
}
/**
* Cancels the in-flight agent-driven config write: hard-stops the sandbox so
* the agent stops mid-work. Also cancels runs in `awaiting_review`. No revert
* — if the agent already pushed, the commit stays. Returns `not-running` if
* no run is in flight.
*/
async cancelConfigAgentRun(): Promise<{ status: "cancelling" | "not-running" }> {
const response = await this.sendAdminRequest(
`/internal/config/github/cancel`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
},
null,
);
return await response.json();
}
/**
* Commits and pushes the agent's already-applied changes after the user has
* reviewed the diff. Only valid when a run is in `awaiting_review` status.
* Returns `sandbox-expired` if the review state exists but its sandbox id is
* missing, which means the user needs to rerun the agent.
*/
async commitConfigAgentRun(options: { githubAccessToken: string, commitMessage?: string }): Promise<{ status: "committing" | "not-awaiting-review" | "sandbox-expired" }> {
const response = await this.sendAdminRequest(
`/internal/config/github/commit`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
github_access_token: options.githubAccessToken,
...(options.commitMessage ? { commit_message: options.commitMessage } : {}),
}),
},
null,
);
return await response.json();
}
async resetConfigOverrideKeys(level: "branch" | "environment", keys: string[]): Promise<void> {
await this.sendAdminRequest(
`/internal/config/override/${level}/reset-keys`,
{
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ keys }),
},
null,
);
}
async createEmailTemplate(displayName: string): Promise<{ id: string }> {
const response = await this.sendAdminRequest(
`/internal/email-templates`,
{
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
display_name: displayName,
}),
},
null,
);
return await response.json();
}
async deleteEmailTemplate(id: string): Promise<void> {
await this.sendAdminRequest(
`/internal/email-templates/${id}`,
{
method: "DELETE",
},
null,
);
}
async setupPayments(): Promise<{ url: string }> {
const response = await this.sendAdminRequest(
"/internal/payments/setup",
{
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({}),
},
null,
);
return await response.json();
}
async getStripeAccountInfo(): Promise<null | { account_id: string, charges_enabled: boolean, details_submitted: boolean, payouts_enabled: boolean }> {
const response = await this.sendAdminRequestAndCatchKnownError(
"/internal/payments/stripe/account-info",
{},
null,
[KnownErrors.StripeAccountInfoNotFound],
);
if (response.status === "error") {
return null;
}
return await response.data.json();
}
async getPaymentMethodConfigs(): Promise<{ configId: string, methods: Array<{ id: string, name: string, enabled: boolean, available: boolean, overridable: boolean }> } | null> {
const response = await this.sendAdminRequestAndCatchKnownError(
"/internal/payments/method-configs",
{ method: "GET" },
null,
[KnownErrors.StripeAccountInfoNotFound],
);
if (response.status === "error") {
return null;
}
const data = await response.data.json();
return {
configId: data.config_id,
methods: data.methods,
};
}
async updatePaymentMethodConfigs(configId: string, updates: Record<string, 'on' | 'off'>): Promise<void> {
await this.sendAdminRequest(
"/internal/payments/method-configs",
{
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ config_id: configId, updates }),
},
null,
);
}
async createStripeWidgetAccountSession(): Promise<{ client_secret: string }> {
const response = await this.sendAdminRequest(
"/internal/payments/stripe-widgets/account-session",
{
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({}),
},
null,
);
return await response.json();
}
async listTransactions(params?: { cursor?: string, limit?: number, type?: TransactionType, customerType?: 'user' | 'team' | 'custom', customerId?: string }): Promise<{ transactions: Transaction[], nextCursor: string | null }> {
const qs = new URLSearchParams();
if (params?.cursor) qs.set('cursor', params.cursor);
if (typeof params?.limit === 'number') qs.set('limit', String(params.limit));
if (params?.type) qs.set('type', params.type);
if (params?.customerType) qs.set('customer_type', params.customerType);
if (params?.customerId) qs.set('customer_id', params.customerId);
const response = await this.sendAdminRequest(
`/internal/payments/transactions${qs.size ? `?${qs.toString()}` : ''}`,
{ method: 'GET' },
null,
);
const json = await response.json() as { transactions: Transaction[], next_cursor: string | null };
return { transactions: json.transactions, nextCursor: json.next_cursor };
}
async listSessionReplays(params?: AdminListSessionReplaysOptions): Promise<AdminListSessionReplaysResponse> {
const qs = new URLSearchParams();
if (params?.cursor) qs.set("cursor", params.cursor);
if (typeof params?.limit === "number") qs.set("limit", String(params.limit));
if (params?.user_ids && params.user_ids.length > 0) qs.set("user_ids", params.user_ids.join(","));
if (params?.team_ids && params.team_ids.length > 0) qs.set("team_ids", params.team_ids.join(","));
if (typeof params?.duration_ms_min === "number") qs.set("duration_ms_min", String(params.duration_ms_min));
if (typeof params?.duration_ms_max === "number") qs.set("duration_ms_max", String(params.duration_ms_max));
if (typeof params?.last_event_at_from_millis === "number") qs.set("last_event_at_from_millis", String(params.last_event_at_from_millis));
if (typeof params?.last_event_at_to_millis === "number") qs.set("last_event_at_to_millis", String(params.last_event_at_to_millis));
if (typeof params?.click_count_min === "number") qs.set("click_count_min", String(params.click_count_min));
const response = await this.sendAdminRequest(
`/internal/session-replays${qs.size ? `?${qs.toString()}` : ""}`,
{ method: "GET" },
null,
);
return await response.json();
}
async getSessionReplay(sessionReplayId: string): Promise<AdminGetSessionReplayResponse> {
const response = await this.sendAdminRequest(
`/internal/session-replays/${encodeURIComponent(sessionReplayId)}`,
{ method: "GET" },
null,
);
return await response.json();
}
async listSessionReplayChunks(sessionReplayId: string, params?: AdminListSessionReplayChunksOptions): Promise<AdminListSessionReplayChunksResponse> {
const qs = new URLSearchParams();
if (params?.cursor) qs.set("cursor", params.cursor);
if (typeof params?.limit === "number") qs.set("limit", String(params.limit));
const response = await this.sendAdminRequest(
`/internal/session-replays/${encodeURIComponent(sessionReplayId)}/chunks${qs.size ? `?${qs.toString()}` : ""}`,
{ method: "GET" },
null,
);
return await response.json();
}
async getSessionReplayChunkEvents(sessionReplayId: string, chunkId: string): Promise<AdminGetSessionReplayChunkEventsResponse> {
const response = await this.sendAdminRequest(
`/internal/session-replays/${encodeURIComponent(sessionReplayId)}/chunks/${encodeURIComponent(chunkId)}/events`,
{ method: "GET" },
null,
);
return await response.json();
}
async getSessionReplayEvents(sessionReplayId: string, options?: { offset?: number, limit?: number }): Promise<AdminGetSessionReplayAllEventsResponse> {
const qs = new URLSearchParams();
if (typeof options?.offset === "number") qs.set("offset", String(options.offset));
if (typeof options?.limit === "number") qs.set("limit", String(options.limit));
const response = await this.sendAdminRequest(
`/internal/session-replays/${encodeURIComponent(sessionReplayId)}/events${qs.size ? `?${qs.toString()}` : ""}`,
{ method: "GET" },
null,
);
return await response.json();
}
async refundTransaction(options: {
type: "subscription" | "one-time-purchase",
id: string,
invoiceId?: string,
amountUsd: MoneyAmount,
/**
* Lifecycle action for the source purchase:
* "now" — end product access immediately (revokes product,
* expires item grants, cancels Stripe sub if any).
* "at-period-end" — schedule sub cancel-at-period-end; subscriptions
* only — rejected for one-time purchases.
* undefined — no lifecycle change; refund money only.
*/
endAction?: "now" | "at-period-end",
}): Promise<{ success: boolean, refundTransactionId: string }> {
const response = await this.sendAdminRequest(
"/internal/payments/transactions/refund",
{
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
type: options.type,
id: options.id,
...(options.invoiceId !== undefined ? { invoice_id: options.invoiceId } : {}),
amount_usd: options.amountUsd,
...(options.endAction !== undefined ? { end_action: options.endAction } : {}),
}),
},
null,
);
const json = await response.json();
return { success: json.success, refundTransactionId: json.refund_transaction_id };
}
async previewAffectedUsersByOnboardingChange(
onboarding: { require_email_verification?: boolean },
limit?: number,
): Promise<{
affected_users: Array<{
id: string,
display_name: string | null,
primary_email: string | null,
restricted_reason: RestrictedReason,
}>,
total_affected_count: number,
}> {
const response = await this.sendAdminRequest(
`/internal/onboarding/preview-affected-users${limit ? `?limit=${limit}` : ''}`,
{
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ onboarding }),
},
null,
);
return await response.json();
}
async queryAnalytics(options: AnalyticsQueryOptions): Promise<AnalyticsQueryResponse> {
const response = await this.sendAdminRequest(
"/internal/analytics/query",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
query: options.query,
params: options.params ?? {},
timeout_ms: options.timeout_ms ?? 1000,
include_all_branches: options.include_all_branches ?? false,
}),
},
null,
);
return await response.json();
}
async listOutboxEmails(options?: { status?: string, simple_status?: string, user_id?: string, limit?: number, cursor?: string }): Promise<EmailOutboxCrud["Server"]["List"]> {
const qs = new URLSearchParams();
if (options?.status) qs.set('status', options.status);
if (options?.simple_status) qs.set('simple_status', options.simple_status);
if (options?.user_id) qs.set('user_id', options.user_id);
if (options?.limit !== undefined) qs.set('limit', options.limit.toString());
if (options?.cursor) qs.set('cursor', options.cursor);
const response = await this.sendServerRequest(
`/emails/outbox${qs.size ? `?${qs.toString()}` : ''}`,
{ method: 'GET' },
null,
);
return await response.json();
}
async getOutboxEmail(id: string): Promise<EmailOutboxCrud["Server"]["Read"]> {
const response = await this.sendServerRequest(
`/emails/outbox/${id}`,
{ method: 'GET' },
null,
);
return await response.json();
}
async updateOutboxEmail(id: string, data: EmailOutboxCrud["Server"]["Update"]): Promise<EmailOutboxCrud["Server"]["Read"]> {
const response = await this.sendServerRequest(
`/emails/outbox/${id}`,
{
method: 'PATCH',
headers: {
"content-type": "application/json",
},
body: JSON.stringify(data),
},
null,
);
return await response.json();
}
}