Public Access
1
0

feat: complete admin phase C and add safe mutation pilot

This commit is contained in:
pguerrerox
2026-05-28 12:46:06 +00:00
parent c58945353d
commit ce49497a6a
17 changed files with 1568 additions and 21 deletions
+2
View File
@@ -11,6 +11,7 @@ import { searchJobRoutes } from './routes/search-jobs.js';
import { analyticsRoutes } from './routes/analytics.js';
import { adminBootstrapRoutes } from './routes/admin-bootstrap.js';
import { adminAccessRoutes } from './routes/admin-access.js';
import { adminOpsRoutes } from './routes/admin-ops.js';
function parseAllowedOrigins(rawOrigins: string) {
return rawOrigins
@@ -60,6 +61,7 @@ export async function buildApp() {
await app.register(analyticsRoutes, { prefix: '/api' });
await app.register(adminBootstrapRoutes, { prefix: '/api' });
await app.register(adminAccessRoutes, { prefix: '/api' });
await app.register(adminOpsRoutes, { prefix: '/api' });
return app;
}
+151 -1
View File
@@ -6,6 +6,24 @@ import { getDbPool } from '../db/pool.js';
type DbClient = Pool | PoolClient;
export const ADMIN_AUDIT_ACTIONS = {
ADMIN_ACCESS_LIST: 'admin_access_list',
ADMIN_ACCESS_UPSERT: 'admin_access_upsert',
ADMIN_ACCESS_STATUS_CHANGED: 'admin_access_status_changed',
ANALYTICS_SUMMARY: 'analytics_summary',
BILLING_WORKSPACES_LIST: 'billing_workspaces_list',
BILLING_WORKSPACE_DETAIL: 'billing_workspace_detail',
BOOTSTRAP_ADMIN_CLAIMED: 'bootstrap_admin_claimed',
ADMIN_OPS_AUDIT_LIST: 'admin_ops_audit_list',
ADMIN_OPS_SECURITY_POSTURE: 'admin_ops_security_posture',
ADMIN_OPS_DIAGNOSTICS: 'admin_ops_diagnostics',
ADMIN_MUTATION_BILLING_RESYNC_REQUESTED: 'admin_mutation_billing_resync_requested',
} as const;
export type AdminAuditAction = (typeof ADMIN_AUDIT_ACTIONS)[keyof typeof ADMIN_AUDIT_ACTIONS];
export const ADMIN_AUDIT_ACTION_VALUES = Object.values(ADMIN_AUDIT_ACTIONS) as [AdminAuditAction, ...AdminAuditAction[]];
type ApplicationAdminRow = {
id: string;
email: string;
@@ -17,6 +35,40 @@ type ApplicationAdminRow = {
updated_at: string;
};
type AdminAccessAuditRow = {
id: string;
actor_user_id: string | null;
actor_email: string | null;
route: string;
action: string;
target_workspace_id: string | null;
target_workspace_name: string | null;
metadata_json: Record<string, unknown>;
occurred_at: string;
};
export type AdminAccessAuditSummary = {
id: string;
actorUserId: string | null;
actorEmail: string | null;
route: string;
action: AdminAuditAction;
targetWorkspaceId: string | null;
targetWorkspaceName: string | null;
metadataJson: Record<string, unknown>;
occurredAt: string;
};
export type AdminAccessAuditListFilters = {
actorEmail?: string;
action?: AdminAuditAction;
workspaceId?: string;
from?: string;
to?: string;
limit: number;
offset: number;
};
function toApplicationAdminSummary(row: ApplicationAdminRow): ApplicationAdminSummary {
return {
id: row.id,
@@ -33,7 +85,7 @@ type AdminAccessAuditPayload = {
actorUserId?: string | null;
actorEmail?: string | null;
route: string;
action: string;
action: AdminAuditAction;
targetWorkspaceId?: string | null;
metadataJson?: Record<string, unknown>;
occurredAt?: string;
@@ -222,3 +274,101 @@ export async function recordAdminAccessAudit(db: DbClient, payload: AdminAccessA
],
);
}
function buildAdminAccessAuditWhere(filters: Omit<AdminAccessAuditListFilters, 'limit' | 'offset'>) {
const clauses: string[] = [];
const params: Array<string> = [];
if (filters.actorEmail?.trim()) {
params.push(`%${filters.actorEmail.trim().toLowerCase()}%`);
clauses.push(`lower(audit.actor_email) like $${params.length}`);
}
if (filters.action?.trim()) {
params.push(filters.action.trim());
clauses.push(`audit.action = $${params.length}`);
}
if (filters.workspaceId?.trim()) {
params.push(filters.workspaceId.trim());
clauses.push(`audit.target_workspace_id = $${params.length}::uuid`);
}
if (filters.from) {
params.push(filters.from);
clauses.push(`audit.occurred_at >= $${params.length}::timestamptz`);
}
if (filters.to) {
params.push(filters.to);
clauses.push(`audit.occurred_at <= $${params.length}::timestamptz`);
}
return {
whereSql: clauses.length > 0 ? `where ${clauses.join(' and ')}` : '',
params,
};
}
function mapAdminAccessAuditRow(row: AdminAccessAuditRow): AdminAccessAuditSummary {
return {
id: row.id,
actorUserId: row.actor_user_id,
actorEmail: row.actor_email,
route: row.route,
action: row.action as AdminAuditAction,
targetWorkspaceId: row.target_workspace_id,
targetWorkspaceName: row.target_workspace_name,
metadataJson: row.metadata_json,
occurredAt: row.occurred_at,
};
}
export async function countAdminAccessAudit(
db: DbClient,
filters: Omit<AdminAccessAuditListFilters, 'limit' | 'offset'>,
): Promise<number> {
const built = buildAdminAccessAuditWhere(filters);
const result = await db.query<{ count: string }>(
`
select count(*)::text as count
from public.admin_access_audit audit
${built.whereSql}
`,
built.params,
);
return Number(result.rows[0]?.count ?? '0');
}
export async function listAdminAccessAudit(db: DbClient, filters: AdminAccessAuditListFilters): Promise<AdminAccessAuditSummary[]> {
const built = buildAdminAccessAuditWhere(filters);
const params = [...built.params, filters.limit, filters.offset];
const limitParam = `$${built.params.length + 1}`;
const offsetParam = `$${built.params.length + 2}`;
const result = await db.query<AdminAccessAuditRow>(
`
select
audit.id,
audit.actor_user_id,
audit.actor_email,
audit.route,
audit.action,
audit.target_workspace_id,
workspace.name as target_workspace_name,
audit.metadata_json,
audit.occurred_at
from public.admin_access_audit audit
left join public.workspaces workspace
on workspace.id = audit.target_workspace_id
${built.whereSql}
order by audit.occurred_at desc
limit ${limitParam}
offset ${offsetParam}
`,
params,
);
return result.rows.map(mapAdminAccessAuditRow);
}
+140
View File
@@ -133,6 +133,17 @@ type BillingAdminWorkspaceSummaryRow = {
external_subscription_ref: string | null;
};
type BillingStaleSyncWorkspaceRow = {
workspace_id: string;
workspace_name: string;
billing_sync_status: BillingSyncStatus;
last_stripe_sync_at: string | null;
status: AccountBillingStatus;
plan_code: string | null;
pending_plan_code: string | null;
pending_plan_effective_at: string | null;
};
type UsagePeriodRow = {
id: string;
workspace_id: string;
@@ -174,6 +185,23 @@ type AddonPurchaseRow = {
updated_at: string;
};
export type BillingStaleSyncWorkspaceSummary = {
workspaceId: string;
workspaceName: string;
billingSyncStatus: BillingSyncStatus;
lastStripeSyncAt: string | null;
status: AccountBillingStatus;
planCode: PlanCode | null;
pendingPlanCode: PlanCode | null;
pendingPlanEffectiveAt: string | null;
};
export type BillingTimelineAnomalySummary = {
repeatedPaymentFailedCount: number;
pendingPlanPastEffectiveCount: number;
staleSyncThresholdCount: number;
};
export async function getBillingAccountForWorkspace(db: DbClient, workspaceId: string): Promise<BillingAccountRecord | null> {
const result = await db.query<BillingAccountRow>(
`
@@ -509,6 +537,118 @@ export async function listRecentBillingTimelineEventsForWorkspace(db: DbClient,
return result.rows.map(mapBillingTimelineEventRow);
}
export async function listStaleBillingSyncWorkspaces(
db: DbClient,
input: { staleThresholdHours?: number; limit?: number } = {},
): Promise<BillingStaleSyncWorkspaceSummary[]> {
const staleThresholdHours = input.staleThresholdHours ?? 24;
const limit = input.limit ?? 10;
const result = await db.query<BillingStaleSyncWorkspaceRow>(
`
select
w.id as workspace_id,
w.name as workspace_name,
billing.billing_sync_status,
billing.last_stripe_sync_at,
billing.status,
billing.plan_code,
billing.pending_plan_code,
billing.pending_plan_effective_at
from public.workspace_billing_accounts billing
join public.workspaces w on w.id = billing.workspace_id
where
billing.billing_sync_status in ('stale', 'error')
or billing.last_stripe_sync_at is null
or billing.last_stripe_sync_at < now() - make_interval(hours => $1::int)
order by
case when billing.billing_sync_status = 'error' then 0 else 1 end asc,
billing.last_stripe_sync_at asc nulls first
limit $2
`,
[staleThresholdHours, limit],
);
return result.rows.map((row) => ({
workspaceId: row.workspace_id,
workspaceName: row.workspace_name,
billingSyncStatus: row.billing_sync_status,
lastStripeSyncAt: row.last_stripe_sync_at,
status: row.status,
planCode: row.plan_code as PlanCode | null,
pendingPlanCode: row.pending_plan_code as PlanCode | null,
pendingPlanEffectiveAt: row.pending_plan_effective_at,
}));
}
export async function countStaleBillingSyncWorkspaces(db: DbClient, staleThresholdHours = 24): Promise<number> {
const result = await db.query<{ count: string }>(
`
select count(*)::text as count
from public.workspace_billing_accounts billing
where
billing.billing_sync_status in ('stale', 'error')
or billing.last_stripe_sync_at is null
or billing.last_stripe_sync_at < now() - make_interval(hours => $1::int)
`,
[staleThresholdHours],
);
return Number(result.rows[0]?.count ?? '0');
}
export async function getBillingTimelineAnomalySummary(
db: DbClient,
input: { windowDays?: number; paymentFailedThreshold?: number; staleThresholdHours?: number } = {},
): Promise<BillingTimelineAnomalySummary> {
const windowDays = input.windowDays ?? 7;
const paymentFailedThreshold = input.paymentFailedThreshold ?? 2;
const staleThresholdHours = input.staleThresholdHours ?? 24;
const repeatedPaymentFailedResult = await db.query<{ count: string }>(
`
select count(*)::text as count
from (
select event.workspace_id
from public.workspace_billing_timeline_events event
where event.event_type = 'invoice_payment_failed'
and event.occurred_at >= now() - make_interval(days => $1::int)
group by event.workspace_id
having count(*) >= $2
) repeated_failure_workspaces
`,
[windowDays, paymentFailedThreshold],
);
const pendingPlanPastEffectiveResult = await db.query<{ count: string }>(
`
select count(*)::text as count
from public.workspace_billing_accounts billing
where billing.pending_plan_code is not null
and billing.pending_plan_effective_at is not null
and billing.pending_plan_effective_at < now()
`,
);
const staleSyncThresholdResult = await db.query<{ count: string }>(
`
select count(*)::text as count
from public.workspace_billing_accounts billing
where
billing.billing_sync_status in ('stale', 'error')
or billing.last_stripe_sync_at is null
or billing.last_stripe_sync_at < now() - make_interval(hours => $1::int)
`,
[staleThresholdHours],
);
return {
repeatedPaymentFailedCount: Number(repeatedPaymentFailedResult.rows[0]?.count ?? '0'),
pendingPlanPastEffectiveCount: Number(pendingPlanPastEffectiveResult.rows[0]?.count ?? '0'),
staleSyncThresholdCount: Number(staleSyncThresholdResult.rows[0]?.count ?? '0'),
};
}
export async function listBillingAdminWorkspaceSummaries(db: DbClient, search: string | null, limit = 50) {
const result = await db.query<BillingAdminWorkspaceSummaryRow>(
`
+13
View File
@@ -68,11 +68,24 @@ function parseAdminEmailAllowlist(allowlist: string | undefined): string[] {
.filter(Boolean);
}
function hasEntries(raw: string | undefined) {
return parseAdminEmailAllowlist(raw).length > 0;
}
export function getAdminEmailAllowlist() {
const env = getEnv();
return parseAdminEmailAllowlist(env.ADMIN_EMAILS || env.BILLING_ADMIN_EMAILS);
}
export function hasAdminEmailAllowlistConfigured() {
return hasEntries(getEnv().ADMIN_EMAILS);
}
export function isUsingDeprecatedBillingAdminFallback() {
const env = getEnv();
return !hasEntries(env.ADMIN_EMAILS) && hasEntries(env.BILLING_ADMIN_EMAILS);
}
export function isBillingAdminEmail(email: string) {
const allowlist = getAdminEmailAllowlist();
+64
View File
@@ -38,6 +38,14 @@ type BillingWebhookEventRow = {
updated_at: string;
};
type BillingFailedWebhookEventRow = BillingWebhookEventRow & {
workspace_name: string | null;
};
export type BillingFailedWebhookEventSummary = BillingWebhookEventRecord & {
workspaceName: string | null;
};
export async function recordIncomingWebhookEvent(
db: DbClient,
input: {
@@ -146,6 +154,62 @@ export async function listRecentWebhookEventsForWorkspace(db: DbClient, workspac
return result.rows.map(mapBillingWebhookEventRow);
}
export async function listRecentFailedWebhookEvents(
db: DbClient,
input: { sinceDays?: number; limit?: number } = {},
): Promise<BillingFailedWebhookEventSummary[]> {
const sinceDays = input.sinceDays ?? 7;
const limit = input.limit ?? 10;
const result = await db.query<BillingFailedWebhookEventRow>(
`
select
event.id,
event.provider,
event.external_event_id,
event.event_type,
event.status,
event.workspace_id,
event.external_customer_ref,
event.external_subscription_ref,
event.payload_json,
event.error_message,
event.received_at,
event.processed_at,
event.created_at,
event.updated_at,
workspace.name as workspace_name
from public.billing_webhook_events event
left join public.workspaces workspace
on workspace.id = event.workspace_id
where event.status = 'failed'
and event.received_at >= now() - make_interval(days => $1::int)
order by event.received_at desc
limit $2
`,
[sinceDays, limit],
);
return result.rows.map((row) => ({
...mapBillingWebhookEventRow(row),
workspaceName: row.workspace_name,
}));
}
export async function countRecentFailedWebhookEvents(db: DbClient, sinceDays = 7): Promise<number> {
const result = await db.query<{ count: string }>(
`
select count(*)::text as count
from public.billing_webhook_events event
where event.status = 'failed'
and event.received_at >= now() - make_interval(days => $1::int)
`,
[sinceDays],
);
return Number(result.rows[0]?.count ?? '0');
}
function mapBillingWebhookEventRow(row: BillingWebhookEventRow): BillingWebhookEventRecord {
return {
id: row.id,
+30 -1
View File
@@ -5,7 +5,14 @@ import { getAddonByCode } from '../../../shared/billing/addons.js';
import type { AddonCode, ActivePlanCode } from '../../../shared/billing/plans.js';
import { getPlanByCode } from '../../../shared/billing/plans.js';
import { ensureWorkspaceForUser } from '../account/repository.js';
import { ensureBillingAccountForWorkspace, listAddonBalancesForWorkspace, recordAddonPurchase, updateBillingAccountState, upsertAddonBalance } from '../billing/repository.js';
import {
ensureBillingAccountForWorkspace,
getBillingAccountForWorkspace,
listAddonBalancesForWorkspace,
recordAddonPurchase,
updateBillingAccountState,
upsertAddonBalance,
} from '../billing/repository.js';
import { getEnv } from '../config/env.js';
import {
assertAddonSupportsStripeCheckout,
@@ -336,6 +343,28 @@ export async function retrieveStripeSubscription(subscriptionId: string) {
return stripe.subscriptions.retrieve(subscriptionId);
}
export async function requestWorkspaceBillingResyncByAdmin(db: DbClient, input: { workspaceId: string }) {
ensureStripeReady('resync');
const billingAccount = await getBillingAccountForWorkspace(db, input.workspaceId);
if (!billingAccount) {
throw new Error('Billing workspace not found.');
}
if (!billingAccount.externalSubscriptionRef) {
throw new Error('Workspace does not have a Stripe subscription reference.');
}
const subscription = await retrieveStripeSubscription(billingAccount.externalSubscriptionRef);
await syncWorkspaceBillingFromStripeSubscription(db, subscription, input.workspaceId);
return {
workspaceId: input.workspaceId,
externalSubscriptionRef: billingAccount.externalSubscriptionRef,
};
}
export function mapStripeSubscriptionStatus(status: Stripe.Subscription.Status): 'active' | 'inactive' | 'past_due' | 'canceled' {
switch (status) {
case 'active':
+4 -3
View File
@@ -7,6 +7,7 @@ import type {
AdminApplicationAdminUpsertRequest,
} from '../../../shared/types.js';
import {
ADMIN_AUDIT_ACTIONS,
getActiveApplicationAdminCountExcludingId,
listApplicationAdmins,
recordAdminAccessAudit,
@@ -37,7 +38,7 @@ export const adminAccessRoutes: FastifyPluginAsync = async (app) => {
actorUserId: request.authUser?.id,
actorEmail: request.authUser?.email,
route: request.routeOptions.url ?? request.url,
action: 'admin_access_list',
action: ADMIN_AUDIT_ACTIONS.ADMIN_ACCESS_LIST,
});
const admins = await listApplicationAdmins(db);
const response: AdminApplicationAdminsListResponse = { admins };
@@ -60,7 +61,7 @@ export const adminAccessRoutes: FastifyPluginAsync = async (app) => {
actorUserId: request.authUser?.id,
actorEmail: request.authUser?.email,
route: request.routeOptions.url ?? request.url,
action: 'admin_access_upsert',
action: ADMIN_AUDIT_ACTIONS.ADMIN_ACCESS_UPSERT,
metadataJson: {
targetEmail: payload.email.trim(),
},
@@ -103,7 +104,7 @@ export const adminAccessRoutes: FastifyPluginAsync = async (app) => {
actorUserId: request.authUser?.id,
actorEmail: request.authUser?.email,
route: request.routeOptions.url ?? request.url,
action: 'admin_access_status_changed',
action: ADMIN_AUDIT_ACTIONS.ADMIN_ACCESS_STATUS_CHANGED,
metadataJson: {
targetAdminId: adminId,
nextStatus: payload.status,
+8 -2
View File
@@ -2,7 +2,13 @@ import type { FastifyPluginAsync, FastifyRequest } from 'fastify';
import { ZodError, z } from 'zod';
import type { AdminBootstrapClaimResponse, AdminBootstrapStatusResponse } from '../../../shared/types.js';
import { createDefaultWorkspaceForUser } from '../account/repository.js';
import { createApplicationAdmin, isApplicationAdmin, isBootstrapRequired, recordAdminAccessAudit } from '../auth/admin.js';
import {
ADMIN_AUDIT_ACTIONS,
createApplicationAdmin,
isApplicationAdmin,
isBootstrapRequired,
recordAdminAccessAudit,
} from '../auth/admin.js';
import { hashPassword } from '../auth/passwords.js';
import { createSession, setSessionCookie } from '../auth/sessions.js';
import { createUser, getUserByEmail, toAppUser } from '../auth/users.js';
@@ -91,7 +97,7 @@ export const adminBootstrapRoutes: FastifyPluginAsync = async (app) => {
actorUserId: user.id,
actorEmail: user.email,
route: '/api/admin/bootstrap/claim',
action: 'bootstrap_admin_claimed',
action: ADMIN_AUDIT_ACTIONS.BOOTSTRAP_ADMIN_CLAIMED,
metadataJson: {
bootstrapRequiredAtClaim: true,
},
+259
View File
@@ -0,0 +1,259 @@
import type { FastifyPluginAsync } from 'fastify';
import { ZodError, z } from 'zod';
import type {
AdminAuditLogListResponse,
AdminBillingResyncRequest,
AdminBillingResyncResponse,
AdminSecurityPostureResponse,
AdminSupportDiagnosticsResponse,
} from '../../../shared/types.js';
import {
ADMIN_AUDIT_ACTIONS,
ADMIN_AUDIT_ACTION_VALUES,
countAdminAccessAudit,
getActiveApplicationAdminCount,
isBootstrapRequired,
listAdminAccessAudit,
recordAdminAccessAudit,
requireAdmin,
} from '../auth/admin.js';
import { requireAuth } from '../auth/middleware.js';
import {
countStaleBillingSyncWorkspaces,
getBillingTimelineAnomalySummary,
listStaleBillingSyncWorkspaces,
} from '../billing/repository.js';
import {
hasAdminEmailAllowlistConfigured,
isAdminBootstrapEnabled,
isUsingDeprecatedBillingAdminFallback,
} from '../config/env.js';
import { getDbPool } from '../db/pool.js';
import {
countRecentFailedWebhookEvents,
listRecentFailedWebhookEvents,
} from '../payments/repository.js';
import { requestWorkspaceBillingResyncByAdmin } from '../payments/service.js';
const auditListQuerySchema = z.object({
actorEmail: z.string().trim().min(1).max(255).optional(),
action: z.enum(ADMIN_AUDIT_ACTION_VALUES).optional(),
workspaceId: z.string().uuid().optional(),
from: z.string().datetime().optional(),
to: z.string().datetime().optional(),
page: z.coerce.number().int().min(1).max(10_000).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
});
const diagnosticsQuerySchema = z.object({
windowDays: z.coerce.number().int().min(1).max(90).optional(),
staleSyncThresholdHours: z.coerce.number().int().min(1).max(720).optional(),
sampleLimit: z.coerce.number().int().min(1).max(50).optional(),
});
const billingResyncBodySchema = z.object({
workspaceId: z.string().uuid(),
reason: z.string().trim().min(10).max(500),
confirmationText: z.literal('RESYNC'),
ticketRef: z.string().trim().min(1).max(120).optional(),
});
export const adminOpsRoutes: FastifyPluginAsync = async (app) => {
app.get('/admin/ops/audit', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => {
try {
const query = auditListQuerySchema.parse(request.query ?? {});
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 25;
const offset = (page - 1) * pageSize;
const db = getDbPool();
await recordAdminAccessAudit(db, {
actorUserId: request.authUser?.id,
actorEmail: request.authUser?.email,
route: request.routeOptions.url ?? request.url,
action: ADMIN_AUDIT_ACTIONS.ADMIN_OPS_AUDIT_LIST,
metadataJson: {
filters: {
actorEmail: query.actorEmail ?? null,
action: query.action ?? null,
workspaceId: query.workspaceId ?? null,
from: query.from ?? null,
to: query.to ?? null,
},
page,
pageSize,
},
});
const [items, total] = await Promise.all([
listAdminAccessAudit(db, {
actorEmail: query.actorEmail,
action: query.action,
workspaceId: query.workspaceId,
from: query.from,
to: query.to,
limit: pageSize,
offset,
}),
countAdminAccessAudit(db, {
actorEmail: query.actorEmail,
action: query.action,
workspaceId: query.workspaceId,
from: query.from,
to: query.to,
}),
]);
const response: AdminAuditLogListResponse = {
items,
page,
pageSize,
total,
};
return response;
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid admin audit query.' });
}
request.log.error(error);
return reply.code(500).send({ error: 'Failed to load admin audit logs.' });
}
});
app.get('/admin/ops/security-posture', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => {
try {
const db = getDbPool();
await recordAdminAccessAudit(db, {
actorUserId: request.authUser?.id,
actorEmail: request.authUser?.email,
route: request.routeOptions.url ?? request.url,
action: ADMIN_AUDIT_ACTIONS.ADMIN_OPS_SECURITY_POSTURE,
});
const [bootstrapRequired, activeApplicationAdminCount] = await Promise.all([
isBootstrapRequired(db),
getActiveApplicationAdminCount(db),
]);
const response: AdminSecurityPostureResponse = {
bootstrapRequired,
bootstrapEnabled: isAdminBootstrapEnabled(),
activeApplicationAdminCount,
adminAllowlistConfigured: hasAdminEmailAllowlistConfigured(),
usingDeprecatedBillingAdminFallback: isUsingDeprecatedBillingAdminFallback(),
};
return response;
} catch (error) {
request.log.error(error);
return reply.code(500).send({ error: 'Failed to load admin security posture.' });
}
});
app.get('/admin/ops/diagnostics', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => {
try {
const query = diagnosticsQuerySchema.parse(request.query ?? {});
const windowDays = query.windowDays ?? 7;
const staleSyncThresholdHours = query.staleSyncThresholdHours ?? 24;
const sampleLimit = query.sampleLimit ?? 10;
const db = getDbPool();
await recordAdminAccessAudit(db, {
actorUserId: request.authUser?.id,
actorEmail: request.authUser?.email,
route: request.routeOptions.url ?? request.url,
action: ADMIN_AUDIT_ACTIONS.ADMIN_OPS_DIAGNOSTICS,
metadataJson: {
windowDays,
staleSyncThresholdHours,
sampleLimit,
},
});
const [failedWebhooksCount, staleBillingSyncCount, failedWebhooks, staleBillingSync, timelineAnomalies] = await Promise.all([
countRecentFailedWebhookEvents(db, windowDays),
countStaleBillingSyncWorkspaces(db, staleSyncThresholdHours),
listRecentFailedWebhookEvents(db, { sinceDays: windowDays, limit: sampleLimit }),
listStaleBillingSyncWorkspaces(db, { staleThresholdHours: staleSyncThresholdHours, limit: sampleLimit }),
getBillingTimelineAnomalySummary(db, {
windowDays,
staleThresholdHours: staleSyncThresholdHours,
paymentFailedThreshold: 2,
}),
]);
const response: AdminSupportDiagnosticsResponse = {
windowDays,
staleSyncThresholdHours,
failedWebhooks: {
count: failedWebhooksCount,
items: failedWebhooks.map((issue) => ({
id: issue.id,
eventType: issue.eventType,
workspaceId: issue.workspaceId,
workspaceName: issue.workspaceName,
errorMessage: issue.errorMessage,
receivedAt: issue.receivedAt,
externalEventId: issue.externalEventId,
})),
},
staleBillingSync: {
count: staleBillingSyncCount,
items: staleBillingSync,
},
timelineAnomalies,
};
return response;
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid admin diagnostics query.' });
}
request.log.error(error);
return reply.code(500).send({ error: 'Failed to load admin diagnostics.' });
}
});
app.post('/admin/mutations/billing/resync', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => {
try {
const payload = billingResyncBodySchema.parse(request.body ?? {}) as AdminBillingResyncRequest;
const db = getDbPool();
const result = await requestWorkspaceBillingResyncByAdmin(db, {
workspaceId: payload.workspaceId,
});
await recordAdminAccessAudit(db, {
actorUserId: request.authUser?.id,
actorEmail: request.authUser?.email,
route: request.routeOptions.url ?? request.url,
action: ADMIN_AUDIT_ACTIONS.ADMIN_MUTATION_BILLING_RESYNC_REQUESTED,
targetWorkspaceId: payload.workspaceId,
metadataJson: {
reason: payload.reason,
ticketRef: payload.ticketRef?.trim() || null,
confirmationText: payload.confirmationText,
},
});
const response: AdminBillingResyncResponse = {
accepted: true,
message: 'Billing resync was requested successfully.',
workspaceId: result.workspaceId,
externalSubscriptionRef: result.externalSubscriptionRef,
};
return reply.code(202).send(response);
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid billing resync payload.' });
}
request.log.error(error);
return reply.code(500).send({ error: error instanceof Error ? error.message : 'Failed to request billing resync.' });
}
});
};
+2 -2
View File
@@ -2,7 +2,7 @@ import type { FastifyPluginAsync } from 'fastify';
import { z, ZodError } from 'zod';
import { ensureWorkspaceForUser } from '../account/repository.js';
import { hydrateAuthUser, requireAuth } from '../auth/middleware.js';
import { recordAdminAccessAudit, requireAdmin } from '../auth/admin.js';
import { ADMIN_AUDIT_ACTIONS, recordAdminAccessAudit, requireAdmin } from '../auth/admin.js';
import { getDbPool } from '../db/pool.js';
import { getAdminAnalyticsSummary, recordAnalyticsEvent } from '../analytics/service.js';
@@ -75,7 +75,7 @@ export const analyticsRoutes: FastifyPluginAsync = async (app) => {
actorUserId: request.authUser?.id,
actorEmail: request.authUser?.email,
route: request.routeOptions.url ?? request.url,
action: 'analytics_summary',
action: ADMIN_AUDIT_ACTIONS.ANALYTICS_SUMMARY,
metadataJson: {
days: query.days ?? 30,
},
+3 -3
View File
@@ -2,7 +2,7 @@ import type { FastifyPluginAsync } from 'fastify';
import { z, ZodError } from 'zod';
import { getDbPool } from '../db/pool.js';
import { requireAuth } from '../auth/middleware.js';
import { recordAdminAccessAudit, requireAdmin } from '../auth/admin.js';
import { ADMIN_AUDIT_ACTIONS, recordAdminAccessAudit, requireAdmin } from '../auth/admin.js';
import type { AddonCode, ActivePlanCode } from '../../../shared/billing/plans.js';
import { listRecentWebhookEventsForWorkspace } from '../payments/repository.js';
import { createAddonCheckoutSession, createBillingPortalSession, createSubscriptionCheckoutSession } from '../payments/service.js';
@@ -121,7 +121,7 @@ export const billingRoutes: FastifyPluginAsync = async (app) => {
actorUserId: request.authUser?.id,
actorEmail: request.authUser?.email,
route: request.routeOptions.url ?? request.url,
action: 'billing_workspaces_list',
action: ADMIN_AUDIT_ACTIONS.BILLING_WORKSPACES_LIST,
metadataJson: {
query: query.query ?? null,
limit: query.limit ?? 50,
@@ -151,7 +151,7 @@ export const billingRoutes: FastifyPluginAsync = async (app) => {
actorUserId: request.authUser?.id,
actorEmail: request.authUser?.email,
route: request.routeOptions.url ?? request.url,
action: 'billing_workspace_detail',
action: ADMIN_AUDIT_ACTIONS.BILLING_WORKSPACE_DETAIL,
targetWorkspaceId: workspaceId,
});
const summary = await getBillingAdminWorkspaceSummaryByWorkspaceId(db, workspaceId);