From b89aa28342191d2bf1268f58a79dda522363738a Mon Sep 17 00:00:00 2001 From: Nihey Takizawa Date: Wed, 14 Oct 2026 18:34:11 -0300 Subject: [PATCH 1/2] feat: add account personal access tokens --- AGENTS.md | 12 +- README.md | 22 + api/README.md | 24 + api/src/app.module.ts | 1 + .../billing/billing-actions.resolver.spec.ts | 44 ++ api/src/billing/billing-actions.resolver.ts | 39 ++ api/src/billing/billing.module.ts | 5 +- api/src/checks/checks.resolver.ts | 4 +- api/src/schema.gql | 7 + api/src/telegram/telegram.resolver.ts | 6 +- api/src/tokens/account-access.guard.spec.ts | 48 ++ api/src/tokens/account-access.guard.ts | 41 ++ api/src/tokens/api-principal.ts | 2 +- api/src/tokens/api-token.strategy.spec.ts | 42 ++ api/src/tokens/api-token.strategy.ts | 23 +- api/src/tokens/token-policy.spec.ts | 52 ++ api/src/tokens/token-policy.ts | 57 +- api/src/tokens/token.model.ts | 6 +- api/src/tokens/tokens-core.module.ts | 10 +- api/src/tokens/tokens.resolver.spec.ts | 13 + api/src/tokens/tokens.resolver.ts | 30 +- api/src/tokens/tokens.service.spec.ts | 63 +++ api/src/tokens/tokens.service.ts | 33 ++ api/test/personal-access-tokens.e2e-spec.ts | 361 +++++++++++++ api/test/token-schema-isolation.e2e-spec.ts | 21 + docs/ARCHITECTURE.md | 17 + docs/DEPLOYMENT.md | 11 + ...26-10-14-account-personal-access-tokens.md | 105 ++++ ...4-account-personal-access-tokens-design.md | 48 ++ frontend/README.md | 16 + .../account/agent-connections/page.test.tsx | 497 +----------------- .../(app)/account/agent-connections/page.tsx | 399 +------------- frontend/app/(app)/account/page.test.tsx | 10 +- frontend/app/(app)/account/page.tsx | 30 +- frontend/app/(app)/app-shell.test.tsx | 10 +- frontend/app/(app)/dashboard/page.test.tsx | 16 +- frontend/app/(app)/dashboard/page.tsx | 13 - frontend/app/(app)/layout.tsx | 2 +- .../app/connect-agent-dialog.test.tsx | 65 ++- .../components/app/connect-agent-dialog.tsx | 82 ++- .../app/personal-access-tokens.test.tsx | 487 +++++++++++++++++ .../components/app/personal-access-tokens.tsx | 403 ++++++++++++++ frontend/components/app/sidebar.tsx | 8 +- frontend/lib/agent-connection-config.test.ts | 10 +- frontend/lib/agent-connection-config.ts | 18 +- frontend/lib/graphql-documents.test.ts | 4 +- frontend/lib/queries.ts | 13 + integrations/mcp/README.md | 61 ++- integrations/mcp/package-lock.json | 11 + integrations/mcp/package.json | 1 + integrations/mcp/src/account-tools.ts | 158 ++++++ integrations/mcp/src/credential.ts | 11 +- integrations/mcp/test/account-tools.test.ts | 145 +++++ 53 files changed, 2591 insertions(+), 1026 deletions(-) create mode 100644 api/src/billing/billing-actions.resolver.spec.ts create mode 100644 api/src/billing/billing-actions.resolver.ts create mode 100644 api/src/tokens/account-access.guard.spec.ts create mode 100644 api/src/tokens/account-access.guard.ts create mode 100644 api/test/personal-access-tokens.e2e-spec.ts create mode 100644 docs/superpowers/plans/2026-10-14-account-personal-access-tokens.md create mode 100644 docs/superpowers/specs/2026-10-14-account-personal-access-tokens-design.md create mode 100644 frontend/components/app/personal-access-tokens.test.tsx create mode 100644 frontend/components/app/personal-access-tokens.tsx create mode 100644 integrations/mcp/src/account-tools.ts create mode 100644 integrations/mcp/test/account-tools.test.ts diff --git a/AGENTS.md b/AGENTS.md index f5d5d49..bd481f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,11 +21,21 @@ organization creation provisions the workspace. The next cleanup release removes the deprecated public project surface, but physical removal of the internal project table requires a separate approved design. -Authenticated account-session organization members can query +Authenticated account-session and account-PAT organization members can query `organizationCheckAllowance` for the creator account's aggregate check usage, limit, and remaining allowance; the response never exposes creator identity data. +Personal access tokens use the explicit `account:manage` scope, no workspace +binding, and `ACCOUNT` credential discovery mode. They delegate public product +operations across the issuer's current organization memberships plus GraphQL +billing checkout/portal links. Keep the public operation allowlist explicit; +password changes, token administration, and platform administration are excluded. +Only non-impersonated sessions may issue PATs. Reject workspace-bound or mixed-scope +account credentials. Scoped and legacy token access stays compatible. MCP account +tools are registered only for ACCOUNT credentials; hosted Stripe pages complete +billing changes. Token secrets bypass Apollo caching and are displayed only once. + Never commit credentials, production data, generated keys, or local `.env` files. Keep `.env.example` files limited to safe placeholders. The `docker-compose.infrastructure.yml` file is a generic stateful infrastructure template and must never contain production values, identifiers, hosts, or credentials. Keep pull requests focused, add tests for behavior changes, and update public documentation when a user-visible workflow, deployment contract, or API changes. diff --git a/README.md b/README.md index cda23fa..27a6edb 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,28 @@ check. The dashboard and check detail page let users turn individual channels on or off, including turning every channel off with a clear `Notifications off` warning. +## Personal access tokens for agents + +Open **Account > Personal access tokens > Create personal access token** to delegate +account management to an agent. Save the secret when it is displayed: it is shown +only once. The setup wizard provides Claude Code, Codex, Cursor, universal MCP, +and GraphQL/cURL configurations. Account tokens default to 30 days; choose another +expiration or no expiration as needed. Revoke them from the same page. + +A personal access token manages organizations, checks, notification channels, +members, invitations, status pages, and check moves across every organization +you can currently access. It follows your current organization roles, membership +and plan limits, including organizations you join later. It can read account +subscription/usage and obtain hosted billing checkout or portal links; billing +changes are completed on Stripe's hosted pages. Password changes, token +administration, and platform administration require your login session. + +Existing organization-scoped credentials stay valid and can be revoked from +Account. The former Agent connections URL redirects to Account's personal-token +section; token history includes credentials from all organizations. See the +[MCP guide](integrations/mcp/README.md) for tool coverage, source-checkout usage +and the MCP package publication requirement. + ## Organization workspace compatibility This compatibility release makes the organization the only public workspace. diff --git a/api/README.md b/api/README.md index 15fffc0..60823b3 100644 --- a/api/README.md +++ b/api/README.md @@ -16,6 +16,30 @@ npm run test:e2e Run `npm run start:dev` to start the API locally on port 8888. The API requires PostgreSQL and Redis; `../dev.sh` starts the local infrastructure. +## Account personal access tokens + +With a non-impersonated session JWT, call +`createPersonalAccessToken(name: String!, expirationDays: Int)` and save its +`plaintext` field from that one response. Omit expirationDays (or pass null) for +no expiration; supplied values must be integers from 1 to 3650. Only the hash and +identifying prefix are stored. List metadata with `apiTokens` and revoke with +`revokeApiToken`, both session-only. + +Use the returned secret as `Authorization: Bearer svt_...` on `/graphql`. +`apiCredential` reports mode `ACCOUNT`, `account:manage`, and effective check +capabilities with no organization binding. Current membership, roles, ownership, +plan limits and handshake requirements still apply. Account tokens can manage +public product resources, move checks and query allowance. Password changes, +token administration and `/admin/graphql` remain excluded, even for admin issuers. +Expired, revoked, suspended-account and malformed credentials are rejected. + +`createBillingCheckout(plan: String!, interval: String)` accepts SIGNAL/FLEET +and month/year (default month); `createBillingPortal` returns the account's +hosted billing portal. Both return `{ url }`, require a non-impersonated session +or account PAT, and reuse BillingService. The user completes changes on Stripe's +hosted pages. Existing session-only REST billing endpoints remain compatible. +Scoped and legacy credentials do not gain account permissions. + ## Per-check notification routing `CheckModel.notificationChannelIds` lists the enabled notification channels diff --git a/api/src/app.module.ts b/api/src/app.module.ts index a568e0e..e6071ee 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -32,6 +32,7 @@ const PUBLIC_ONLY_SCHEMA_TYPES = new Set([ 'ApiCredential', 'ManagedTelegramBotModel', 'TelegramConnectionPreviewModel', + 'BillingLink', ]); function isolateAdminSchema(schema: GraphQLSchema): GraphQLSchema { diff --git a/api/src/billing/billing-actions.resolver.spec.ts b/api/src/billing/billing-actions.resolver.spec.ts new file mode 100644 index 0000000..e39b183 --- /dev/null +++ b/api/src/billing/billing-actions.resolver.spec.ts @@ -0,0 +1,44 @@ +import { BadRequestException } from '@nestjs/common'; +import { BillingActionsResolver } from './billing-actions.resolver'; +import { BillingService } from './billing.service'; + +describe('BillingActionsResolver', () => { + const user = { userId: 'u1', email: 'agent@example.test' }; + function setup() { + const createCheckout = jest + .fn() + .mockResolvedValue({ url: 'https://checkout.example.test' }); + const createPortal = jest + .fn() + .mockResolvedValue({ url: 'https://portal.example.test' }); + const resolver = new BillingActionsResolver({ + createCheckout, + createPortal, + } as unknown as BillingService); + return { resolver, createCheckout, createPortal }; + } + it('uses the authenticated account and defaults checkout to monthly', async () => { + const { resolver, createCheckout } = setup(); + await expect( + resolver.createBillingCheckout(user, 'SIGNAL'), + ).resolves.toHaveProperty('url'); + expect(createCheckout).toHaveBeenCalledWith('u1', 'SIGNAL', 'month'); + }); + it.each([ + ['SOLO', 'month'], + ['FLEET', 'week'], + ])('rejects invalid checkout %s/%s', async (plan, interval) => { + const { resolver, createCheckout } = setup(); + await expect( + resolver.createBillingCheckout(user, plan, interval), + ).rejects.toThrow(BadRequestException); + expect(createCheckout).not.toHaveBeenCalled(); + }); + it('returns a portal link for the authenticated account', async () => { + const { resolver, createPortal } = setup(); + await expect(resolver.createBillingPortal(user)).resolves.toHaveProperty( + 'url', + ); + expect(createPortal).toHaveBeenCalledWith('u1'); + }); +}); diff --git a/api/src/billing/billing-actions.resolver.ts b/api/src/billing/billing-actions.resolver.ts new file mode 100644 index 0000000..d6fa845 --- /dev/null +++ b/api/src/billing/billing-actions.resolver.ts @@ -0,0 +1,39 @@ +import { Args, Field, Mutation, ObjectType, Resolver } from '@nestjs/graphql'; +import { BadRequestException } from '@nestjs/common'; +import { CurrentUser } from '../common/current-user.decorator'; +import type { JwtUser } from '../auth/jwt.strategy'; +import { AccountAccess } from '../tokens/account-access.guard'; +import { BillingService } from './billing.service'; + +@ObjectType() +export class BillingLink { + @Field() url!: string; +} + +@Resolver() +@AccountAccess() +export class BillingActionsResolver { + constructor(private readonly billing: BillingService) {} + + @Mutation(() => BillingLink) + async createBillingCheckout( + @CurrentUser() user: JwtUser, + @Args('plan') plan: string, + @Args('interval', { type: () => String, nullable: true }) + interval?: string | null, + ): Promise { + if (plan !== 'SIGNAL' && plan !== 'FLEET') { + throw new BadRequestException('Invalid billing plan'); + } + const billingInterval = interval ?? 'month'; + if (billingInterval !== 'month' && billingInterval !== 'year') { + throw new BadRequestException('Invalid billing interval'); + } + return this.billing.createCheckout(user.userId, plan, billingInterval); + } + + @Mutation(() => BillingLink) + createBillingPortal(@CurrentUser() user: JwtUser): Promise { + return this.billing.createPortal(user.userId); + } +} diff --git a/api/src/billing/billing.module.ts b/api/src/billing/billing.module.ts index d257292..5d15c24 100644 --- a/api/src/billing/billing.module.ts +++ b/api/src/billing/billing.module.ts @@ -9,9 +9,11 @@ import { AccountEntitlementsService } from './account-entitlements.service'; import { SubscriptionReconciliationService } from './subscription-reconciliation.service'; import { ReconciliationRunLock } from './subscription-reconciliation-lock'; import { AuthModule } from '../auth/auth.module'; +import { TokensCoreModule } from '../tokens/tokens-core.module'; +import { BillingActionsResolver } from './billing-actions.resolver'; @Module({ - imports: [AuthModule], + imports: [AuthModule, TokensCoreModule], controllers: [BillingController], providers: [ stripeClientFactory, @@ -19,6 +21,7 @@ import { AuthModule } from '../auth/auth.module'; StripePlanBootstrapService, BillingService, BillingResolver, + BillingActionsResolver, AccountEntitlementsService, SubscriptionReconciliationService, ReconciliationRunLock, diff --git a/api/src/checks/checks.resolver.ts b/api/src/checks/checks.resolver.ts index 1e4c190..9326f15 100644 --- a/api/src/checks/checks.resolver.ts +++ b/api/src/checks/checks.resolver.ts @@ -10,7 +10,7 @@ import { } from '@nestjs/graphql'; import { UseGuards } from '@nestjs/common'; import { ApiAuthGuard } from '../tokens/api-auth.guard'; -import { AccountSessionOnly } from '../auth/account-session.guard'; +import { AccountAccess } from '../tokens/account-access.guard'; import { CurrentUser } from '../common/current-user.decorator'; import type { ApiPrincipal } from '../tokens/api-principal'; import { requireCheckAccess } from '../tokens/token-policy'; @@ -234,7 +234,7 @@ export class ChecksResolver { } @Mutation(() => CheckModel) - @AccountSessionOnly() + @AccountAccess() async moveCheck( @CurrentUser() principal: ApiPrincipal, @Args('checkId', { type: () => ID }) checkId: string, diff --git a/api/src/schema.gql b/api/src/schema.gql index f859f8c..58b1937 100644 --- a/api/src/schema.gql +++ b/api/src/schema.gql @@ -121,6 +121,10 @@ type AuditLogModel { targetType: String } +type BillingLink { + url: String! +} + type ChannelModel { configJson: String! enabled: Boolean! @@ -234,9 +238,12 @@ type Mutation { connectTelegramChannel(organizationId: ID, projectId: ID @deprecated(reason: "Use organizationId."), token: String!): ChannelModel! createActiveCheck(expectedStatus: Int, intervalSeconds: Int!, method: String, name: String!, organizationId: ID, projectId: ID @deprecated(reason: "Use organizationId."), target: String!, timeoutMs: Int!, type: String!): CheckModel! createApiToken(name: String!, scopes: [String!]!): ApiTokenCreateResult! + createBillingCheckout(interval: String, plan: String!): BillingLink! + createBillingPortal: BillingLink! createChannel(configJson: String!, organizationId: ID, projectId: ID @deprecated(reason: "Use organizationId."), type: String!): ChannelModel! createCheck(graceSeconds: Int!, name: String!, organizationId: ID, periodSeconds: Int, projectId: ID @deprecated(reason: "Use organizationId."), schedule: String, tz: String): CheckModel! createOrganization(name: String!): OrganizationModel! + createPersonalAccessToken(expirationDays: Int, name: String!): ApiTokenCreateResult! createScopedApiToken(input: CreateApiTokenInput!): ApiTokenCreateResult! createStatusPage(brandingJson: String, checkIds: [ID!]!, organizationId: ID, projectId: ID @deprecated(reason: "Use organizationId."), slug: String!, title: String!): StatusPageModel! deleteChannel(id: ID!): Boolean! diff --git a/api/src/telegram/telegram.resolver.ts b/api/src/telegram/telegram.resolver.ts index 69d172d..f1e0111 100644 --- a/api/src/telegram/telegram.resolver.ts +++ b/api/src/telegram/telegram.resolver.ts @@ -1,6 +1,6 @@ import { UseGuards } from '@nestjs/common'; import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql'; -import { AccountSessionOnly } from '../auth/account-session.guard'; +import { AccountAccess } from '../tokens/account-access.guard'; import type { JwtUser } from '../auth/jwt.strategy'; import { CurrentUser } from '../common/current-user.decorator'; import { ChannelModel } from '../channels/channel.model'; @@ -29,13 +29,13 @@ export class TelegramResolver { } @Query(() => TelegramConnectionPreviewModel) - @AccountSessionOnly() + @AccountAccess() telegramConnectionPreview(@Args('token') token: string) { return this.telegramConnections.preview(token); } @Mutation(() => ChannelModel) - @AccountSessionOnly() + @AccountAccess() async connectTelegramChannel( @CurrentUser() user: JwtUser, @Args('token') token: string, diff --git a/api/src/tokens/account-access.guard.spec.ts b/api/src/tokens/account-access.guard.spec.ts new file mode 100644 index 0000000..3784357 --- /dev/null +++ b/api/src/tokens/account-access.guard.spec.ts @@ -0,0 +1,48 @@ +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { AccountAccessGuard } from './account-access.guard'; + +function context(user: unknown): ExecutionContext { + return { + getType: () => 'http', + switchToHttp: () => ({ getRequest: () => ({ user }) }), + } as unknown as ExecutionContext; +} + +describe('AccountAccessGuard', () => { + const guard = new AccountAccessGuard(); + const user = { userId: 'u1', email: 'agent@example.test' }; + const token = { + ...user, + authKind: 'api-token', + apiToken: { + id: 't1', + projectId: null, + capabilities: ['account:manage'], + legacyScopes: [], + }, + }; + it.each([{ ...user, authKind: 'session' }, token])( + 'allows account access %#', + (principal) => { + expect(guard.canActivate(context(principal))).toBe(true); + }, + ); + it.each([ + undefined, + { ...user, authKind: 'session', impersonatedBy: 'admin1' }, + { ...token, impersonatedBy: 'admin1' }, + { + ...token, + apiToken: { + ...token.apiToken, + capabilities: [], + legacyScopes: ['write'], + }, + }, + { ...token, apiToken: { ...token.apiToken, projectId: 'p1' } }, + ])('rejects credentials without account authority %#', (principal) => { + expect(() => guard.canActivate(context(principal))).toThrow( + ForbiddenException, + ); + }); +}); diff --git a/api/src/tokens/account-access.guard.ts b/api/src/tokens/account-access.guard.ts new file mode 100644 index 0000000..ba547c1 --- /dev/null +++ b/api/src/tokens/account-access.guard.ts @@ -0,0 +1,41 @@ +import { + applyDecorators, + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + UseGuards, +} from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import type { ApiPrincipal } from './api-principal'; +import { ApiAuthGuard } from './api-auth.guard'; +import { isAccountToken } from './token-policy'; + +@Injectable() +export class AccountAccessGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = + context.getType<'graphql'>() === 'graphql' + ? GqlExecutionContext.create(context).getContext<{ + req: { user?: ApiPrincipal }; + }>().req + : context.switchToHttp().getRequest<{ user?: ApiPrincipal }>(); + const principal = request.user; + if (principal?.impersonatedBy) { + throw new ForbiddenException('Account session required'); + } + if ( + !principal || + (principal.authKind !== 'session' && !isAccountToken(principal)) + ) { + throw new ForbiddenException( + 'Account session or personal access token required', + ); + } + return true; + } +} + +export function AccountAccess(): MethodDecorator & ClassDecorator { + return applyDecorators(UseGuards(ApiAuthGuard, AccountAccessGuard)); +} diff --git a/api/src/tokens/api-principal.ts b/api/src/tokens/api-principal.ts index 0842e95..51a7987 100644 --- a/api/src/tokens/api-principal.ts +++ b/api/src/tokens/api-principal.ts @@ -1,6 +1,6 @@ import type { JwtUser } from '../auth/jwt.strategy'; -export type TokenCapability = 'checks:read' | 'checks:write'; +export type TokenCapability = 'checks:read' | 'checks:write' | 'account:manage'; export interface ApiTokenMetadata { id: string; diff --git a/api/src/tokens/api-token.strategy.spec.ts b/api/src/tokens/api-token.strategy.spec.ts index c76745a..c7ccba7 100644 --- a/api/src/tokens/api-token.strategy.spec.ts +++ b/api/src/tokens/api-token.strategy.spec.ts @@ -97,6 +97,48 @@ function setup(token: TokenRecord | null = tokenRecord()) { }; } +describe('account personal access authentication', () => { + it('authenticates an unbound account token with explicit capabilities', async () => { + const { strategy } = setup( + tokenRecord({ + projectId: null, + projectNameSnapshot: null, + organizationNameSnapshot: null, + scopes: ['account:manage'], + }), + ); + await expect( + strategy.validate(bearer('svt_account')), + ).resolves.toMatchObject({ + authKind: 'api-token', + userId: 'user-1', + apiToken: { + projectId: null, + capabilities: ['account:manage'], + legacyScopes: [], + }, + }); + }); + it.each([ + { projectId: 'project-1', scopes: ['account:manage'] }, + { projectId: null, scopes: ['account:manage', 'write'] }, + { projectId: null, scopes: ['account:manage', 'checks:write'] }, + { projectId: null, scopes: ['account:manage', 'unknown'] }, + ])('rejects malformed account credentials %#', async (overrides) => { + const { strategy, updateMany } = setup( + tokenRecord({ + projectNameSnapshot: null, + organizationNameSnapshot: null, + ...overrides, + }), + ); + await expect(strategy.validate(bearer('svt_invalid'))).rejects.toThrow( + UnauthorizedException, + ); + expect(updateMany).not.toHaveBeenCalled(); + }); +}); + describe('ApiTokenStrategy', () => { beforeEach(() => { jest.spyOn(Date, 'now').mockReturnValue(NOW.getTime()); diff --git a/api/src/tokens/api-token.strategy.ts b/api/src/tokens/api-token.strategy.ts index e8aeec4..de2b730 100644 --- a/api/src/tokens/api-token.strategy.ts +++ b/api/src/tokens/api-token.strategy.ts @@ -10,6 +10,7 @@ import type { ApiPrincipal, TokenCapability } from './api-principal'; import type { Prisma } from '@systemvitals/database'; const TOKEN_CAPABILITIES: readonly TokenCapability[] = [ + 'account:manage', 'checks:read', 'checks:write', ]; @@ -98,17 +99,35 @@ export class ApiTokenStrategy extends PassportStrategy(Strategy, 'api') { if (token.user.suspendedAt) { throw new UnauthorizedException('Credential owner account suspended'); } + const isAccount = token.scopes.includes('account:manage'); + if ( + isAccount && + (token.scopes.length !== 1 || + token.projectId !== null || + token.projectNameSnapshot !== null || + token.organizationNameSnapshot !== null) + ) { + throw new UnauthorizedException('Invalid account credential'); + } if (token.projectId === null && token.projectNameSnapshot !== null) { throw new UnauthorizedException( 'Credential project no longer exists', ); } - if (token.projectId === null && token.scopes.some(isTokenCapability)) { + if ( + !isAccount && + token.projectId === null && + token.scopes.some(isTokenCapability) + ) { throw new UnauthorizedException( 'Scoped credential is missing a project binding', ); } - if (token.projectId === null && !token.scopes.some(isLegacyScope)) { + if ( + !isAccount && + token.projectId === null && + !token.scopes.some(isLegacyScope) + ) { throw new UnauthorizedException('Credential has no supported scope'); } if ( diff --git a/api/src/tokens/token-policy.spec.ts b/api/src/tokens/token-policy.spec.ts index 772b8fc..21b6da5 100644 --- a/api/src/tokens/token-policy.spec.ts +++ b/api/src/tokens/token-policy.spec.ts @@ -27,6 +27,58 @@ function apiTokenPrincipal( }; } +describe('account personal access policy', () => { + const account = () => + apiTokenPrincipal({ + projectId: null, + capabilities: ['account:manage'], + legacyScopes: [], + }); + it.each([ + 'me', + 'createOrganization', + 'deleteOrganization', + 'createChannel', + 'createStatusPage', + 'organizationMembers', + 'inviteMember', + 'acceptInvite', + 'moveCheck', + 'organizationCheckAllowance', + 'telegramConnectionPreview', + 'connectTelegramChannel', + 'createBillingCheckout', + 'createBillingPortal', + ])('allows product account operation %s', (operation) => { + expect(() => requireOperationAccess(account(), operation)).not.toThrow(); + }); + it.each([ + 'setPassword', + 'apiTokens', + 'createApiToken', + 'createScopedApiToken', + 'createPersonalAccessToken', + 'revokeApiToken', + 'adminPing', + 'adminImpersonate', + 'unknownFutureOperation', + ])('denies privileged operation %s', (operation) => { + expect(() => requireOperationAccess(account(), operation)).toThrow( + ForbiddenException, + ); + }); + it('allows check reads and writes in different workspaces', () => { + for (const project of ['project-1', 'project-2']) { + expect(() => + requireCheckAccess(account(), 'checks:read', project), + ).not.toThrow(); + expect(() => + requireCheckAccess(account(), 'checks:write', project), + ).not.toThrow(); + } + }); +}); + describe('requireCheckAccess', () => { it('allows session principals without scope or project checks', () => { expect(() => diff --git a/api/src/tokens/token-policy.ts b/api/src/tokens/token-policy.ts index bfa21b2..95eed69 100644 --- a/api/src/tokens/token-policy.ts +++ b/api/src/tokens/token-policy.ts @@ -27,12 +27,67 @@ const SESSION_ONLY_OPERATIONS = new Set([ 'organizationCheckAllowance', ]); +const ACCOUNT_TOKEN_OPERATIONS = new Set([ + ...SCOPED_TOKEN_OPERATIONS, + 'telegramConnectionPreview', + 'connectTelegramChannel', + 'moveCheck', + 'organizationCheckAllowance', + 'me', + 'projects', + 'mySubscription', + 'createOrganization', + 'updateOrganization', + 'updateOrganizationSlug', + 'transferOrganizationCreatorship', + 'deleteOrganization', + 'regenerateOrganizationPingKey', + 'regeneratePingKey', + 'channels', + 'createChannel', + 'resendEmailChannelVerification', + 'deleteChannel', + 'organizationMembers', + 'organizationInvites', + 'inviteMember', + 'revokeInvite', + 'acceptInvite', + 'updateMemberRole', + 'removeMember', + 'leaveOrganization', + 'statusPages', + 'createStatusPage', + 'updateStatusPage', + 'deleteStatusPage', + 'createBillingCheckout', + 'createBillingPortal', +]); + +export function isAccountToken(principal: ApiPrincipal): boolean { + return ( + principal.authKind === 'api-token' && + principal.apiToken.projectId === null && + principal.apiToken.capabilities.length === 1 && + principal.apiToken.capabilities[0] === 'account:manage' && + principal.apiToken.legacyScopes.length === 0 + ); +} + export function requireOperationAccess( principal: ApiPrincipal, operationName: string, ): void { if (principal.authKind === 'session') return; + if (isAccountToken(principal)) { + if (!ACCOUNT_TOKEN_OPERATIONS.has(operationName)) { + throw new ForbiddenException( + 'Account credential cannot access this operation', + ); + } + return; + } + if (SESSION_ONLY_OPERATIONS.has(operationName)) { throw new ForbiddenException('Account session required'); } @@ -54,7 +109,7 @@ export function requireCheckAccess( capability: TokenCapability, projectId: string, ): void { - if (principal.authKind === 'session') return; + if (principal.authKind === 'session' || isAccountToken(principal)) return; const token = principal.apiToken; if (token.projectId !== null && token.projectId !== projectId) { diff --git a/api/src/tokens/token.model.ts b/api/src/tokens/token.model.ts index 4592fa9..f219daa 100644 --- a/api/src/tokens/token.model.ts +++ b/api/src/tokens/token.model.ts @@ -1,6 +1,10 @@ import { Field, ID, ObjectType } from '@nestjs/graphql'; -export type CredentialMode = 'SESSION' | 'LEGACY_BROAD' | 'PROJECT_SCOPED'; +export type CredentialMode = + | 'SESSION' + | 'LEGACY_BROAD' + | 'PROJECT_SCOPED' + | 'ACCOUNT'; @ObjectType() export class ApiTokenModel { diff --git a/api/src/tokens/tokens-core.module.ts b/api/src/tokens/tokens-core.module.ts index 4090531..72c91e4 100644 --- a/api/src/tokens/tokens-core.module.ts +++ b/api/src/tokens/tokens-core.module.ts @@ -5,6 +5,7 @@ import { ApiAuthGuard } from './api-auth.guard'; import { ApiTokenStrategy } from './api-token.strategy'; import { TokensService } from './tokens.service'; import { WorkspacesModule } from '../workspaces/workspaces.module'; +import { AccountAccessGuard } from './account-access.guard'; @Module({ imports: [ @@ -16,7 +17,12 @@ import { WorkspacesModule } from '../workspaces/workspaces.module'; }), }), ], - providers: [TokensService, ApiTokenStrategy, ApiAuthGuard], - exports: [TokensService, ApiAuthGuard], + providers: [ + TokensService, + ApiTokenStrategy, + ApiAuthGuard, + AccountAccessGuard, + ], + exports: [TokensService, ApiAuthGuard, AccountAccessGuard], }) export class TokensCoreModule {} diff --git a/api/src/tokens/tokens.resolver.spec.ts b/api/src/tokens/tokens.resolver.spec.ts index 726039e..5cea398 100644 --- a/api/src/tokens/tokens.resolver.spec.ts +++ b/api/src/tokens/tokens.resolver.spec.ts @@ -24,6 +24,19 @@ function apiTokenPrincipal(projectId: string | null): ApiTokenPrincipal { }; } +it('describes an account token without workspace identity', async () => { + const { resolver, findUnique } = setup(null); + const principal = apiTokenPrincipal(null); + principal.apiToken.capabilities = ['account:manage']; + await expect(resolver.apiCredential(principal)).resolves.toMatchObject({ + credentialMode: 'ACCOUNT', + capabilities: ['account:manage', 'checks:read', 'checks:write'], + organizationId: null, + projectId: null, + }); + expect(findUnique).not.toHaveBeenCalled(); +}); + describe('ApiCredentialResolver', () => { it('returns canonical organization identity for a live scoped credential', async () => { const { resolver, findUnique } = setup({ diff --git a/api/src/tokens/tokens.resolver.ts b/api/src/tokens/tokens.resolver.ts index 615411a..d618846 100644 --- a/api/src/tokens/tokens.resolver.ts +++ b/api/src/tokens/tokens.resolver.ts @@ -1,4 +1,4 @@ -import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql'; +import { Args, ID, Int, Mutation, Query, Resolver } from '@nestjs/graphql'; import { UseGuards } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../common/current-user.decorator'; @@ -13,6 +13,8 @@ import { ApiTokenModel, } from './token.model'; import { CreateApiTokenInput } from './create-api-token.input'; +import { isAccountToken } from './token-policy'; +import { AccountSessionOnly } from '../auth/account-session.guard'; @Resolver() export class ApiCredentialResolver { @@ -36,6 +38,17 @@ export class ApiCredentialResolver { } const capabilities = new Set(principal.apiToken.capabilities); + if (isAccountToken(principal)) { + return { + authKind: 'api-token', + credentialMode: 'ACCOUNT', + capabilities: ['account:manage', 'checks:read', 'checks:write'], + organizationId: null, + organizationName: null, + projectId: null, + projectName: null, + }; + } if (principal.apiToken.legacyScopes.includes('read')) { capabilities.add('checks:read'); } @@ -82,6 +95,21 @@ export class ApiCredentialResolver { export class TokensResolver { constructor(private readonly tokens: TokensService) {} + @Mutation(() => ApiTokenCreateResult) + @AccountSessionOnly() + createPersonalAccessToken( + @CurrentUser() user: JwtUser, + @Args('name') name: string, + @Args('expirationDays', { type: () => Int, nullable: true }) + expirationDays?: number | null, + ) { + return this.tokens.createPersonal( + user.userId, + name, + expirationDays ?? undefined, + ); + } + @Query(() => [ApiTokenModel]) apiTokens(@CurrentUser() user: JwtUser) { return this.tokens.list(user.userId); diff --git a/api/src/tokens/tokens.service.spec.ts b/api/src/tokens/tokens.service.spec.ts index ea8c40a..4f462e6 100644 --- a/api/src/tokens/tokens.service.spec.ts +++ b/api/src/tokens/tokens.service.spec.ts @@ -78,6 +78,69 @@ function scopedInput(selector: { }; } +describe('personal token creation', () => { + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(NOW.getTime()); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it('creates a hash-only account token without a workspace binding', async () => { + const { service, tokenCreate, resolveForUser } = setup(); + const create = service as TokensService & { + createPersonal( + userId: string, + name: string, + expirationDays?: number, + ): Promise<{ plaintext: string }>; + }; + const before = Date.now(); + const result = await create.createPersonal( + 'user-1', + ' Account agent ', + 30, + ); + expect(result.plaintext).toMatch(/^svt_[0-9a-f]{40}$/); + expect(tokenCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: 'user-1', + name: 'Account agent', + scopes: ['account:manage'], + projectId: null, + expiresAt: new Date(before + 30 * 86400000), + tokenHash: expect.any(String) as unknown, + }) as object, + }), + ); + expect(tokenCreate.mock.calls[0][0].data).not.toHaveProperty('plaintext'); + expect(resolveForUser).not.toHaveBeenCalled(); + }); + it.each([0, -1, 1.5, 3651, NaN])( + 'rejects expiration %s before writing', + async (days) => { + const { service, tokenCreate } = setup(); + const create = service as TokensService & { + createPersonal(u: string, n: string, d?: number): Promise; + }; + await expect( + create.createPersonal('user-1', 'Agent', days), + ).rejects.toThrow(BadRequestException); + expect(tokenCreate).not.toHaveBeenCalled(); + }, + ); + it('rejects an empty name before writing', async () => { + const { service, tokenCreate } = setup(); + const create = service as TokensService & { + createPersonal(u: string, n: string): Promise; + }; + await expect(create.createPersonal('user-1', ' ')).rejects.toThrow( + BadRequestException, + ); + expect(tokenCreate).not.toHaveBeenCalled(); + }); +}); + describe('TokensService', () => { beforeEach(() => { jest.spyOn(Date, 'now').mockReturnValue(NOW.getTime()); diff --git a/api/src/tokens/tokens.service.ts b/api/src/tokens/tokens.service.ts index 30bc560..12f836c 100644 --- a/api/src/tokens/tokens.service.ts +++ b/api/src/tokens/tokens.service.ts @@ -58,6 +58,39 @@ export class TokensService { private readonly workspaces: WorkspacesService, ) {} + async createPersonal(userId: string, name: string, expirationDays?: number) { + const trimmedName = name.trim(); + if (!trimmedName) throw new BadRequestException('Token name is required'); + if ( + expirationDays !== undefined && + (!Number.isInteger(expirationDays) || + expirationDays < 1 || + expirationDays > 3650) + ) { + throw new BadRequestException( + 'expirationDays must be an integer from 1 to 3650', + ); + } + const expiresAt = + expirationDays === undefined + ? null + : new Date(Date.now() + expirationDays * 86400000); + const { plaintext, prefix, hash } = generateToken(); + const token = await this.prisma.apiToken.create({ + data: { + userId, + name: trimmedName, + prefix, + tokenHash: hash, + scopes: ['account:manage'], + projectId: null, + expiresAt, + }, + select: TOKEN_SELECT, + }); + return { ...this.toModel(token), plaintext }; + } + async create(userId: string, name: string, scopes: string[]) { if ( scopes.some((scope) => diff --git a/api/test/personal-access-tokens.e2e-spec.ts b/api/test/personal-access-tokens.e2e-spec.ts new file mode 100644 index 0000000..519fc02 --- /dev/null +++ b/api/test/personal-access-tokens.e2e-spec.ts @@ -0,0 +1,361 @@ +import type { NestFastifyApplication } from '@nestjs/platform-fastify'; +import { JwtService } from '@nestjs/jwt'; +import { buildApp } from '../src/main'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { BillingService } from '../src/billing/billing.service'; +import { cleanupTestUsers } from './cleanup-test-users'; +import { hashToken } from '../src/tokens/token.util'; + +type Response = { data?: T; errors?: { message: string }[] }; +type Created = { + id: string; + plaintext: string; + scopes: string[]; + expiresAt: string | null; +}; +const emails = ['pat-owner@systemvitals.test', 'pat-other@systemvitals.test']; + +describe('account personal access tokens (e2e)', () => { + let app: NestFastifyApplication; + let prisma: PrismaService; + let session: string; + let otherSession: string; + let userId: string; + let otherUserId: string; + let organizationId: string; + let otherOrganizationId: string; + let credential: Created; + let checkout: jest.SpyInstance; + let portal: jest.SpyInstance; + + async function gql( + token: string, + query: string, + variables?: Record, + url = '/graphql', + ): Promise> { + const response = await app.inject({ + method: 'POST', + url, + headers: { authorization: `Bearer ${token}` }, + payload: { query, variables }, + }); + return JSON.parse(response.body) as Response; + } + async function create(name = 'Account agent') { + const result = await gql<{ createPersonalAccessToken: Created }>( + session, + `mutation($name:String!){ createPersonalAccessToken(name:$name,expirationDays:30){id plaintext scopes expiresAt} }`, + { name }, + ); + expect(result.errors).toBeUndefined(); + return result.data!.createPersonalAccessToken; + } + + beforeAll(async () => { + app = await buildApp(); + // Only test service delegation here; never contact a billing provider. + checkout = jest + .spyOn(app.get(BillingService), 'createCheckout') + .mockResolvedValue({ url: 'https://checkout.example.test' }); + portal = jest + .spyOn(app.get(BillingService), 'createPortal') + .mockResolvedValue({ url: 'https://portal.example.test' }); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + prisma = app.get(PrismaService); + await cleanupTestUsers(prisma, emails); + const sessions: string[] = []; + for (const email of emails) { + const response = await app.inject({ + method: 'POST', + url: '/auth/signup', + payload: { email, password: 'test-password-123' }, + }); + expect(response.statusCode).toBe(201); + sessions.push((JSON.parse(response.body) as { token: string }).token); + } + [session, otherSession] = sessions; + const users = await Promise.all( + emails.map((email) => + prisma.user.findUniqueOrThrow({ + where: { email }, + include: { memberships: true }, + }), + ), + ); + [userId, otherUserId] = users.map((user) => user.id); + [organizationId, otherOrganizationId] = users.map( + (user) => user.memberships[0].organizationId, + ); + }); + beforeEach(async () => { + credential = await create(); + checkout.mockClear(); + portal.mockClear(); + }); + afterAll(async () => { + jest.restoreAllMocks(); + try { + await cleanupTestUsers(prisma, emails); + } finally { + await app.close(); + } + }); + + it('stores only the hash, reports ACCOUNT, and never lists the secret', async () => { + const record = await prisma.apiToken.findUniqueOrThrow({ + where: { id: credential.id }, + }); + expect(record.tokenHash).toBe(hashToken(credential.plaintext)); + expect(record.projectId).toBeNull(); + expect(record.scopes).toEqual(['account:manage']); + expect(record.expiresAt!.getTime()).toBeGreaterThan( + Date.now() + 29 * 86400000, + ); + const discovery = await gql( + credential.plaintext, + `{apiCredential{authKind credentialMode capabilities organizationId projectId} me{id} mySubscription{plan} organizationCheckAllowance(organizationId:"${organizationId}"){used limit remaining}}`, + ); + expect(discovery.errors).toBeUndefined(); + expect(discovery.data).toMatchObject({ + apiCredential: { + authKind: 'api-token', + credentialMode: 'ACCOUNT', + organizationId: null, + projectId: null, + }, + me: { id: userId }, + }); + const history = await gql( + session, + `{apiTokens{id name prefix scopes lastUsedAt}}`, + ); + expect(history.errors).toBeUndefined(); + expect(JSON.stringify(history)).not.toContain(credential.plaintext); + expect( + ( + await prisma.apiToken.findUniqueOrThrow({ + where: { id: credential.id }, + }) + ).lastUsedAt, + ).not.toBeNull(); + }); + + it('creates a new organization, moves checks, and manages channels and status pages', async () => { + const created = await gql<{ createOrganization: { id: string } }>( + credential.plaintext, + `mutation{createOrganization(name:"Agent workspace"){id}}`, + ); + expect(created.errors).toBeUndefined(); + const destination = created.data!.createOrganization.id; + const check = await gql<{ createCheck: { id: string } }>( + credential.plaintext, + `mutation{createCheck(organizationId:"${organizationId}",name:"PAT heartbeat",periodSeconds:300,graceSeconds:60){id}}`, + ); + expect(check.errors).toBeUndefined(); + const checkId = check.data!.createCheck.id; + const moved = await gql( + credential.plaintext, + `mutation{moveCheck(checkId:"${checkId}",destinationOrganizationId:"${destination}"){id organizationId}}`, + ); + expect(moved.errors).toBeUndefined(); + expect(moved.data).toMatchObject({ + moveCheck: { id: checkId, organizationId: destination }, + }); + const channel = await gql<{ createChannel: { id: string } }>( + credential.plaintext, + `mutation($config:String!){createChannel(organizationId:"${destination}",type:"WEBHOOK",configJson:$config){id}}`, + { config: JSON.stringify({ url: 'https://example.test/notify' }) }, + ); + expect(channel.errors).toBeUndefined(); + const page = await gql<{ createStatusPage: { id: string } }>( + credential.plaintext, + `mutation{createStatusPage(organizationId:"${destination}",slug:"pat-status",title:"Agent status",checkIds:["${checkId}"]){id}}`, + ); + expect(page.errors).toBeUndefined(); + const pageId = page.data!.createStatusPage.id; + expect( + ( + await gql( + credential.plaintext, + `mutation{updateStatusPage(id:"${pageId}",title:"Updated status"){title}}`, + ) + ).data, + ).toMatchObject({ updateStatusPage: { title: 'Updated status' } }); + expect( + ( + await gql( + credential.plaintext, + `query{statusPages(organizationId:"${destination}"){id} channels(organizationId:"${destination}"){id}}`, + ) + ).errors, + ).toBeUndefined(); + expect( + ( + await gql( + credential.plaintext, + `mutation{deleteStatusPage(id:"${pageId}") deleteChannel(id:"${channel.data!.createChannel.id}") deleteCheck(id:"${checkId}") deleteOrganization(organizationId:"${destination}")}`, + ) + ).errors, + ).toBeUndefined(); + }); + + it('does not access another account until membership is granted, and loses access on removal', async () => { + const query = `query{checks(organizationId:"${otherOrganizationId}"){id}}`; + expect((await gql(credential.plaintext, query)).errors).toBeDefined(); + const membership = await prisma.membership.create({ + data: { userId, organizationId: otherOrganizationId, role: 'MEMBER' }, + }); + try { + expect((await gql(credential.plaintext, query)).errors).toBeUndefined(); + const rename = `mutation{updateOrganization(organizationId:"${otherOrganizationId}",name:"Renamed by agent"){id}}`; + expect((await gql(credential.plaintext, rename)).errors).toBeDefined(); + await prisma.membership.update({ + where: { id: membership.id }, + data: { role: 'ADMIN' }, + }); + expect((await gql(credential.plaintext, rename)).errors).toBeUndefined(); + await prisma.membership.update({ + where: { id: membership.id }, + data: { role: 'MEMBER' }, + }); + expect((await gql(credential.plaintext, rename)).errors).toBeDefined(); + } finally { + await prisma.membership.delete({ where: { id: membership.id } }); + } + expect((await gql(credential.plaintext, query)).errors).toBeDefined(); + expect((await gql(otherSession, `{me{id}}`)).data).toMatchObject({ + me: { id: otherUserId }, + }); + }); + + it.each([ + `{apiTokens{id}}`, + `mutation{createApiToken(name:"Nested",scopes:["write"]){id}}`, + `mutation{createPersonalAccessToken(name:"Nested"){id}}`, + `mutation{createScopedApiToken(input:{name:"Nested",organizationId:"unknown",capabilities:["checks:read","checks:write"]}){id}}`, + `mutation{revokeApiToken(id:"unknown")}`, + `mutation{setPassword(newPassword:"replacement-password")}`, + ])('denies credential and password administration %#', async (query) => { + expect((await gql(credential.plaintext, query)).errors).toBeDefined(); + }); + + it('cannot inherit platform administration from an admin issuer', async () => { + await prisma.user.update({ + where: { id: userId }, + data: { isAdmin: true }, + }); + try { + expect( + ( + await gql( + credential.plaintext, + `{adminPing}`, + undefined, + '/admin/graphql', + ) + ).errors, + ).toBeDefined(); + expect( + (await gql(session, `{adminPing}`, undefined, '/admin/graphql')).errors, + ).toBeUndefined(); + } finally { + await prisma.user.update({ + where: { id: userId }, + data: { isAdmin: false }, + }); + } + }); + + it.each(['revoked', 'expired', 'suspended'])( + 'rejects a %s credential', + async (reason) => { + if (reason === 'revoked') { + expect( + ( + await gql( + session, + `mutation{revokeApiToken(id:"${credential.id}")}`, + ) + ).errors, + ).toBeUndefined(); + } else if (reason === 'expired') { + await prisma.apiToken.update({ + where: { id: credential.id }, + data: { expiresAt: new Date(0) }, + }); + } else { + await prisma.user.update({ + where: { id: userId }, + data: { suspendedAt: new Date() }, + }); + } + try { + expect( + (await gql(credential.plaintext, `{me{id}}`)).errors, + ).toBeDefined(); + } finally { + if (reason === 'suspended') + await prisma.user.update({ + where: { id: userId }, + data: { suspendedAt: null }, + }); + } + }, + ); + + it('issues hosted billing links for its own account', async () => { + expect( + ( + await gql( + credential.plaintext, + `mutation{createBillingCheckout(plan:"FLEET",interval:"year"){url} createBillingPortal{url}}`, + ) + ).errors, + ).toBeUndefined(); + expect(checkout).toHaveBeenCalledWith(userId, 'FLEET', 'year'); + expect(portal).toHaveBeenCalledWith(userId); + }); + + it('keeps old broad and scoped tokens out of new billing operations', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { organizationId }, + }); + const old = await gql<{ createApiToken: Created }>( + session, + `mutation{createApiToken(name:"Legacy",scopes:["write"]){plaintext}}`, + ); + const scoped = await gql<{ createScopedApiToken: Created }>( + session, + `mutation{createScopedApiToken(input:{name:"Scoped",projectId:"${project.id}",capabilities:["checks:read","checks:write"]}){plaintext}}`, + ); + for (const token of [ + old.data!.createApiToken.plaintext, + scoped.data!.createScopedApiToken.plaintext, + ]) { + expect( + (await gql(token, `mutation{createBillingPortal{url}}`)).errors, + ).toBeDefined(); + } + expect(portal).not.toHaveBeenCalled(); + }); + + it('rejects impersonated sessions for issuance and billing', async () => { + const impersonated = app + .get(JwtService) + .sign({ sub: userId, email: emails[0], act: otherUserId }); + expect( + ( + await gql( + impersonated, + `mutation{createPersonalAccessToken(name:"Impersonated"){id}}`, + ) + ).errors, + ).toBeDefined(); + expect( + (await gql(impersonated, `mutation{createBillingPortal{url}}`)).errors, + ).toBeDefined(); + expect(portal).not.toHaveBeenCalled(); + }); +}); diff --git a/api/test/token-schema-isolation.e2e-spec.ts b/api/test/token-schema-isolation.e2e-spec.ts index 3fc1648..8d2653b 100644 --- a/api/test/token-schema-isolation.e2e-spec.ts +++ b/api/test/token-schema-isolation.e2e-spec.ts @@ -45,6 +45,7 @@ const TOKEN_QUERIES = ['apiCredential', 'apiTokens']; const TOKEN_MUTATIONS = [ 'createApiToken', 'createScopedApiToken', + 'createPersonalAccessToken', 'revokeApiToken', ]; const TELEGRAM_QUERIES = ['managedTelegramBot', 'telegramConnectionPreview']; @@ -102,6 +103,26 @@ describe('token GraphQL schema isolation (e2e)', () => { return JSON.parse(response.body) as SchemaResponse; } + it('keeps billing actions and link metadata out of the admin schema', async () => { + const [publicSchema, adminSchema] = await Promise.all([ + schemaAt('/graphql'), + schemaAt('/admin/graphql'), + ]); + const publicMutations = publicSchema.data!.__schema.mutationType.fields.map( + ({ name }) => name, + ); + const adminMutations = adminSchema.data!.__schema.mutationType.fields.map( + ({ name }) => name, + ); + for (const name of ['createBillingCheckout', 'createBillingPortal']) { + expect(publicMutations).toContain(name); + expect(adminMutations).not.toContain(name); + } + expect( + adminSchema.data!.__schema.types.map(({ name }) => name), + ).not.toContain('BillingLink'); + }); + it('registers token management only on the public GraphQL schema', async () => { const [publicSchema, adminSchema] = await Promise.all([ schemaAt('/graphql'), diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 152e856..88603eb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -114,6 +114,23 @@ depend on those retired database tables. Recovering that data requires restoring a pre-Release 3 database backup together with a matching application release. +## Account personal access tokens + +Account PATs reuse the ApiToken hash, expiration, revocation and last-used +lifecycle with the explicit `account:manage` scope and no workspace binding. +Credential discovery reports `ACCOUNT`. Authentication rejects mixed scopes, +workspace bindings and deleted-workspace snapshots for this credential mode. +A public GraphQL operation allowlist delegates product operations to the existing +services, which enforce current membership, roles, ownership and quotas. +Password/token administration and platform administration remain excluded. + +Non-impersonated sessions issue PATs. A shared account-access guard admits a +non-impersonated session or a valid PAT to check moves, Telegram handshake +operations and GraphQL billing links. Billing mutations call the existing +BillingService and return hosted Stripe URLs; they do not directly complete +payment or subscription changes. Legacy and scoped credential permissions stay +compatible. MCP registers its additional account catalog only for ACCOUNT mode. + ## Deployment boundaries The API and worker share PostgreSQL and Redis but are deployed separately from the frontend. The API and worker Dockerfiles use the repository root as their build context because both consume the database package. The frontend Dockerfile uses `frontend/` as its build context. See [deployment](DEPLOYMENT.md) for the supported self-hosting and zero-downtime topologies. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 263a97f..934da7d 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -89,6 +89,17 @@ never copy it into this checkout, print its values, add its values to tests or logs, or commit it. Other installations should use their own equivalently protected external environment source. +## Account PAT rollout + +Deploy the API's `createPersonalAccessToken`, `ACCOUNT` credential discovery and +GraphQL billing-link operations before deploying the account-token frontend. +Publish the updated `@systemvitals/mcp` package before exposing its `npx` setup; +the package is currently prepared for publication but unpublished. Source-checkout +MCP and direct GraphQL access do not depend on publication. This feature reuses +existing token storage and requires no database migration or worker change. +Older API versions reject account PATs instead of treating them as legacy broad +tokens; existing scoped and legacy tokens remain compatible with the new API. + ## Operational checks Before and after every deployment: diff --git a/docs/superpowers/plans/2026-10-14-account-personal-access-tokens.md b/docs/superpowers/plans/2026-10-14-account-personal-access-tokens.md new file mode 100644 index 0000000..ae51136 --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-account-personal-access-tokens.md @@ -0,0 +1,105 @@ +# Account Personal Access Tokens Implementation Plan + +> Execute inline using the executing-plans and test-driven-development skills. + +**Goal:** Let an agent manage the user's product account and obtain hosted billing links. + +**Architecture:** Reuse ApiToken hashing, ownership and lifecycle with an explicit +`account:manage` scope and `ACCOUNT` credential mode. Route account tokens through +existing services and a public operation allowlist; leave credential management +and password guards session-only. + +**Tech Stack:** NestJS, Prisma/PostgreSQL, Next.js/React, Apollo, TypeScript, MCP. + +**Spec:** `docs/superpowers/specs/2026-10-14-account-personal-access-tokens-design.md` + +### Task 1: Account credential policy and lifecycle + +- [x] Add failing tests to `api/src/tokens/token-policy.spec.ts`, + `api-token.strategy.spec.ts`, `tokens.service.spec.ts`, and `tokens.resolver.spec.ts`. + Cover cross-workspace checks, operational access, denied password/token/admin + operations, invalid workspace-bound/mixed credentials, and credential discovery. +- [x] Run `cd api && npm test -- --runInBand tokens` and observe missing support. +- [x] Add `account:manage` to TokenCapability. A valid account token has exactly + that capability, no legacy scopes and no project binding. Explicitly allow public + product operations; reject all others. RequireCheckAccess still checks resource + ownership in the calling service. Add createPersonalAccessToken(name, expirationDays) + under the existing JWT-only resolver. Require a trimmed nonempty name and integer + expiration of 1–3650 days when supplied. Store only hash/prefix and scope. +- [x] Run focused token tests and TypeScript; expect all passing. + +### Task 2: Account operations and billing GraphQL + +- [x] Write `api/test/personal-access-tokens.e2e-spec.ts` to exercise creation, + cross-organization product operations, status pages, role/membership changes, + revocation/expiry/suspension, credential/password/admin denials, and GraphQL + billing service delegation without external Stripe calls. +- [x] Add guard tests for sessions/account tokens, legacy/scoped rejection and + impersonation rejection. Run tests and observe the absent account guard/mutations. +- [x] Create `api/src/tokens/account-access.guard.ts`, export its guard via + TokensCoreModule, and use its decorator for moveCheck and Telegram handshake. + Add `api/src/billing/billing-actions.resolver.ts` with validated paid plan and + interval arguments and createBillingCheckout/createBillingPortal returning URL. + Delegate to BillingService for the principal's userId. Preserve REST endpoints. +- [x] Run API lint, typecheck, unit tests and full e2e suite; inspect schema snapshots. + +### Task 3: MCP account tools + +- [x] Add failing `integrations/mcp/test/account-tools.test.ts` tests for ACCOUNT + tool discovery, exclusion of credential/password operations, correct GraphQL + fields/variables, and error propagation. +- [x] Create `integrations/mcp/src/account-tools.ts` for the supplemental tools. + Add ACCOUNT to credential.ts and return the existing catalog plus account tools + only for that mode. Keep scoped/session/legacy catalogs compatible. +- [x] Run `cd integrations/mcp && npx tsc --noEmit && npm test`; expect unit and + package smoke tests passing. + +### Task 4: Account UI and documentation + +- [x] Add failing account wizard tests alongside connect-agent-dialog tests. + Check account mutation variables, copy/one-time display, expiration, and scope copy. +- [x] Parameterize ConnectAgentDialog with account/organization props, reuse the + same result and navigation protection, and select CREATE_PERSONAL_ACCESS_TOKEN + for account creation. Show its button in account connection history and label + account:manage as Manage account. Add the GraphQL document in queries.ts. +- [x] Update root AGENTS.md, README.md and API/MCP documentation with boundaries, + creation/revocation instructions and billing's hosted completion requirement. +- [x] Run frontend lint/typecheck/tests and re-run checks affected by final fixes. + +### Review focus + +Check that deleted scoped bindings never widen into account access; account tokens +cannot mint tokens, change passwords or inherit an issuer's platform-admin powers. +Confirm every GraphQL data operation remains organization-first, current roles are +checked by services, secret creation bypasses Apollo cache, and legacy tool catalogs +retain their authorization boundary. + +## Execution record + +Implemented on `feat/account-personal-access-tokens` in the shared checkout. +Validation used Node.js 22.23.3, an isolated PostgreSQL/Redis test environment, +and fake billing providers; no production infrastructure or credentials were used. + +- API lint and TypeScript passed; 923 unit and 397 e2e tests passed. +- Frontend lint and TypeScript passed; 542 tests passed. Regenerated stale local + Next.js route types before typechecking. +- MCP TypeScript, 154 tests, build and packed-package smoke passed. +- `git diff --check` passed. +- Independent review found one important recovery issue: navigating to connection + history on the already-mounted account page did not refresh a token whose + creation response was lost. Added explicit recovery refresh and a regression + test; observed failure before the fix and success in the full frontend suite. + No critical or minor findings remain. +- Preserved the existing impersonation rejection message for compatibility. +- Added public-only billing type isolation and its e2e regression test. +- MCP publication and deployment are release steps, outside this local change. + +## PR follow-up + +The user authorized PR creation and merge and asked to retire the obsolete agent +page. Moved controls to `frontend/components/app/personal-access-tokens.tsx` in +Account settings, with account-wide history and a legacy URL redirect. Removed +dashboard scoped-token entry points and the separate navigation item. Regression +tests were observed failing before these layout changes. Fixed Claude CLI name +validation: an isolated CLI test rejected `SystemVitals account` and accepted the +normalized generated name. npm confirms the MCP package is unpublished (E404). diff --git a/docs/superpowers/specs/2026-10-14-account-personal-access-tokens-design.md b/docs/superpowers/specs/2026-10-14-account-personal-access-tokens-design.md new file mode 100644 index 0000000..c3aba24 --- /dev/null +++ b/docs/superpowers/specs/2026-10-14-account-personal-access-tokens-design.md @@ -0,0 +1,48 @@ +# Account personal access tokens + +Status: Approved by the user on 2026-10-14. + +Create an account-wide personal access token from Account > Personal access tokens. +It delegates product management and billing checkout/portal links to an agent. +Password changes, token administration, and platform administration remain outside +its authority. Current organization membership, roles, quotas, last-owner rules, +and recipient verification continue to govern every operation. + +Use the existing hashed `svt_` token storage with the explicit `account:manage` +scope, no organization binding, and credential mode `ACCOUNT`. Only a login +session can issue, list, or revoke these credentials. Support optional expiration, +one-time secret display, immediate revocation, and last-used tracking. Reject +malformed account tokens with workspace bindings or mixed scopes. Existing scoped +and legacy credentials retain their current access. + +Account access uses an explicit public-operation allowlist. Add a reusable guard +for a real account session or account token, rejecting impersonation where an +account session was previously required. Allow check moves, allowance queries, +and Telegram connection operations; Telegram still requires its existing handshake. +Expose billing checkout and portal as GraphQL mutations backed by BillingService; +retain the existing REST behavior for compatibility. Billing links require the +user to complete Stripe's hosted flow. + +Reuse the existing connection wizard with an account mode and the same secret +handling and client configurations. Account mode explains its broad access and +excluded credential operations. Connection history labels account tokens clearly. + +MCP discovers ACCOUNT credentials and exposes the existing broad tools plus account +details, subscription, allowance, check moves, status-page management, invitation +inspection/acceptance, Telegram handshake tools, and billing links. Existing +credential catalogs stay compatible. No token-management or password tools exist. + +Verify policy boundaries, authentication/expiry/revocation, creation and hash-only +storage, changing membership and roles, unrelated accounts, admin isolation, +billing service delegation, MCP catalogs and GraphQL variables, and the account +wizard. Run API lint/typecheck/unit/e2e and frontend lint/typecheck/unit checks, +plus MCP typecheck/tests/package smoke. Update contributor and user documentation. + +## Approved follow-up: simplify account token controls + +The user requested retiring the old agent-connections page. Token creation, +account-wide history and revocation now live directly in Account settings. +The legacy URL redirects to `/account#personal-access-tokens`; organization-scoped +creation buttons and the separate sidebar entry are removed. Existing credentials +retain their API permissions and can be revoked from Account. Claude setup names +are normalized to meet CLI restrictions. npm publication remains a release step. diff --git a/frontend/README.md b/frontend/README.md index 952a31c..d1a8ff4 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -16,6 +16,22 @@ npm run dev # starts on port 9999 Copy `.env.example` to `.env.local` and set `NEXT_PUBLIC_API_URL` to point at the api. +## Agent credentials + +Account > Personal access tokens creates and revokes account-wide personal access +tokens. The shared connection wizard uses a GraphQL mutation with Apollo's +`no-cache` policy, displays the secret once, and protects uncopied results on +navigation. If creation has an unknown outcome, reviewing connections explicitly +refreshes history so a potentially active token can be revoked. + +Account tokens manage product resources across current organization permissions +and obtain hosted billing links. Password and token administration stay with the +login session. The organization Connect agent buttons and separate navigation entry are +retired; the old URL redirects to `/account#personal-access-tokens`. Existing +scoped credentials remain available in account-wide history for revocation. Deploy API support and publish the updated MCP package before rolling out +the account wizard's `npx` setup; source-checkout MCP and GraphQL access work without +package publication. + ## Error monitoring Sentry project: `nexus/systemvitals-frontend` at https://sentry.nihey.org. Set diff --git a/frontend/app/(app)/account/agent-connections/page.test.tsx b/frontend/app/(app)/account/agent-connections/page.test.tsx index f8ee96b..24041ba 100644 --- a/frontend/app/(app)/account/agent-connections/page.test.tsx +++ b/frontend/app/(app)/account/agent-connections/page.test.tsx @@ -1,491 +1,8 @@ -import { ApolloLink, InMemoryCache, Observable } from "@apollo/client"; -import { MockedProvider } from "@apollo/client/testing/react"; -import type { MockedResponse } from "@apollo/client/testing"; -import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; -import { print } from "graphql"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { API_TOKENS, REVOKE_API_TOKEN } from "@/lib/queries"; -import { AgentConnectionsPage } from "./page"; - -const now = new Date("2026-07-24T12:00:00.000Z"); -interface TestToken { - id: string; - name: string; - prefix: string; - scopes: string[]; - organizationId: string | null; - projectName: string | null; - organizationName: string | null; - createdAt: string; - expiresAt: string | null; - lastUsedAt: string | null; - revokedAt: string | null; -} - -const active: TestToken = { - id: "active", - name: "Codex — Production", - prefix: "svt_abcd", - scopes: ["checks:read", "checks:write"], - organizationId: "org-1", - projectName: "Production", - organizationName: "Acme", - createdAt: "2026-07-20T12:00:00.000Z", - expiresAt: null, - lastUsedAt: null, - revokedAt: null, -}; -const expired: TestToken = { - ...active, - id: "expired", - name: "Old deploy bot", - expiresAt: "2026-07-23T12:00:00.000Z", - lastUsedAt: "2026-07-22T12:00:00.000Z", -}; -const revoked: TestToken = { - ...active, - id: "revoked", - name: "Former Cursor", - expiresAt: "2026-07-22T12:00:00.000Z", - revokedAt: "2026-07-21T12:00:00.000Z", -}; -const deletedProject: TestToken = { - ...active, - id: "deleted-project", - name: "Historical project agent", - organizationId: null, - projectName: "Retired production", - organizationName: "Acme", -}; - -const otherOrganization: TestToken = { - ...active, - id: "other-organization", - name: "Other organization agent", - organizationId: "org-2", - organizationName: "Other organization", -}; - -vi.mock("@/lib/org-context", () => ({ - useOrg: () => ({ - activeOrg: { - id: "org-1", - name: "Acme", - slug: "acme", - role: "OWNER", - plan: "SIGNAL", - creatorUserId: "user-1", - creatorLabel: "owner@example.com", - pingKey: "ping-key", - }, - }), -})); - -function queryMock(tokens: TestToken[] = [active, expired, revoked]): MockedResponse { - return { - request: { query: API_TOKENS }, - result: { data: { apiTokens: tokens } }, - }; -} - -function renderPage(mocks: MockedResponse[] = [queryMock()]) { - return render( - - now} /> - , - ); -} - -describe("AgentConnectionsPage", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it("queries exact non-secret connection metadata", () => { - const document = print(API_TOKENS); - expect(document).toBe(`query ApiTokens { - apiTokens { - id - name - prefix - scopes - organizationId - projectName - organizationName - createdAt - expiresAt - lastUsedAt - revokedAt - } -}`); - expect(document).not.toMatch(/plaintext|tokenHash/); - }); - - it("shows active, expired, and revoked history with revoked taking precedence", async () => { - renderPage(); - - const activeRow = await screen.findByRole("listitem", { name: /codex — production/i }); - const expiredRow = screen.getByRole("listitem", { name: /old deploy bot/i }); - const revokedRow = screen.getByRole("listitem", { name: /former cursor/i }); - - expect(within(activeRow).getByText("Active")).toBeInTheDocument(); - expect(within(expiredRow).getByText("Expired")).toBeInTheDocument(); - expect(within(revokedRow).getByText("Revoked")).toBeInTheDocument(); - expect(within(activeRow).getByText("Acme")).toBeInTheDocument(); - expect(within(activeRow).getByText("Read checks")).toBeInTheDocument(); - expect(within(activeRow).getByText("Manage checks")).toBeInTheDocument(); - expect(within(activeRow).getByText("Never")).toBeInTheDocument(); - expect(within(activeRow).getByText("Never used")).toBeInTheDocument(); - expect(within(activeRow).getByText("svt_abcd…")).toBeInTheDocument(); - expect(within(activeRow).getByRole("button", { name: /revoke codex — production/i })).toBeEnabled(); - expect(within(expiredRow).getByRole("button", { name: /revoke old deploy bot/i })).toBeEnabled(); - expect(within(revokedRow).queryByRole("button", { name: /revoke/i })).toBeNull(); - }); - - it("uses neutral deleted-workspace copy without exposing a legacy project name", async () => { - const legacy = { - ...active, - id: "legacy", - name: "Legacy account token", - organizationId: null, - projectName: null, - organizationName: null, - scopes: ["read", "write"], - }; - renderPage([queryMock([deletedProject, legacy])]); - - const deletedRow = await screen.findByRole("listitem", { - name: /historical project agent/i, - }); - const legacyRow = screen.getByRole("listitem", { - name: /legacy account token/i, - }); - expect(within(deletedRow).getByText("Inactive")).toBeInTheDocument(); - expect( - within(deletedRow).getByText("Workspace unavailable"), - ).toBeInTheDocument(); - expect(within(deletedRow).queryByText("Retired production")).toBeNull(); - expect(within(deletedRow).queryByText("Active")).not.toBeInTheDocument(); - expect( - within(deletedRow).getByRole("button", { - name: /revoke historical project agent/i, - }), - ).toBeEnabled(); - expect(within(legacyRow).getByText("Active")).toBeInTheDocument(); - expect(within(legacyRow).getByText("All organizations")).toBeInTheDocument(); - }); - - it("filters canonical connection history to the active organization", async () => { - renderPage([queryMock([active, otherOrganization])]); - - expect( - await screen.findByRole("listitem", { name: /codex — production/i }), - ).toBeInTheDocument(); - expect( - screen.queryByRole("listitem", { name: /other organization agent/i }), - ).not.toBeInTheDocument(); - }); - - it("revokes an expired credential that has not already been revoked", async () => { - renderPage([ - queryMock([expired]), - { - request: { query: REVOKE_API_TOKEN, variables: { id: "expired" } }, - result: { data: { revokeApiToken: true } }, - }, - queryMock([{ ...expired, revokedAt: now.toISOString() }]), - ]); - const row = await screen.findByRole("listitem", { name: /old deploy bot/i }); - - fireEvent.click(within(row).getByRole("button", { name: /revoke old deploy bot/i })); - fireEvent.click( - within(screen.getByRole("dialog")).getByRole("button", { - name: /^revoke connection$/i, - }), - ); - - await waitFor(() => expect(within(row).getByText("Revoked")).toBeInTheDocument()); - expect(within(row).queryByRole("button", { name: /revoke/i })).toBeNull(); - }); - - it("renders row-shaped loading skeletons", () => { - renderPage([]); - expect(screen.getByRole("status", { name: /loading agent connections/i })).toBeInTheDocument(); - expect(screen.getAllByTestId("connection-skeleton")).toHaveLength(3); - }); - - it("renders an accessible inline error and retries", async () => { - renderPage([ - { request: { query: API_TOKENS }, error: new Error("offline") }, - queryMock([active]), - ]); - - expect(await screen.findByRole("alert")).toHaveTextContent(/couldn't load agent connections/i); - fireEvent.click(screen.getByRole("button", { name: /try again/i })); - expect(await screen.findByRole("listitem", { name: /codex — production/i })).toBeInTheDocument(); - }); - - it("directs an empty organization to its Connect agent action", async () => { - renderPage([queryMock([])]); - expect(await screen.findByText(/no agent connections yet/i)).toBeInTheDocument(); - expect(screen.getByRole("link", { name: /go to dashboard/i })).toHaveAttribute( - "href", - "/dashboard", - ); - expect( - screen.getByText(/open an organization and choose connect agent/i), - ).toBeInTheDocument(); - }); - - it("cancels revocation without mutating", async () => { - renderPage(); - const row = await screen.findByRole("listitem", { name: /codex — production/i }); - fireEvent.click(within(row).getByRole("button", { name: /revoke codex — production/i })); - const dialog = screen.getByRole("dialog", { name: /revoke agent connection/i }); - expect(within(dialog).getByText(/codex — production/i)).toBeInTheDocument(); - fireEvent.click(within(dialog).getByRole("button", { name: /cancel/i })); - await waitFor(() => - expect(screen.queryByRole("dialog", { name: /revoke agent connection/i })).toBeNull(), - ); - expect(within(row).getByText("Active")).toBeInTheDocument(); - }); - - it("shows Revoked immediately after successful confirmation", async () => { - renderPage([ - queryMock([active]), - { - request: { query: REVOKE_API_TOKEN, variables: { id: "active" } }, - result: { data: { revokeApiToken: true } }, - }, - queryMock([{ ...active, revokedAt: now.toISOString() }]), - ]); - const row = await screen.findByRole("listitem", { name: /codex — production/i }); - fireEvent.click(within(row).getByRole("button", { name: /revoke codex — production/i })); - fireEvent.click( - within(screen.getByRole("dialog")).getByRole("button", { name: /^revoke connection$/i }), - ); - - await waitFor(() => expect(within(row).getByText("Revoked")).toBeInTheDocument()); - expect(within(row).queryByRole("button", { name: /revoke/i })).toBeNull(); - }); - - it("shows revoked metadata while the authoritative refetch is still in flight", async () => { - renderPage([ - queryMock([active]), - { - request: { query: REVOKE_API_TOKEN, variables: { id: "active" } }, - result: { data: { revokeApiToken: true } }, - }, - { - ...queryMock([{ ...active, revokedAt: now.toISOString() }]), - delay: 80, - }, - ]); - const row = await screen.findByRole("listitem", { name: /codex — production/i }); - fireEvent.click(within(row).getByRole("button", { name: /revoke codex — production/i })); - fireEvent.click( - within(screen.getByRole("dialog")).getByRole("button", { name: /^revoke connection$/i }), - ); - - await waitFor(() => expect(within(row).getByText("Revoked")).toBeInTheDocument()); - expect( - within(screen.getByRole("dialog")).getByRole("button", { name: /revoking/i }), - ).toBeDisabled(); - }); - - it("keeps a revoked credential current after unmount and remount", async () => { - const cache = new InMemoryCache(); - const mocks = [ - queryMock([active]), - { - request: { query: REVOKE_API_TOKEN, variables: { id: "active" } }, - result: { data: { revokeApiToken: true } }, - }, - queryMock([{ ...active, revokedAt: now.toISOString() }]), - queryMock([{ ...active, revokedAt: now.toISOString() }]), - ]; - const first = render( - - now} /> - , - ); - const row = await screen.findByRole("listitem", { name: /codex — production/i }); - fireEvent.click(within(row).getByRole("button", { name: /revoke codex — production/i })); - fireEvent.click( - within(screen.getByRole("dialog")).getByRole("button", { name: /^revoke connection$/i }), - ); - await waitFor(() => expect(within(row).getByText("Revoked")).toBeInTheDocument()); - first.unmount(); - - render( - - now} /> - , - ); - const remounted = await screen.findByRole("listitem", { - name: /codex — production/i, - }); - expect(within(remounted).getByText("Revoked")).toBeInTheDocument(); - expect(within(remounted).queryByRole("button", { name: /revoke/i })).toBeNull(); - }); - - it("transitions an active connection to expired at its expiration boundary", async () => { - vi.useFakeTimers(); - vi.setSystemTime(now); - const soon = { - ...active, - expiresAt: new Date(now.getTime() + 1_000).toISOString(), - }; - const cache = new InMemoryCache(); - cache.writeQuery({ - query: API_TOKENS, - data: { apiTokens: [soon] }, - }); - render( - - - , - ); - const row = screen.getByRole("listitem", { name: /codex — production/i }); - expect(within(row).getByText("Active")).toBeInTheDocument(); - await act(async () => { - await vi.advanceTimersByTimeAsync(1_000); - }); - expect(within(row).getByText("Expired")).toBeInTheDocument(); - expect(within(row).getByRole("button", { name: /revoke codex — production/i })).toBeEnabled(); - }); - - it("keeps the connection active and reports a failed revocation", async () => { - renderPage([ - queryMock([active]), - { - request: { query: REVOKE_API_TOKEN, variables: { id: "active" } }, - error: new Error("failed"), - }, - ]); - const row = await screen.findByRole("listitem", { name: /codex — production/i }); - fireEvent.click(within(row).getByRole("button", { name: /revoke codex — production/i })); - fireEvent.click( - within(screen.getByRole("dialog")).getByRole("button", { name: /^revoke connection$/i }), - ); - - expect(await within(screen.getByRole("dialog")).findByRole("alert")).toHaveTextContent( - /couldn't revoke/i, - ); - expect(within(row).getByText("Active")).toBeInTheDocument(); - }); - - it("keeps the connection active when the API does not confirm revocation", async () => { - renderPage([ - queryMock([active]), - { - request: { query: REVOKE_API_TOKEN, variables: { id: "active" } }, - result: { data: { revokeApiToken: false } }, - }, - ]); - const row = await screen.findByRole("listitem", { name: /codex — production/i }); - fireEvent.click(within(row).getByRole("button", { name: /revoke codex — production/i })); - fireEvent.click( - within(screen.getByRole("dialog")).getByRole("button", { name: /^revoke connection$/i }), - ); - - expect(await within(screen.getByRole("dialog")).findByRole("alert")).toHaveTextContent( - /couldn't revoke/i, - ); - expect(within(row).getByText("Active")).toBeInTheDocument(); - }); - - it("does not update revoke state after the page unmounts", async () => { - let completeMutation: (() => void) | undefined; - const link = new ApolloLink( - (operation) => - new Observable((observer) => { - if (operation.operationName === "ApiTokens") { - observer.next({ data: { apiTokens: [active] } }); - observer.complete(); - return; - } - completeMutation = () => { - observer.next({ data: { revokeApiToken: true } }); - observer.complete(); - }; - }), - ); - const view = render( - - now} /> - , - ); - const row = await screen.findByRole("listitem", { - name: /codex — production/i, - }); - fireEvent.click( - within(row).getByRole("button", { name: /revoke codex — production/i }), - ); - fireEvent.click( - within(screen.getByRole("dialog")).getByRole("button", { - name: /^revoke connection$/i, - }), - ); - await waitFor(() => expect(completeMutation).toBeTypeOf("function")); - - view.unmount(); - await new Promise((resolve) => setImmediate(resolve)); - const windowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); - const unhandledReasons: unknown[] = []; - const onUnhandled = (reason: unknown) => unhandledReasons.push(reason); - process.on("unhandledRejection", onUnhandled); - try { - try { - Reflect.deleteProperty(globalThis, "window"); - completeMutation?.(); - for (let step = 0; step < 10; step += 1) { - await Promise.resolve(); - } - } finally { - if (windowDescriptor) { - Object.defineProperty(globalThis, "window", windowDescriptor); - } - } - await new Promise((resolve) => setImmediate(resolve)); - } finally { - process.off("unhandledRejection", onUnhandled); - } - - expect(unhandledReasons).toEqual([]); - }); - - it("guards the revoke mutation against duplicate confirmation clicks", async () => { - let calls = 0; - const link = new ApolloLink( - (operation) => - new Observable((observer) => { - if (operation.operationName === "ApiTokens") { - observer.next({ data: { apiTokens: [active] } }); - observer.complete(); - return; - } - calls += 1; - setTimeout(() => { - observer.next({ data: { revokeApiToken: true } }); - observer.complete(); - }, 20); - }), - ); - render( - - now} /> - , - ); - const row = await screen.findByRole("listitem", { name: /codex — production/i }); - fireEvent.click(within(row).getByRole("button", { name: /revoke codex — production/i })); - const confirm = within(screen.getByRole("dialog")).getByRole("button", { - name: /^revoke connection$/i, - }); - fireEvent.click(confirm); - fireEvent.click(confirm); - expect(calls).toBe(1); - expect(confirm).toBeDisabled(); - }); +import { expect, it, vi } from "vitest"; +import AgentConnectionsRoute from "./page"; +const redirect = vi.hoisted(() => vi.fn()); +vi.mock("next/navigation", () => ({ redirect })); +it("redirects the retired agent page to Account token controls", () => { + AgentConnectionsRoute(); + expect(redirect).toHaveBeenCalledWith("/account#personal-access-tokens"); }); diff --git a/frontend/app/(app)/account/agent-connections/page.tsx b/frontend/app/(app)/account/agent-connections/page.tsx index 5c5d10e..28f1baa 100644 --- a/frontend/app/(app)/account/agent-connections/page.tsx +++ b/frontend/app/(app)/account/agent-connections/page.tsx @@ -1,400 +1,5 @@ -"use client"; - -import { useEffect, useMemo, useRef, useState } from "react"; -import Link from "next/link"; -import { useMutation, useQuery } from "@apollo/client/react"; -import { KeyRound, RotateCcw, ShieldCheck } from "lucide-react"; - -import { API_TOKENS, REVOKE_API_TOKEN } from "@/lib/queries"; -import { useOrg } from "@/lib/org-context"; -import { Badge } from "@/components/ui/badge"; -import { Button, buttonVariants } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Skeleton } from "@/components/ui/skeleton"; - -interface ApiToken { - id: string; - name: string; - prefix: string; - scopes: string[]; - organizationId: string | null; - projectName: string | null; - organizationName: string | null; - createdAt: string; - expiresAt: string | null; - lastUsedAt: string | null; - revokedAt: string | null; -} - -interface ApiTokensData { - apiTokens: ApiToken[]; -} - -interface RevokeApiTokenData { - revokeApiToken: boolean; -} - -type ConnectionStatus = "Active" | "Expired" | "Inactive" | "Revoked"; - -const scopeLabels: Record = { - "checks:read": "Read checks", - "checks:write": "Manage checks", - read: "Read", - write: "Write", -}; -const systemNow = () => new Date(); -const noConnections: ApiToken[] = []; - -function connectionStatus(token: ApiToken, now: Date): ConnectionStatus { - if (token.revokedAt) return "Revoked"; - if (token.expiresAt && new Date(token.expiresAt).getTime() <= now.getTime()) { - return "Expired"; - } - if (isDeletedWorkspaceConnection(token)) return "Inactive"; - return "Active"; -} - -function isDeletedWorkspaceConnection(token: ApiToken) { - return token.organizationId === null && token.projectName !== null; -} - -function formatDate(value: string) { - return new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", - }).format(new Date(value)); -} - -function StatusBadge({ status }: { status: ConnectionStatus }) { - if (status === "Active") { - return ( - - {status} - - ); - } - if (status === "Revoked") { - return {status}; - } - if (status === "Inactive") { - return {status}; - } - return ( - - {status} - - ); -} - -function LoadingRows() { - return ( -
- Loading agent connections - {[0, 1, 2].map((row) => ( -
-
- - -
- -
- - -
-
- ))} -
- ); -} - -export function AgentConnectionsPage({ now = systemNow }: { now?: () => Date }) { - const { activeOrg } = useOrg(); - const { data, loading, error, refetch } = useQuery(API_TOKENS, { - fetchPolicy: "cache-and-network", - }); - const [revokeApiToken] = useMutation( - REVOKE_API_TOKEN, - ); - const [selected, setSelected] = useState(null); - const [revokeError, setRevokeError] = useState(null); - const [refreshError, setRefreshError] = useState(null); - const [revoking, setRevoking] = useState(false); - const [currentTime, setCurrentTime] = useState(now); - const revokingRef = useRef(false); - const mountedRef = useRef(true); - const connections = useMemo( - () => - (data?.apiTokens ?? noConnections).filter( - (token) => - token.organizationId === activeOrg?.id || - token.organizationId === null, - ), - [activeOrg?.id, data?.apiTokens], - ); - - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); - - useEffect(() => { - const currentTimestamp = currentTime.getTime(); - const nearestExpiration = connections.reduce((nearest, token) => { - if (token.revokedAt || !token.expiresAt) return nearest; - const expiresAt = new Date(token.expiresAt).getTime(); - if (expiresAt <= currentTimestamp) return nearest; - return nearest === null || expiresAt < nearest ? expiresAt : nearest; - }, null); - if (nearestExpiration === null) return; - - const maxSafeDelay = 2_147_483_647; - const delay = Math.min(nearestExpiration - currentTimestamp, maxSafeDelay); - const timer = window.setTimeout(() => setCurrentTime(now()), delay); - return () => window.clearTimeout(timer); - }, [connections, currentTime, now]); - - async function handleRevoke() { - if (!selected || revokingRef.current) return; - revokingRef.current = true; - setRevoking(true); - setRevokeError(null); - setRefreshError(null); - try { - const revokedAt = now().toISOString(); - const result = await revokeApiToken({ - variables: { id: selected.id }, - update(cache, { data: mutationData }) { - if (mutationData?.revokeApiToken !== true) return; - const cached = cache.readQuery({ query: API_TOKENS }); - if (!cached) return; - cache.writeQuery({ - query: API_TOKENS, - data: { - apiTokens: cached.apiTokens.map((token) => - token.id === selected.id ? { ...token, revokedAt } : token, - ), - }, - }); - }, - }); - if (result.data?.revokeApiToken !== true) throw new Error("Revoke was not confirmed"); - try { - await refetch(); - } catch { - if (mountedRef.current) { - setRefreshError( - "The connection was revoked, but the latest history couldn't be refreshed. Reload the page to try again.", - ); - } - } - if (mountedRef.current) { - setSelected(null); - } - } catch { - if (mountedRef.current) { - setRevokeError( - "We couldn't revoke this connection. Its status has not changed. Try again.", - ); - } - } finally { - revokingRef.current = false; - if (mountedRef.current) { - setRevoking(false); - } - } - } - - return ( -
-
-
-
-

- Review and revoke the credentials used by coding agents and automation. -

-
- - {refreshError && ( -

- {refreshError} -

- )} - -
-
-
-

- Connection history -

-

- Expired, revoked, and unavailable-workspace credentials remain here for your - records. -

-
-
- - {loading && !data ? ( - - ) : error ? ( -
-

- Couldn't load agent connections.{" "} - Check your connection and try again. -

- -
- ) : connections.length === 0 ? ( -
-
- ) : ( -
    - {connections.map((token) => { - const status = connectionStatus(token, currentTime); - const location = isDeletedWorkspaceConnection(token) - ? "Workspace unavailable" - : token.organizationName ?? "All organizations"; - return ( -
  • -
    -
    -
    -

    {token.name}

    - - {token.prefix}… -
    -

    {location}

    -
    - {token.scopes.map((scope) => ( - - {scopeLabels[scope] ?? scope} - - ))} -
    -
    -
    -
    Created
    -
    {formatDate(token.createdAt)}
    -
    -
    -
    Expires
    -
    - {token.expiresAt ? formatDate(token.expiresAt) : "Never"} -
    -
    -
    -
    Last used
    -
    - {token.lastUsedAt ? formatDate(token.lastUsedAt) : "Never used"} -
    -
    -
    -
    - {!token.revokedAt && ( - - )} -
    -
  • - ); - })} -
- )} -
- - { - if (!open && !revoking) { - setSelected(null); - setRevokeError(null); - } - }} - > - - - Revoke agent connection? - - {selected?.name} will immediately lose access. This action cannot be undone. - - - {revokeError && ( -

- {revokeError} -

- )} - - - - -
-
-
- ); -} +import { redirect } from "next/navigation"; export default function AgentConnectionsRoute() { - return ; + redirect("/account#personal-access-tokens"); } diff --git a/frontend/app/(app)/account/page.test.tsx b/frontend/app/(app)/account/page.test.tsx index 9461dc2..972063d 100644 --- a/frontend/app/(app)/account/page.test.tsx +++ b/frontend/app/(app)/account/page.test.tsx @@ -1,6 +1,10 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, it, expect, vi } from "vitest"; +vi.mock("@/components/app/personal-access-tokens", () => ({ + PersonalAccessTokensSection: () =>
, +})); + const setPassword = vi.fn().mockResolvedValue({ data: { setPassword: true } }); vi.mock("@apollo/client/react", () => ({ useMutation: () => [setPassword, { loading: false }], @@ -31,10 +35,8 @@ describe("AccountPage", () => { expect(screen.queryByLabelText(/current password/i)).toBeNull(); expect(screen.getByLabelText(/new password/i)).toBeInTheDocument(); expect(screen.getByRole("button", { name: /set password/i })).toBeInTheDocument(); - expect(screen.getByRole("link", { name: /manage agent connections/i })).toHaveAttribute( - "href", - "/account/agent-connections", - ); + expect(screen.getByRole("button", { name: "Create personal access token" })).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /manage agent connections/i })).not.toBeInTheDocument(); }); it("asks for the current password once one is set", () => { diff --git a/frontend/app/(app)/account/page.tsx b/frontend/app/(app)/account/page.tsx index 1e27e46..15fe8d5 100644 --- a/frontend/app/(app)/account/page.tsx +++ b/frontend/app/(app)/account/page.tsx @@ -1,11 +1,10 @@ "use client"; import { useState } from "react"; -import Link from "next/link"; import { useMutation } from "@apollo/client/react"; -import { Check, KeyRound, X } from "lucide-react"; +import { Check, X } from "lucide-react"; import { useAuth } from "@/lib/auth-context"; import { SET_PASSWORD } from "@/lib/queries"; -import { Button, buttonVariants } from "@/components/ui/button"; +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { @@ -22,6 +21,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { PersonalAccessTokensSection } from "@/components/app/personal-access-tokens"; import { GoogleMark } from "@/components/brand/google-mark"; export default function AccountPage() { @@ -132,29 +132,7 @@ export default function AccountPage() { - - -
-
-
-
- Agent connections - - Review connection history and revoke agent credentials. - -
-
-
- - - Manage agent connections - - -
+ setErrorMessage(null)}> diff --git a/frontend/app/(app)/app-shell.test.tsx b/frontend/app/(app)/app-shell.test.tsx index 62a9d59..e70db4d 100644 --- a/frontend/app/(app)/app-shell.test.tsx +++ b/frontend/app/(app)/app-shell.test.tsx @@ -52,7 +52,7 @@ describe("App shell", () => { expect(screen.getByText("content")).toBeTruthy(); }); - it("marks only Agent connections active in the mobile drawer on its nested route", () => { + it("marks Account active on the retired agent URL without a separate navigation entry", () => { pathname = "/account/agent-connections"; render( @@ -64,12 +64,8 @@ describe("App shell", () => { const drawer = screen.getByRole("dialog", { name: "Navigation menu" }); expect(drawer).toHaveClass("max-h-[calc(100dvh-2rem)]", "overflow-y-auto"); const account = within(drawer).getByRole("link", { name: "Account" }); - const agentConnections = within(drawer).getByRole("link", { - name: "Agent connections", - }); - - expect(account).not.toHaveClass("text-primary"); - expect(agentConnections).toHaveClass("text-primary"); + expect(within(drawer).queryByRole("link", { name: "Agent connections" })).not.toBeInTheDocument(); + expect(account).toHaveClass("text-primary"); }); it("redirects an unauthenticated dashboard visit through login with an encoded next path", async () => { diff --git a/frontend/app/(app)/dashboard/page.test.tsx b/frontend/app/(app)/dashboard/page.test.tsx index 7381f59..0119729 100644 --- a/frontend/app/(app)/dashboard/page.test.tsx +++ b/frontend/app/(app)/dashboard/page.test.tsx @@ -255,22 +255,10 @@ describe("DashboardPage", () => { ).not.toBeInTheDocument(); }); - it("restores Connect agent entry points for the active organization", async () => { + it("retires organization Connect agent entry points", async () => { renderDashboard("SIGNAL"); - await screen.findByText("No checks yet. Start monitoring your first service."); - const entryPoints = screen.getAllByRole("button", { - name: "Connect agent", - }); - expect(entryPoints).toHaveLength(2); - - fireEvent.click(entryPoints[0]); - const dialog = await screen.findByRole("dialog", { - name: "Connect agent", - }); - expect(dialog).toHaveTextContent( - "Create an organization-scoped connection for Signal org.", - ); + expect(screen.queryByRole("button", { name: "Connect agent" })).not.toBeInTheDocument(); }); it("lets the user type a timeout when creating an HTTP check", async () => { diff --git a/frontend/app/(app)/dashboard/page.tsx b/frontend/app/(app)/dashboard/page.tsx index ff64504..ba403fb 100644 --- a/frontend/app/(app)/dashboard/page.tsx +++ b/frontend/app/(app)/dashboard/page.tsx @@ -40,7 +40,6 @@ import { planIntervalFloor } from "@/lib/plan-limits"; import { isValidCron, nextCronFires } from "@/lib/cron"; import { Skeleton } from "@/components/ui/skeleton"; import { CopyField } from "@/components/app/copy-field"; -import { ConnectAgentDialog } from "@/components/app/connect-agent-dialog"; import { CheckNotificationChannels, type NotificationChannelOption, @@ -516,13 +515,11 @@ function CreateCheckDialog({ interface ChecksListProps { organizationId: string; - organizationName: string; organizationSlug: string; } function ChecksList({ organizationId, - organizationName, organizationSlug, }: ChecksListProps) { const [showCreate, setShowCreate] = useState(false); @@ -608,10 +605,6 @@ function ChecksList({ {allowance && }
- -
@@ -819,7 +807,6 @@ export default function DashboardPage() { )} diff --git a/frontend/app/(app)/layout.tsx b/frontend/app/(app)/layout.tsx index 9ac47a3..1c5fbcc 100644 --- a/frontend/app/(app)/layout.tsx +++ b/frontend/app/(app)/layout.tsx @@ -34,7 +34,7 @@ function MobileDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {APP_NAV.map(({ label, href, icon: Icon }) => { const active = pathname === href || - (href !== "/account" && pathname.startsWith(href + "/")); + pathname.startsWith(href + "/"); return ( { + beforeEach(() => { + history.replaceState(null, '', '/account#personal-access-tokens'); + Object.defineProperty(navigator, 'clipboard', { configurable: true, + value: {writeText: vi.fn().mockResolvedValue(undefined)} }); + }); + it('creates an account credential without organization or capability inputs', async () => { + const cache = new InMemoryCache(); + render(); + fireEvent.click(screen.getByRole('button',{name:'Create personal access token'})); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText(/all organizations you can access/i)).toBeInTheDocument(); + expect(within(dialog).getByText(/password changes and token administration/i)).toBeInTheDocument(); + fireEvent.click(within(dialog).getByRole('button',{name:'Create connection'})); + await screen.findByText(plaintext); + expect(JSON.stringify(cache.extract())).not.toContain(plaintext); + fireEvent.click(screen.getByRole('button',{name:/copy token/i})); + await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith(plaintext)); + }); + it('offers account creation from account connection history', async () => { + render( + + ); + expect(screen.getByRole('button',{name:'Create personal access token'})).toBeInTheDocument(); + }); + it('refreshes same-page history after an uncertain account creation', async () => { + const recovered = {id:'lost-token',name:'Lost response agent',prefix:'svt_lost',scopes:['account:manage'], + organizationId:null,projectName:null,organizationName:null,createdAt:'2026-10-14T12:00:00Z', + expiresAt:null,lastUsedAt:null,revokedAt:null}; + render(); + await screen.findByText('No access tokens yet'); + fireEvent.click(screen.getByRole('button',{name:'Create personal access token'})); + fireEvent.click(await screen.findByRole('button',{name:'Create connection'})); + await screen.findByRole('alert',{name:'Connection status unknown'}); + fireEvent.click(screen.getByRole('checkbox')); + fireEvent.click(screen.getByRole('button',{name:'Review Access tokens'})); + await screen.findByRole('listitem',{name:'Lost response agent'}); + }); +}); + vi.mock("next/navigation", () => ({ useRouter: () => ({ push: mockRouterPush }), })); @@ -465,7 +514,7 @@ describe("ConnectAgentDialog", () => { }); expect(warning).toHaveTextContent(/connection may have been created/i); expect(warning).toHaveTextContent(/one-time secret was not received/i); - expect(warning).toHaveTextContent(/agent connections/i); + expect(warning).toHaveTextContent(/access tokens/i); expect(warning).not.toHaveTextContent(/failed to fetch|could not be created/i); expect(dispatchBeforeUnload()).toBe(true); @@ -495,7 +544,7 @@ describe("ConnectAgentDialog", () => { expect(mockRouterPush).not.toHaveBeenCalled(); }); - it("routes to Agent connections only after acknowledging a potentially created credential", async () => { + it("routes to Access tokens only after acknowledging a potentially created credential", async () => { renderDialog([transportFailureMock()]); const dialog = await openDialog(); fireEvent.click( @@ -506,13 +555,13 @@ describe("ConnectAgentDialog", () => { name: /connection status unknown/i, }); const reviewConnections = screen.getByRole("button", { - name: /review agent connections/i, + name: /review access tokens/i, }); expect(reviewConnections).toBeDisabled(); fireEvent.click( screen.getByRole("checkbox", { - name: /i understand.*connection may have been created.*review agent connections/i, + name: /i understand.*connection may have been created.*review access tokens/i, }), ); expect(reviewConnections).toBeEnabled(); @@ -520,7 +569,7 @@ describe("ConnectAgentDialog", () => { fireEvent.click(reviewConnections); expect(mockRouterPush).toHaveBeenCalledWith( - "/account/agent-connections", + "/account#personal-access-tokens", ); expect( screen.queryByRole("dialog", { name: /connect agent/i }), @@ -601,7 +650,7 @@ describe("ConnectAgentDialog", () => { ]} cache={cache} > - +
, ); expect( diff --git a/frontend/components/app/connect-agent-dialog.tsx b/frontend/components/app/connect-agent-dialog.tsx index e905310..fd03d31 100644 --- a/frontend/components/app/connect-agent-dialog.tsx +++ b/frontend/components/app/connect-agent-dialog.tsx @@ -11,7 +11,7 @@ import { type AgentClient, generateAgentConnectionConfig, } from "@/lib/agent-connection-config"; -import { CREATE_API_TOKEN } from "@/lib/queries"; +import { CREATE_API_TOKEN, CREATE_PERSONAL_ACCESS_TOKEN } from "@/lib/queries"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { @@ -90,9 +90,19 @@ type CreatedConnection = { }; type CreateTokenData = { - createScopedApiToken: CreatedConnection; + createScopedApiToken?: CreatedConnection; + createPersonalAccessToken?: CreatedConnection; }; +type ConnectionProps = { + secondary?: boolean; + onCreated?: () => void; + onReviewConnections?: () => void; +} & ( + | { scope: "account"; organizationId?: never; organizationName?: never } + | { scope?: "organization"; organizationId: string; organizationName: string } +); + type PendingExit = | { kind: "close" } | { kind: "back" } @@ -102,20 +112,20 @@ const HISTORY_GUARD_KEY = "__systemVitalsAgentSecretGuard"; let fallbackGuardSequence = 0; export function ConnectAgentDialog({ - organizationId, - organizationName, + organizationId = "", + organizationName = "SystemVitals account", + scope = "organization", + onCreated, + onReviewConnections, secondary = false, -}: { - organizationId: string; - organizationName: string; - secondary?: boolean; -}) { +}: ConnectionProps) { + const isAccount = scope === "account"; const router = useRouter(); const [open, setOpen] = useState(false); const [pendingExit, setPendingExit] = useState(null); const [name, setName] = useState(() => `Claude Code — ${organizationName}`); const [nameEdited, setNameEdited] = useState(false); - const [expiration, setExpiration] = useState("never"); + const [expiration, setExpiration] = useState(isAccount ? "30" : "never"); const [customDays, setCustomDays] = useState(""); const [created, setCreated] = useState(null); const [creationAtRisk, setCreationAtRisk] = useState(false); @@ -134,7 +144,8 @@ export function ConnectAgentDialog({ const submitInFlightRef = useRef(false); const closeButtonRef = useRef(null); const guardIdRef = useRef(null); - const [createToken, { loading }] = useMutation(CREATE_API_TOKEN, { + const [createToken, { loading }] = useMutation( + isAccount ? CREATE_PERSONAL_ACCESS_TOKEN : CREATE_API_TOKEN, { fetchPolicy: "no-cache", update(cache) { cache.evict({ id: "ROOT_QUERY", fieldName: "apiTokens" }); @@ -158,6 +169,7 @@ export function ConnectAgentDialog({ apiUrl, token: created.plaintext, organizationId, + scope, }) : ""; const secretSecured = secretCopied || tokenAcknowledged; @@ -321,7 +333,7 @@ export function ConnectAgentDialog({ setClient("claude-code"); setName(`Claude Code — ${organizationName}`); setNameEdited(false); - setExpiration("never"); + setExpiration(isAccount ? "30" : "never"); setCustomDays(""); setCreated(null); setCreationAtRisk(false); @@ -372,10 +384,19 @@ export function ConnectAgentDialog({ }; try { - const result = await createToken({ variables: { input } }); - const connection = result.data?.createScopedApiToken; + const variables = isAccount + ? { + name: input.name, + ...(expiration === "never" ? {} : { expirationDays: selectedDays }), + } + : { input }; + const result = await createToken({ variables }); + const connection = isAccount + ? result.data?.createPersonalAccessToken + : result.data?.createScopedApiToken; if (!connection) throw new Error("Connection could not be created."); setCreated({ plaintext: connection.plaintext, name: connection.name }); + onCreated?.(); } catch { setCreationUncertain(true); } finally { @@ -459,7 +480,8 @@ export function ConnectAgentDialog({ ); } flushSync(clearSecretAndClose); - router.push("/account/agent-connections"); + onReviewConnections?.(); + router.push("/account#personal-access-tokens"); } return ( @@ -470,7 +492,7 @@ export function ConnectAgentDialog({ onClick={() => handleOpenChange(true)} > - Connect agent + {isAccount ? 'Create personal access token' : 'Connect agent'} @@ -528,7 +550,7 @@ export function ConnectAgentDialog({ The creation request did not return a result. A connection may have been created, and the one-time secret was not received. Treat any matching credential as active until you review and - revoke it from Agent connections. + revoke it from Access tokens.

@@ -545,7 +567,7 @@ export function ConnectAgentDialog({ className="cursor-pointer text-sm font-normal" > I understand a connection may have been created, and I will - review Agent connections to revoke any potentially created + review Access tokens to revoke any potentially created credential. @@ -564,17 +586,19 @@ export function ConnectAgentDialog({ disabled={!uncertaintyAcknowledged} onClick={reviewAgentConnections} > - Review Agent connections + Review Access tokens ) : ( <> - Connect agent + {isAccount ? 'Create personal access token' : 'Connect agent'} - Create an organization-scoped connection for{" "} - {organizationName}. + {isAccount ? 'Give an agent access to manage your SystemVitals account.' : <> + Create an organization-scoped connection for{' '} + {organizationName}. + } @@ -668,7 +692,7 @@ export function ConnectAgentDialog({

Test prompt

-

{TEST_PROMPT}

+

{isAccount ? 'List my organizations and show my account subscription and usage.' : TEST_PROMPT}

@@ -734,6 +758,15 @@ export function ConnectAgentDialog({
+ {isAccount ?
+

+ Manage all organizations you can access, checks, channels, members, invitations, and status pages. +

+

+ View subscription and usage, and create billing checkout and portal links. +

+

Uses your current organization permissions, including access to organizations you join later. Password changes and token administration require your login session. Billing changes are completed through Stripe's hosted pages.

+
: <>

@@ -749,6 +782,7 @@ export function ConnectAgentDialog({ members, billing, notification channels, or other organizations.

+ }
diff --git a/frontend/components/app/personal-access-tokens.test.tsx b/frontend/components/app/personal-access-tokens.test.tsx new file mode 100644 index 0000000..cf7e555 --- /dev/null +++ b/frontend/components/app/personal-access-tokens.test.tsx @@ -0,0 +1,487 @@ +import { ApolloLink, InMemoryCache, Observable } from "@apollo/client"; +import { MockedProvider } from "@apollo/client/testing/react"; +import type { MockedResponse } from "@apollo/client/testing"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { print } from "graphql"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { API_TOKENS, REVOKE_API_TOKEN } from "@/lib/queries"; +import { PersonalAccessTokensSection } from "./personal-access-tokens"; + +const now = new Date("2026-07-24T12:00:00.000Z"); +vi.mock('next/navigation', () => ({useRouter: () => ({push: vi.fn()})})); +interface TestToken { + id: string; + name: string; + prefix: string; + scopes: string[]; + organizationId: string | null; + projectName: string | null; + organizationName: string | null; + createdAt: string; + expiresAt: string | null; + lastUsedAt: string | null; + revokedAt: string | null; +} + +const active: TestToken = { + id: "active", + name: "Codex — Production", + prefix: "svt_abcd", + scopes: ["checks:read", "checks:write"], + organizationId: "org-1", + projectName: "Production", + organizationName: "Acme", + createdAt: "2026-07-20T12:00:00.000Z", + expiresAt: null, + lastUsedAt: null, + revokedAt: null, +}; +const expired: TestToken = { + ...active, + id: "expired", + name: "Old deploy bot", + expiresAt: "2026-07-23T12:00:00.000Z", + lastUsedAt: "2026-07-22T12:00:00.000Z", +}; +const revoked: TestToken = { + ...active, + id: "revoked", + name: "Former Cursor", + expiresAt: "2026-07-22T12:00:00.000Z", + revokedAt: "2026-07-21T12:00:00.000Z", +}; +const deletedProject: TestToken = { + ...active, + id: "deleted-project", + name: "Historical project agent", + organizationId: null, + projectName: "Retired production", + organizationName: "Acme", +}; + +const otherOrganization: TestToken = { + ...active, + id: "other-organization", + name: "Other organization agent", + organizationId: "org-2", + organizationName: "Other organization", +}; + +vi.mock("@/lib/org-context", () => ({ + useOrg: () => ({ + activeOrg: { + id: "org-1", + name: "Acme", + slug: "acme", + role: "OWNER", + plan: "SIGNAL", + creatorUserId: "user-1", + creatorLabel: "owner@example.com", + pingKey: "ping-key", + }, + }), +})); + +function queryMock(tokens: TestToken[] = [active, expired, revoked]): MockedResponse { + return { + request: { query: API_TOKENS }, + result: { data: { apiTokens: tokens } }, + }; +} + +function renderPage(mocks: MockedResponse[] = [queryMock()]) { + return render( + + now} /> + , + ); +} + +describe("PersonalAccessTokensSection", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("queries exact non-secret connection metadata", () => { + const document = print(API_TOKENS); + expect(document).toBe(`query ApiTokens { + apiTokens { + id + name + prefix + scopes + organizationId + projectName + organizationName + createdAt + expiresAt + lastUsedAt + revokedAt + } +}`); + expect(document).not.toMatch(/plaintext|tokenHash/); + }); + + it("shows active, expired, and revoked history with revoked taking precedence", async () => { + renderPage(); + + const activeRow = await screen.findByRole("listitem", { name: /codex — production/i }); + const expiredRow = screen.getByRole("listitem", { name: /old deploy bot/i }); + const revokedRow = screen.getByRole("listitem", { name: /former cursor/i }); + + expect(within(activeRow).getByText("Active")).toBeInTheDocument(); + expect(within(expiredRow).getByText("Expired")).toBeInTheDocument(); + expect(within(revokedRow).getByText("Revoked")).toBeInTheDocument(); + expect(within(activeRow).getByText("Acme")).toBeInTheDocument(); + expect(within(activeRow).getByText("Read checks")).toBeInTheDocument(); + expect(within(activeRow).getByText("Manage checks")).toBeInTheDocument(); + expect(within(activeRow).getByText("Never")).toBeInTheDocument(); + expect(within(activeRow).getByText("Never used")).toBeInTheDocument(); + expect(within(activeRow).getByText("svt_abcd…")).toBeInTheDocument(); + expect(within(activeRow).getByRole("button", { name: /revoke codex — production/i })).toBeEnabled(); + expect(within(expiredRow).getByRole("button", { name: /revoke old deploy bot/i })).toBeEnabled(); + expect(within(revokedRow).queryByRole("button", { name: /revoke/i })).toBeNull(); + }); + + it("uses neutral deleted-workspace copy without exposing a legacy project name", async () => { + const legacy = { + ...active, + id: "legacy", + name: "Legacy account token", + organizationId: null, + projectName: null, + organizationName: null, + scopes: ["read", "write"], + }; + renderPage([queryMock([deletedProject, legacy])]); + + const deletedRow = await screen.findByRole("listitem", { + name: /historical project agent/i, + }); + const legacyRow = screen.getByRole("listitem", { + name: /legacy account token/i, + }); + expect(within(deletedRow).getByText("Inactive")).toBeInTheDocument(); + expect( + within(deletedRow).getByText("Workspace unavailable"), + ).toBeInTheDocument(); + expect(within(deletedRow).queryByText("Retired production")).toBeNull(); + expect(within(deletedRow).queryByText("Active")).not.toBeInTheDocument(); + expect( + within(deletedRow).getByRole("button", { + name: /revoke historical project agent/i, + }), + ).toBeEnabled(); + expect(within(legacyRow).getByText("Active")).toBeInTheDocument(); + expect(within(legacyRow).getByText("All organizations")).toBeInTheDocument(); + }); + + it("shows account-wide token history across organizations", async () => { + renderPage([queryMock([active, otherOrganization])]); + + expect( + await screen.findByRole("listitem", { name: /codex — production/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("listitem", { name: /other organization agent/i }), + ).toBeInTheDocument(); + }); + + it("revokes an expired credential that has not already been revoked", async () => { + renderPage([ + queryMock([expired]), + { + request: { query: REVOKE_API_TOKEN, variables: { id: "expired" } }, + result: { data: { revokeApiToken: true } }, + }, + queryMock([{ ...expired, revokedAt: now.toISOString() }]), + ]); + const row = await screen.findByRole("listitem", { name: /old deploy bot/i }); + + fireEvent.click(within(row).getByRole("button", { name: /revoke old deploy bot/i })); + fireEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { + name: /^revoke token$/i, + }), + ); + + await waitFor(() => expect(within(row).getByText("Revoked")).toBeInTheDocument()); + expect(within(row).queryByRole("button", { name: /revoke/i })).toBeNull(); + }); + + it("renders row-shaped loading skeletons", () => { + renderPage([]); + expect(screen.getByRole("status", { name: /loading access tokens/i })).toBeInTheDocument(); + expect(screen.getAllByTestId("connection-skeleton")).toHaveLength(3); + }); + + it("renders an accessible inline error and retries", async () => { + renderPage([ + { request: { query: API_TOKENS }, error: new Error("offline") }, + queryMock([active]), + ]); + + expect(await screen.findByRole("alert")).toHaveTextContent(/couldn't load access tokens/i); + fireEvent.click(screen.getByRole("button", { name: /try again/i })); + expect(await screen.findByRole("listitem", { name: /codex — production/i })).toBeInTheDocument(); + }); + + it("offers personal-token creation in the empty state", async () => { + renderPage([queryMock([])]); + expect(await screen.findByText(/no access tokens yet/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create personal access token" })).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /go to dashboard/i })).not.toBeInTheDocument(); + }); + + it("cancels revocation without mutating", async () => { + renderPage(); + const row = await screen.findByRole("listitem", { name: /codex — production/i }); + fireEvent.click(within(row).getByRole("button", { name: /revoke codex — production/i })); + const dialog = screen.getByRole("dialog", { name: /revoke access token/i }); + expect(within(dialog).getByText(/codex — production/i)).toBeInTheDocument(); + fireEvent.click(within(dialog).getByRole("button", { name: /cancel/i })); + await waitFor(() => + expect(screen.queryByRole("dialog", { name: /revoke access token/i })).toBeNull(), + ); + expect(within(row).getByText("Active")).toBeInTheDocument(); + }); + + it("shows Revoked immediately after successful confirmation", async () => { + renderPage([ + queryMock([active]), + { + request: { query: REVOKE_API_TOKEN, variables: { id: "active" } }, + result: { data: { revokeApiToken: true } }, + }, + queryMock([{ ...active, revokedAt: now.toISOString() }]), + ]); + const row = await screen.findByRole("listitem", { name: /codex — production/i }); + fireEvent.click(within(row).getByRole("button", { name: /revoke codex — production/i })); + fireEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { name: /^revoke token$/i }), + ); + + await waitFor(() => expect(within(row).getByText("Revoked")).toBeInTheDocument()); + expect(within(row).queryByRole("button", { name: /revoke/i })).toBeNull(); + }); + + it("shows revoked metadata while the authoritative refetch is still in flight", async () => { + renderPage([ + queryMock([active]), + { + request: { query: REVOKE_API_TOKEN, variables: { id: "active" } }, + result: { data: { revokeApiToken: true } }, + }, + { + ...queryMock([{ ...active, revokedAt: now.toISOString() }]), + delay: 80, + }, + ]); + const row = await screen.findByRole("listitem", { name: /codex — production/i }); + fireEvent.click(within(row).getByRole("button", { name: /revoke codex — production/i })); + fireEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { name: /^revoke token$/i }), + ); + + await waitFor(() => expect(within(row).getByText("Revoked")).toBeInTheDocument()); + expect( + within(screen.getByRole("dialog")).getByRole("button", { name: /revoking/i }), + ).toBeDisabled(); + }); + + it("keeps a revoked credential current after unmount and remount", async () => { + const cache = new InMemoryCache(); + const mocks = [ + queryMock([active]), + { + request: { query: REVOKE_API_TOKEN, variables: { id: "active" } }, + result: { data: { revokeApiToken: true } }, + }, + queryMock([{ ...active, revokedAt: now.toISOString() }]), + queryMock([{ ...active, revokedAt: now.toISOString() }]), + ]; + const first = render( + + now} /> + , + ); + const row = await screen.findByRole("listitem", { name: /codex — production/i }); + fireEvent.click(within(row).getByRole("button", { name: /revoke codex — production/i })); + fireEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { name: /^revoke token$/i }), + ); + await waitFor(() => expect(within(row).getByText("Revoked")).toBeInTheDocument()); + first.unmount(); + + render( + + now} /> + , + ); + const remounted = await screen.findByRole("listitem", { + name: /codex — production/i, + }); + expect(within(remounted).getByText("Revoked")).toBeInTheDocument(); + expect(within(remounted).queryByRole("button", { name: /revoke/i })).toBeNull(); + }); + + it("transitions an active connection to expired at its expiration boundary", async () => { + vi.useFakeTimers(); + vi.setSystemTime(now); + const soon = { + ...active, + expiresAt: new Date(now.getTime() + 1_000).toISOString(), + }; + const cache = new InMemoryCache(); + cache.writeQuery({ + query: API_TOKENS, + data: { apiTokens: [soon] }, + }); + render( + + + , + ); + const row = screen.getByRole("listitem", { name: /codex — production/i }); + expect(within(row).getByText("Active")).toBeInTheDocument(); + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000); + }); + expect(within(row).getByText("Expired")).toBeInTheDocument(); + expect(within(row).getByRole("button", { name: /revoke codex — production/i })).toBeEnabled(); + }); + + it("keeps the connection active and reports a failed revocation", async () => { + renderPage([ + queryMock([active]), + { + request: { query: REVOKE_API_TOKEN, variables: { id: "active" } }, + error: new Error("failed"), + }, + ]); + const row = await screen.findByRole("listitem", { name: /codex — production/i }); + fireEvent.click(within(row).getByRole("button", { name: /revoke codex — production/i })); + fireEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { name: /^revoke token$/i }), + ); + + expect(await within(screen.getByRole("dialog")).findByRole("alert")).toHaveTextContent( + /couldn't revoke/i, + ); + expect(within(row).getByText("Active")).toBeInTheDocument(); + }); + + it("keeps the connection active when the API does not confirm revocation", async () => { + renderPage([ + queryMock([active]), + { + request: { query: REVOKE_API_TOKEN, variables: { id: "active" } }, + result: { data: { revokeApiToken: false } }, + }, + ]); + const row = await screen.findByRole("listitem", { name: /codex — production/i }); + fireEvent.click(within(row).getByRole("button", { name: /revoke codex — production/i })); + fireEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { name: /^revoke token$/i }), + ); + + expect(await within(screen.getByRole("dialog")).findByRole("alert")).toHaveTextContent( + /couldn't revoke/i, + ); + expect(within(row).getByText("Active")).toBeInTheDocument(); + }); + + it("does not update revoke state after the page unmounts", async () => { + let completeMutation: (() => void) | undefined; + const link = new ApolloLink( + (operation) => + new Observable((observer) => { + if (operation.operationName === "ApiTokens") { + observer.next({ data: { apiTokens: [active] } }); + observer.complete(); + return; + } + completeMutation = () => { + observer.next({ data: { revokeApiToken: true } }); + observer.complete(); + }; + }), + ); + const view = render( + + now} /> + , + ); + const row = await screen.findByRole("listitem", { + name: /codex — production/i, + }); + fireEvent.click( + within(row).getByRole("button", { name: /revoke codex — production/i }), + ); + fireEvent.click( + within(screen.getByRole("dialog")).getByRole("button", { + name: /^revoke token$/i, + }), + ); + await waitFor(() => expect(completeMutation).toBeTypeOf("function")); + + view.unmount(); + await new Promise((resolve) => setImmediate(resolve)); + const windowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); + const unhandledReasons: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandledReasons.push(reason); + process.on("unhandledRejection", onUnhandled); + try { + try { + Reflect.deleteProperty(globalThis, "window"); + completeMutation?.(); + for (let step = 0; step < 10; step += 1) { + await Promise.resolve(); + } + } finally { + if (windowDescriptor) { + Object.defineProperty(globalThis, "window", windowDescriptor); + } + } + await new Promise((resolve) => setImmediate(resolve)); + } finally { + process.off("unhandledRejection", onUnhandled); + } + + expect(unhandledReasons).toEqual([]); + }); + + it("guards the revoke mutation against duplicate confirmation clicks", async () => { + let calls = 0; + const link = new ApolloLink( + (operation) => + new Observable((observer) => { + if (operation.operationName === "ApiTokens") { + observer.next({ data: { apiTokens: [active] } }); + observer.complete(); + return; + } + calls += 1; + setTimeout(() => { + observer.next({ data: { revokeApiToken: true } }); + observer.complete(); + }, 20); + }), + ); + render( + + now} /> + , + ); + const row = await screen.findByRole("listitem", { name: /codex — production/i }); + fireEvent.click(within(row).getByRole("button", { name: /revoke codex — production/i })); + const confirm = within(screen.getByRole("dialog")).getByRole("button", { + name: /^revoke token$/i, + }); + fireEvent.click(confirm); + fireEvent.click(confirm); + expect(calls).toBe(1); + expect(confirm).toBeDisabled(); + }); +}); diff --git a/frontend/components/app/personal-access-tokens.tsx b/frontend/components/app/personal-access-tokens.tsx new file mode 100644 index 0000000..c7014e9 --- /dev/null +++ b/frontend/components/app/personal-access-tokens.tsx @@ -0,0 +1,403 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useMutation, useQuery } from "@apollo/client/react"; +import { KeyRound, RotateCcw, ShieldCheck } from "lucide-react"; + +import { API_TOKENS, REVOKE_API_TOKEN } from "@/lib/queries"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ConnectAgentDialog } from "@/components/app/connect-agent-dialog"; + +interface ApiToken { + id: string; + name: string; + prefix: string; + scopes: string[]; + organizationId: string | null; + projectName: string | null; + organizationName: string | null; + createdAt: string; + expiresAt: string | null; + lastUsedAt: string | null; + revokedAt: string | null; +} + +interface ApiTokensData { + apiTokens: ApiToken[]; +} + +interface RevokeApiTokenData { + revokeApiToken: boolean; +} + +type ConnectionStatus = "Active" | "Expired" | "Inactive" | "Revoked"; + +const scopeLabels: Record = { + "account:manage": "Manage account", + "checks:read": "Read checks", + "checks:write": "Manage checks", + read: "Read", + write: "Write", +}; +const systemNow = () => new Date(); +const noConnections: ApiToken[] = []; + +function connectionStatus(token: ApiToken, now: Date): ConnectionStatus { + if (token.revokedAt) return "Revoked"; + if (token.expiresAt && new Date(token.expiresAt).getTime() <= now.getTime()) { + return "Expired"; + } + if (isDeletedWorkspaceConnection(token)) return "Inactive"; + return "Active"; +} + +function isDeletedWorkspaceConnection(token: ApiToken) { + return token.organizationId === null && token.projectName !== null; +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)); +} + +function StatusBadge({ status }: { status: ConnectionStatus }) { + if (status === "Active") { + return ( + + {status} + + ); + } + if (status === "Revoked") { + return {status}; + } + if (status === "Inactive") { + return {status}; + } + return ( + + {status} + + ); +} + +function LoadingRows() { + return ( +
+ Loading access tokens + {[0, 1, 2].map((row) => ( +
+
+ + +
+ +
+ + +
+
+ ))} +
+ ); +} + +export function PersonalAccessTokensSection({ now = systemNow }: { now?: () => Date }) { + const { data, loading, error, refetch } = useQuery(API_TOKENS, { + fetchPolicy: "cache-and-network", + }); + const [revokeApiToken] = useMutation( + REVOKE_API_TOKEN, + ); + const [selected, setSelected] = useState(null); + const [revokeError, setRevokeError] = useState(null); + const [refreshError, setRefreshError] = useState(null); + const [revoking, setRevoking] = useState(false); + const [currentTime, setCurrentTime] = useState(now); + const revokingRef = useRef(false); + const mountedRef = useRef(true); + const connections = data?.apiTokens ?? noConnections; + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + useEffect(() => { + const currentTimestamp = currentTime.getTime(); + const nearestExpiration = connections.reduce((nearest, token) => { + if (token.revokedAt || !token.expiresAt) return nearest; + const expiresAt = new Date(token.expiresAt).getTime(); + if (expiresAt <= currentTimestamp) return nearest; + return nearest === null || expiresAt < nearest ? expiresAt : nearest; + }, null); + if (nearestExpiration === null) return; + + const maxSafeDelay = 2_147_483_647; + const delay = Math.min(nearestExpiration - currentTimestamp, maxSafeDelay); + const timer = window.setTimeout(() => setCurrentTime(now()), delay); + return () => window.clearTimeout(timer); + }, [connections, currentTime, now]); + + function refreshConnections() { + setRefreshError(null); + void refetch().catch(() => { + if (mountedRef.current) { + setRefreshError( + "Token history couldn't be refreshed. Reload the page to review and revoke any newly created credentials.", + ); + } + }); + } + + async function handleRevoke() { + if (!selected || revokingRef.current) return; + revokingRef.current = true; + setRevoking(true); + setRevokeError(null); + setRefreshError(null); + try { + const revokedAt = now().toISOString(); + const result = await revokeApiToken({ + variables: { id: selected.id }, + update(cache, { data: mutationData }) { + if (mutationData?.revokeApiToken !== true) return; + const cached = cache.readQuery({ query: API_TOKENS }); + if (!cached) return; + cache.writeQuery({ + query: API_TOKENS, + data: { + apiTokens: cached.apiTokens.map((token) => + token.id === selected.id ? { ...token, revokedAt } : token, + ), + }, + }); + }, + }); + if (result.data?.revokeApiToken !== true) throw new Error("Revoke was not confirmed"); + try { + await refetch(); + } catch { + if (mountedRef.current) { + setRefreshError( + "The token was revoked, but the latest history couldn't be refreshed. Reload the page to try again.", + ); + } + } + if (mountedRef.current) { + setSelected(null); + } + } catch { + if (mountedRef.current) { + setRevokeError( + "We couldn't revoke this token. Its status has not changed. Try again.", + ); + } + } finally { + revokingRef.current = false; + if (mountedRef.current) { + setRevoking(false); + } + } + } + + return ( +
+
+
+
+

+ Create account-wide credentials for agents and automation, and revoke them when needed. +

+
+ +
+
+ + {refreshError && ( +

+ {refreshError} +

+ )} + +
+
+
+

+ Token history +

+

+ Expired, revoked, and unavailable-workspace credentials remain here for your + records. +

+
+
+ + {loading && !data ? ( + + ) : error ? ( +
+

+ Couldn't load access tokens.{" "} + Check your connection and try again. +

+ +
+ ) : connections.length === 0 ? ( +
+
+ ) : ( +
    + {connections.map((token) => { + const status = connectionStatus(token, currentTime); + const location = isDeletedWorkspaceConnection(token) + ? "Workspace unavailable" + : token.scopes.includes('account:manage') ? 'Personal access token · Account-wide access' + : token.organizationName ?? "All organizations"; + return ( +
  • +
    +
    +
    +

    {token.name}

    + + {token.prefix}… +
    +

    {location}

    +
    + {token.scopes.map((scope) => ( + + {scopeLabels[scope] ?? scope} + + ))} +
    +
    +
    +
    Created
    +
    {formatDate(token.createdAt)}
    +
    +
    +
    Expires
    +
    + {token.expiresAt ? formatDate(token.expiresAt) : "Never"} +
    +
    +
    +
    Last used
    +
    + {token.lastUsedAt ? formatDate(token.lastUsedAt) : "Never used"} +
    +
    +
    +
    + {!token.revokedAt && ( + + )} +
    +
  • + ); + })} +
+ )} +
+ + { + if (!open && !revoking) { + setSelected(null); + setRevokeError(null); + } + }} + > + + + Revoke access token? + + {selected?.name} will immediately lose access. This action cannot be undone. + + + {revokeError && ( +

+ {revokeError} +

+ )} + + + + +
+
+
+ ); +} diff --git a/frontend/components/app/sidebar.tsx b/frontend/components/app/sidebar.tsx index 9045db9..fd357c9 100644 --- a/frontend/components/app/sidebar.tsx +++ b/frontend/components/app/sidebar.tsx @@ -7,7 +7,6 @@ import { Building2, CreditCard, Globe, - KeyRound, LayoutDashboard, LogOut, Shield, @@ -28,11 +27,6 @@ export const APP_NAV = [ { label: "Organizations", href: "/organizations", icon: Building2 }, { label: "Billing", href: "/billing", icon: CreditCard }, { label: "Account", href: "/account", icon: UserCog }, - { - label: "Agent connections", - href: "/account/agent-connections", - icon: KeyRound, - }, ]; export function AppSidebar() { @@ -57,7 +51,7 @@ export function AppSidebar() { {APP_NAV.map(({ label, href, icon: Icon }) => { const active = pathname === href || - (href !== "/account" && pathname.startsWith(href + "/")); + pathname.startsWith(href + "/"); return (
  • { + it.each(['SystemVitals account', 'Produção principal', 'workspace', '東京', '']) + ('uses a valid Claude MCP server name for %s', (organizationName) => { + const result = generateAgentConnectionConfig({ ...input, organizationName, client: 'claude-code' }); + const serverName = result.split('\n').find(line => line.startsWith('claude mcp add '))!.split(' ')[3].replaceAll("'", ''); + expect(serverName).toMatch(/^systemvitals(?:-[a-z0-9_-]+)?$/); + }); it.each<[AgentClient, string]>([ [ "claude-code", `read -rsp 'SystemVitals API token: ' SYSTEMVITALS_API_TOKEN printf '\\n' -claude mcp add 'Production checks' --env 'SYSTEMVITALS_API_URL=https://api.systemvitals.example/graphql' --env "SYSTEMVITALS_API_TOKEN=$SYSTEMVITALS_API_TOKEN" -- npx -y @systemvitals/mcp +claude mcp add 'systemvitals-production-checks' --env 'SYSTEMVITALS_API_URL=https://api.systemvitals.example/graphql' --env "SYSTEMVITALS_API_TOKEN=$SYSTEMVITALS_API_TOKEN" -- npx -y @systemvitals/mcp unset SYSTEMVITALS_API_TOKEN`, ], [ @@ -236,7 +242,7 @@ printf 'token-after=%s' "\${SYSTEMVITALS_API_TOKEN-unset}"`, expect(argumentsOutput.replace(/^\n/, "").split("\0").slice(0, -1)).toEqual([ "mcp", "add", - adversarial.organizationName, + "systemvitals-agent-touch-tmp-systemvitals-injected-echo", "--env", `SYSTEMVITALS_API_URL=${adversarial.apiUrl}`, "--env", diff --git a/frontend/lib/agent-connection-config.ts b/frontend/lib/agent-connection-config.ts index beb5326..7e83e64 100644 --- a/frontend/lib/agent-connection-config.ts +++ b/frontend/lib/agent-connection-config.ts @@ -11,6 +11,7 @@ export interface AgentConnectionConfigInput { organizationName: string; apiUrl: string; token: string; + scope?: 'account' | 'organization'; } type McpConfigInput = Omit< @@ -92,13 +93,22 @@ function mcpServer(input: McpConfigInput) { function generateClaudeCode(input: McpConfigInput): string { assertShellSafe(input.organizationName, "organizationName"); assertShellSafe(input.apiUrl, "apiUrl"); + // Claude rejects display names with spaces or punctuation even when quoted. + // Prefix the slug so arbitrary organization names cannot select reserved names. + const slug = input.organizationName + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + const serverName = slug ? `systemvitals-${slug}` : "systemvitals"; return [ "read -rsp 'SystemVitals API token: ' SYSTEMVITALS_API_TOKEN", "printf '\\n'", [ "claude mcp add", - shellQuote(input.organizationName), + shellQuote(serverName), "--env", shellQuote(`SYSTEMVITALS_API_URL=${input.apiUrl}`), "--env", @@ -141,13 +151,15 @@ function generateUniversal(input: McpConfigInput): string { function generateGraphql( input: Pick< AgentConnectionConfigInput, - "apiUrl" | "token" | "organizationId" + "apiUrl" | "token" | "organizationId" | "scope" >, ): string { assertShellSafe(input.apiUrl, "apiUrl"); assertShellSafe(input.organizationId, "organizationId"); - const body = JSON.stringify({ + const body = JSON.stringify(input.scope === 'account' ? { + query: 'query { me { id email organizations { id name slug role } } mySubscription { plan status maxChecks checkCount organizationCount } }', + } : { query: 'mutation CreateHeartbeat($organizationId: ID!) { createCheck(organizationId: $organizationId, name: "agent-heartbeat", periodSeconds: 300, graceSeconds: 60) { id } }', variables: { organizationId: input.organizationId }, diff --git a/frontend/lib/graphql-documents.test.ts b/frontend/lib/graphql-documents.test.ts index edbd70a..ce750b1 100644 --- a/frontend/lib/graphql-documents.test.ts +++ b/frontend/lib/graphql-documents.test.ts @@ -72,11 +72,11 @@ const CANONICAL_TOKEN_SOURCE_FILES = [ ["queries", resolve(__dirname, "queries.ts")], [ "agent connections page", - resolve(__dirname, "../app/(app)/account/agent-connections/page.tsx"), + resolve(__dirname, "../components/app/personal-access-tokens.tsx"), ], [ "agent connections fixtures", - resolve(__dirname, "../app/(app)/account/agent-connections/page.test.tsx"), + resolve(__dirname, "../components/app/personal-access-tokens.test.tsx"), ], [ "connect agent fixtures", diff --git a/frontend/lib/queries.ts b/frontend/lib/queries.ts index eeb8302..61694b1 100644 --- a/frontend/lib/queries.ts +++ b/frontend/lib/queries.ts @@ -533,6 +533,19 @@ export const CREATE_API_TOKEN = gql` } `; +export const CREATE_PERSONAL_ACCESS_TOKEN = gql` + mutation CreatePersonalAccessToken($name: String!, $expirationDays: Int) { + createPersonalAccessToken(name: $name, expirationDays: $expirationDays) { + id + name + scopes + organizationId + expiresAt + plaintext + } + } +`; + export const API_TOKENS = gql` query ApiTokens { apiTokens { diff --git a/integrations/mcp/README.md b/integrations/mcp/README.md index 5782839..8275b76 100644 --- a/integrations/mcp/README.md +++ b/integrations/mcp/README.md @@ -1,7 +1,8 @@ # SystemVitals MCP Server Drive [SystemVitals](../../README.md) from Claude Code or any MCP-compatible -client. Session and legacy broad credentials expose the 26 tools listed below; +client. Account-wide personal access tokens expose 42 tools. Session and legacy +broad credentials expose the 26 tools listed below; organization-scoped connections expose only their authorized check tools. > **This package is prepared for public publication, but is not currently @@ -14,9 +15,9 @@ organization-scoped connections expose only their authorized check tools. - Node.js 22+ - A running SystemVitals API (default: `http://localhost:8888/graphql`) -- A SystemVitals agent connection secret (`svt_...`) — open an organization and - choose **Connect agent**. Existing connections and revocation history live - under **Account > Agent connections**. +- A SystemVitals personal access token (`svt_...`) — choose **Account > Personal access tokens > Create personal access token** for account management. Existing organization-scoped + connections remain limited to their original check permissions. Revocation + history lives under **Account > Personal access tokens**. Existing connections remain valid. --- @@ -33,7 +34,7 @@ Both are required. The server exits with a clear error if either is missing. ## Registering in Claude Code -After the package has been published, the Connect agent wizard generates setup +After the package has been published, the personal-token wizard generates setup for Claude Code, Codex, Cursor, Universal JSON, and GraphQL/cURL. Claude Code setup prompts for the token so the literal secret is not written into shell history: @@ -85,6 +86,10 @@ The server introspects the credential before registering tools: - Session JWTs and legacy broad `read`/`write` tokens retain all **26** tools below for compatibility. +- Account PATs (`ACCOUNT`, explicit `account:manage`) expose **42** tools: the + existing 26 plus the account catalog below, across current organization + memberships and roles. They cannot change passwords, administer tokens, or + access platform administration. - A full organization-scoped agent connection exposes **9** check tools: two reads and seven mutations. - A read-only scoped credential exposes only **2** tools: `list_checks` and @@ -175,12 +180,34 @@ across all organizations attributed to one creator account. The previous creator remains an owner after transfer, and deleting an organization never changes account billing. -> **Not exposed:** status-page list/create/update/delete is currently absent -> from MCP even though that management surface exists in the API/frontend. -> API-token administration (creation, listing/history, and revocation) requires -> a session JWT and cannot be called with an ApiToken. Account password -> management, account billing, checkout, portal, and Stripe actions also remain -> outside MCP. +## Account PAT tool catalog (16 additional tools) + +| Tool | Description | +|---|---| +| `get_account` | Read account identity and accessible organizations | +| `get_subscription` | Read subscription, plan and aggregate usage | +| `get_organization_check_allowance` | Read the workspace creator's shared check allowance | +| `move_check` | Move a check between accessible organizations | +| `list_status_pages` | List organization status-page configurations | +| `create_status_page` | Create a public status page | +| `update_status_page` | Edit its title, checks or branding | +| `delete_status_page` | Delete a public status page | +| `list_invites` | List pending invitations without acceptance secrets | +| `preview_invite` | Inspect a supplied invitation token | +| `accept_invite` | Accept an invitation for this account | +| `get_telegram_bot` | Discover the managed bot for a normal Telegram handshake | +| `preview_telegram_connection` | Inspect a supplied Telegram handshake token | +| `connect_telegram_channel` | Complete a valid Telegram handshake in an organization | +| `create_billing_checkout` | Return a hosted checkout URL for SIGNAL/FLEET, month/year | +| `create_billing_portal` | Return the account's hosted billing portal URL | + +Billing changes require completing the returned Stripe-hosted flow. These tools +do not independently charge a card or cancel a subscription. Account password +management and API-token administration (creation, listing/history, revocation) +require a session JWT and remain outside MCP. The account PAT shares the issuer's +current product permissions, including organizations joined later. Removing its +issuer's membership immediately removes that organization's access on subsequent +requests. Revoking or expiring the token rejects subsequent authenticated calls. Email create/list results include `verificationStatus`, `verificationDeliveryStatus`, and `verificationExpiresAt`. Pending channels @@ -191,7 +218,7 @@ expire after 24 hours and resend has a 60-second cooldown. Agent secrets are shown once at creation. They must never be logged, persisted by SystemVitals, placed in URLs, or sent to analytics. Revoke an exposed -connection from **Account > Agent connections**. +connection from **Account > Personal access tokens**. ## Credential errors @@ -230,3 +257,13 @@ npm start See the repository [contributor guide](../../AGENTS.md) for validation and contribution guidance. + +## Claude setup names and package availability + +The wizard converts organization/account display names into a Claude-compatible +`systemvitals-...` server name. Claude rejects spaces and punctuation in server +names even when shell-quoted. Other clients retain correctly escaped display names. + +`@systemvitals/mcp` currently returns npm E404 because it has not been published. +Until publication, use the source-checkout instructions above or select GraphQL/cURL +in the token wizard. Token creation and GraphQL access do not depend on npm. diff --git a/integrations/mcp/package-lock.json b/integrations/mcp/package-lock.json index aad67e2..485bdc0 100644 --- a/integrations/mcp/package-lock.json +++ b/integrations/mcp/package-lock.json @@ -17,6 +17,7 @@ }, "devDependencies": { "@types/node": "^22.15.29", + "graphql": "^16.14.2", "tsx": "^4.19.4", "typescript": "^5.8.3", "vitest": "^3.2.4" @@ -1678,6 +1679,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", diff --git a/integrations/mcp/package.json b/integrations/mcp/package.json index 84e663c..5e19055 100644 --- a/integrations/mcp/package.json +++ b/integrations/mcp/package.json @@ -30,6 +30,7 @@ }, "devDependencies": { "@types/node": "^22.15.29", + "graphql": "^16.14.2", "tsx": "^4.19.4", "typescript": "^5.8.3", "vitest": "^3.2.4" diff --git a/integrations/mcp/src/account-tools.ts b/integrations/mcp/src/account-tools.ts new file mode 100644 index 0000000..5f462ea --- /dev/null +++ b/integrations/mcp/src/account-tools.ts @@ -0,0 +1,158 @@ +import { z } from "zod"; +import type { ToolDef } from "./tools.js"; + +// These operations are registered only for explicitly account-wide credentials. +// Existing services enforce current membership, roles, quotas and handshakes. +function accountTool( + name: string, + description: string, + inputSchema: z.ZodRawShape, + query: string, + field: string, +): ToolDef { + return { + name, + description, + inputSchema, + async handler(args, gql) { + const variables = z.object(inputSchema).parse(args); + const data = await gql(query, variables); + return { content: [{ type: "text", text: JSON.stringify(data[field]) }] }; + }, + }; +} + +const id = () => z.string().min(1); +const organization = { + organizationId: id().describe("Organization workspace ID"), +}; +const pageFields = "id organizationId slug title checkIds branding"; + +export const accountTools: readonly ToolDef[] = [ + accountTool( + "get_account", + "Get the account identity and all currently accessible organization workspaces.", + {}, + `query { me { id email hasPassword googleLinked organizations { id name slug role plan pingKey } } }`, + "me", + ), + accountTool( + "get_subscription", + "Get the account subscription, plan and aggregate organization/check usage.", + {}, + `query { mySubscription { plan status maxChecks checkCount organizationCount } }`, + "mySubscription", + ), + accountTool( + "get_organization_check_allowance", + "Get the organization creator account's shared check allowance, without creator identity.", + organization, + `query($organizationId:ID!){ organizationCheckAllowance(organizationId:$organizationId){used limit remaining} }`, + "organizationCheckAllowance", + ), + accountTool( + "move_check", + "Move a check to another organization workspace you can access. Existing per-check channel exclusions are reset.", + { checkId: id(), destinationOrganizationId: id() }, + `mutation($checkId:ID!,$destinationOrganizationId:ID!){moveCheck(checkId:$checkId,destinationOrganizationId:$destinationOrganizationId){id organizationId name}}`, + "moveCheck", + ), + accountTool( + "list_status_pages", + "List public status-page configurations in an organization workspace.", + organization, + `query($organizationId:ID!){statusPages(organizationId:$organizationId){${pageFields}}}`, + "statusPages", + ), + accountTool( + "create_status_page", + "Create a public status page using checks from the same organization.", + { + ...organization, + slug: z.string().min(1), + title: z.string().min(1), + checkIds: z.array(id()), + brandingJson: z.string().optional(), + }, + `mutation($organizationId:ID!,$slug:String!,$title:String!,$checkIds:[ID!]!,$brandingJson:String){createStatusPage(organizationId:$organizationId,slug:$slug,title:$title,checkIds:$checkIds,brandingJson:$brandingJson){${pageFields}}}`, + "createStatusPage", + ), + accountTool( + "update_status_page", + "Update a public status page's title, selected checks or branding.", + { + id: id(), + title: z.string().optional(), + checkIds: z.array(id()).optional(), + brandingJson: z.string().optional(), + }, + `mutation($id:ID!,$title:String,$checkIds:[ID!],$brandingJson:String){updateStatusPage(id:$id,title:$title,checkIds:$checkIds,brandingJson:$brandingJson){${pageFields}}}`, + "updateStatusPage", + ), + accountTool( + "delete_status_page", + "Permanently delete a public status page.", + { id: id() }, + `mutation($id:ID!){deleteStatusPage(id:$id)}`, + "deleteStatusPage", + ), + accountTool( + "list_invites", + "List pending organization invitations. Acceptance secrets are omitted.", + organization, + `query($organizationId:ID!){organizationInvites(organizationId:$organizationId){id email role expiresAt}}`, + "organizationInvites", + ), + accountTool( + "preview_invite", + "Inspect an invitation using its acceptance token before accepting it.", + { token: id() }, + `query($token:String!){invitePreview(token:$token){maskedEmail organizationName status}}`, + "invitePreview", + ), + accountTool( + "accept_invite", + "Accept an organization invitation for this account using the supplied invitation token.", + { token: id() }, + `mutation($token:String!){acceptInvite(token:$token){id userId email role}}`, + "acceptInvite", + ), + accountTool( + "get_telegram_bot", + "Get the managed Telegram bot username and availability to begin the normal Telegram handshake.", + {}, + `query{managedTelegramBot{available username}}`, + "managedTelegramBot", + ), + accountTool( + "preview_telegram_connection", + "Inspect a Telegram handshake token obtained through the managed bot.", + { token: id() }, + `query($token:String!){telegramConnectionPreview(token:$token){chatId chatType chatTitle messageThreadId expiresAt}}`, + "telegramConnectionPreview", + ), + accountTool( + "connect_telegram_channel", + "Connect Telegram to an organization using a valid managed-bot handshake token. Does not bypass Telegram verification.", + { ...organization, token: id() }, + `mutation($organizationId:ID!,$token:String!){connectTelegramChannel(organizationId:$organizationId,token:$token){id type enabled organizationId}}`, + "connectTelegramChannel", + ), + accountTool( + "create_billing_checkout", + "Create a hosted Stripe checkout link for SIGNAL or FLEET. The user must open the returned URL and complete checkout.", + { + plan: z.enum(["SIGNAL", "FLEET"]), + interval: z.enum(["month", "year"]).optional(), + }, + `mutation($plan:String!,$interval:String){createBillingCheckout(plan:$plan,interval:$interval){url}}`, + "createBillingCheckout", + ), + accountTool( + "create_billing_portal", + "Create a hosted billing portal link for this account. The user opens the URL to manage payment methods or their subscription.", + {}, + `mutation{createBillingPortal{url}}`, + "createBillingPortal", + ), +]; diff --git a/integrations/mcp/src/credential.ts b/integrations/mcp/src/credential.ts index 2db307a..ee594ce 100644 --- a/integrations/mcp/src/credential.ts +++ b/integrations/mcp/src/credential.ts @@ -1,9 +1,10 @@ import type { Gql } from "./gql.js"; import { tools, type ToolDef } from "./tools.js"; +import { accountTools } from './account-tools.js'; export type Credential = { authKind: "session" | "api-token"; - credentialMode: "SESSION" | "LEGACY_BROAD" | "PROJECT_SCOPED"; + credentialMode: "SESSION" | "LEGACY_BROAD" | "PROJECT_SCOPED" | "ACCOUNT"; capabilities: readonly string[]; organizationId: string | null; organizationName: string | null; @@ -84,6 +85,14 @@ export async function fetchCredential(gql: Gql): Promise { export function toolsForCredential( credential: Credential, ): readonly ToolDef[] { + if (credential.credentialMode === 'ACCOUNT') { + if (credential.authKind !== 'api-token' || + !credential.capabilities.includes('account:manage') || + credential.organizationId !== null || credential.projectId !== null) { + throw new Error('Invalid account API credential metadata.'); + } + return [...tools, ...accountTools]; + } if ( credential.credentialMode === "SESSION" || credential.credentialMode === "LEGACY_BROAD" diff --git a/integrations/mcp/test/account-tools.test.ts b/integrations/mcp/test/account-tools.test.ts new file mode 100644 index 0000000..be9b18f --- /dev/null +++ b/integrations/mcp/test/account-tools.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { buildSchema, parse, validate } from "graphql"; +import { toolsForCredential, type Credential } from "../src/credential.js"; +import type { Gql } from "../src/gql.js"; +import { tools } from "../src/tools.js"; + +const credential: Credential = { + authKind: "api-token", + credentialMode: "ACCOUNT", + capabilities: ["account:manage", "checks:read", "checks:write"], + organizationId: null, + organizationName: null, + projectId: null, + projectName: null, +}; +const cases = [ + { name: "get_account", args: {}, field: "me" }, + { name: "get_subscription", args: {}, field: "mySubscription" }, + { + name: "get_organization_check_allowance", + args: { organizationId: "org1" }, + field: "organizationCheckAllowance", + }, + { + name: "move_check", + args: { checkId: "check1", destinationOrganizationId: "org2" }, + field: "moveCheck", + }, + { + name: "list_status_pages", + args: { organizationId: "org1" }, + field: "statusPages", + }, + { + name: "create_status_page", + args: { + organizationId: "org1", + slug: "public-status", + title: "Status", + checkIds: ["check1"], + }, + field: "createStatusPage", + }, + { + name: "update_status_page", + args: { id: "page1", title: "Updated" }, + field: "updateStatusPage", + }, + { + name: "delete_status_page", + args: { id: "page1" }, + field: "deleteStatusPage", + }, + { + name: "list_invites", + args: { organizationId: "org1" }, + field: "organizationInvites", + }, + { + name: "preview_invite", + args: { token: "invitation" }, + field: "invitePreview", + }, + { + name: "accept_invite", + args: { token: "invitation" }, + field: "acceptInvite", + }, + { name: "get_telegram_bot", args: {}, field: "managedTelegramBot" }, + { + name: "preview_telegram_connection", + args: { token: "handshake" }, + field: "telegramConnectionPreview", + }, + { + name: "connect_telegram_channel", + args: { organizationId: "org1", token: "handshake" }, + field: "connectTelegramChannel", + }, + { + name: "create_billing_checkout", + args: { plan: "SIGNAL", interval: "year" }, + field: "createBillingCheckout", + }, + { name: "create_billing_portal", args: {}, field: "createBillingPortal" }, +]; + +describe("account credential tools", () => { + it("includes broad product tools and the account catalog only for ACCOUNT", () => { + const accountTools = toolsForCredential(credential); + const names = accountTools.map((tool) => tool.name); + expect(names).toEqual([ + ...tools.map((tool) => tool.name), + ...cases.map((test) => test.name), + ]); + expect(new Set(names).size).toBe(names.length); + expect( + names.some((name) => + /password|api_token|personal_access_token|admin/.test(name), + ), + ).toBe(false); + for (const mode of ["SESSION", "LEGACY_BROAD"] as const) { + expect(toolsForCredential({ ...credential, credentialMode: mode })).toBe( + tools, + ); + } + }); + it.each(cases)( + "$name sends a valid GraphQL operation and only its declared variables", + async ({ name, args, field }) => { + const tool = toolsForCredential(credential).find( + (tool) => tool.name === name, + )!; + const gql = vi + .fn() + .mockResolvedValue({ + [field]: { id: "result1", url: "https://billing.example.test" }, + }); + const result = await tool.handler(args, gql); + const [query, variables] = gql.mock.calls[0]; + const schema = buildSchema( + readFileSync( + new URL("../../../api/src/schema.gql", import.meta.url), + "utf8", + ), + ); + expect(validate(schema, parse(query))).toEqual([]); + expect(query).toContain(field); + expect(variables).toEqual(args); + expect(result.content[0].text).toContain("result1"); + }, + ); + it("propagates API authorization errors instead of reporting success", async () => { + const tool = toolsForCredential(credential).find( + (tool) => tool.name === "delete_status_page", + )!; + const gql = vi + .fn() + .mockRejectedValue(new Error("Not a member of this organization")); + await expect(tool.handler({ id: "page1" }, gql)).rejects.toThrow( + "Not a member", + ); + }); +}); From 05c0ac500b428f51ff1493ee6959e0407f5011f4 Mon Sep 17 00:00:00 2001 From: Nihey Takizawa Date: Wed, 14 Oct 2026 18:40:06 -0300 Subject: [PATCH 2/2] test: isolate watchdog account cleanup --- ...26-10-14-account-personal-access-tokens.md | 7 ++++++ worker/test/watchdog.test.ts | 25 ++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-14-account-personal-access-tokens.md b/docs/superpowers/plans/2026-10-14-account-personal-access-tokens.md index ae51136..964cb72 100644 --- a/docs/superpowers/plans/2026-10-14-account-personal-access-tokens.md +++ b/docs/superpowers/plans/2026-10-14-account-personal-access-tokens.md @@ -103,3 +103,10 @@ dashboard scoped-token entry points and the separate navigation item. Regression tests were observed failing before these layout changes. Fixed Claude CLI name validation: an isolated CLI test rejected `SystemVitals account` and accepted the normalized generated name. npm confirms the MCP package is unpublished (E404). + +CI follow-up: all API e2e tests passed, but watchdog fixture cleanup selected every +`@test.invalid` user, colliding with parallel alert-handler fixtures. Restricted +cleanup to the watchdog-test prefix and added an unrelated-account fixture that +reproduced the failure before the fix. Worker TypeScript and all 252 tests pass +against the isolated test database. The user explicitly confirmed token controls +belong directly in Account settings with the old agent page removed. diff --git a/worker/test/watchdog.test.ts b/worker/test/watchdog.test.ts index f2696e7..a1a09c8 100644 --- a/worker/test/watchdog.test.ts +++ b/worker/test/watchdog.test.ts @@ -19,8 +19,21 @@ let checkCronOverdueId: string; let checkCronFreshId: string; let emailChannelId: string; let slackChannelId: string; +let cleanupNeighborUserId: string; +let cleanupNeighborOrgId: string; beforeAll(async () => { + // An unrelated suite can own an account in the same test-email domain. + // Keep its organization present to exercise the creator FK during cleanup. + const neighbor = await prisma.user.create({ + data: { email: `watchdog-cleanup-neighbor-${Date.now()}@test.invalid`, passwordHash: "testhash" }, + }); + cleanupNeighborUserId = neighbor.id; + const neighborOrg = await prisma.organization.create({ + data: { name: "Cleanup neighbor", slug: `cleanup-neighbor-${neighbor.id}`, creatorUserId: neighbor.id }, + }); + cleanupNeighborOrgId = neighborOrg.id; + // Clean up any stale test artifacts from prior failed runs const staleProjects = await prisma.project.findMany({ where: { name: "Watchdog Test Project" }, @@ -48,7 +61,9 @@ beforeAll(async () => { await prisma.membership.deleteMany({ where: { organizationId: { in: staleOrgIds } } }); await prisma.organization.deleteMany({ where: { id: { in: staleOrgIds } } }); } - await prisma.user.deleteMany({ where: { email: { endsWith: "@test.invalid" } } }); + await prisma.user.deleteMany({ + where: { email: { startsWith: "watchdog-test-", endsWith: "@test.invalid" } }, + }); // Create user const user = await prisma.user.create({ @@ -205,6 +220,8 @@ beforeAll(async () => { }); afterAll(async () => { + if (cleanupNeighborOrgId) await prisma.organization.delete({ where: { id: cleanupNeighborOrgId } }); + if (cleanupNeighborUserId) await prisma.user.delete({ where: { id: cleanupNeighborUserId } }); // Clean up in dependency order const allCheckIds = [checkOverdueId, checkFreshId, checkPausedId, checkCronOverdueId, checkCronFreshId]; await prisma.checkEvent.deleteMany({ where: { checkId: { in: allCheckIds } } }); @@ -220,6 +237,12 @@ afterAll(async () => { }); describe("sweepOverdue", () => { + it("preserves unrelated test accounts during watchdog fixture cleanup", async () => { + const neighbor = await prisma.user.findUnique({ where: { id: cleanupNeighborUserId } }); + const organization = await prisma.organization.findUnique({ where: { id: cleanupNeighborOrgId } }); + expect(neighbor?.id).toBe(cleanupNeighborUserId); + expect(organization?.creatorUserId).toBe(cleanupNeighborUserId); + }); it("marks the overdue UP check as DOWN, writes a CheckEvent, enqueues alert, returns 1", async () => { const enqueuedJobs: AlertJob[] = []; const fakeEnqueue = async (job: AlertJob) => {