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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const PUBLIC_ONLY_SCHEMA_TYPES = new Set([
'ApiCredential',
'ManagedTelegramBotModel',
'TelegramConnectionPreviewModel',
'BillingLink',
]);

function isolateAdminSchema(schema: GraphQLSchema): GraphQLSchema {
Expand Down
44 changes: 44 additions & 0 deletions api/src/billing/billing-actions.resolver.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
39 changes: 39 additions & 0 deletions api/src/billing/billing-actions.resolver.ts
Original file line number Diff line number Diff line change
@@ -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<BillingLink> {
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<BillingLink> {
return this.billing.createPortal(user.userId);
}
}
5 changes: 4 additions & 1 deletion api/src/billing/billing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,19 @@ 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,
StripePriceRegistry,
StripePlanBootstrapService,
BillingService,
BillingResolver,
BillingActionsResolver,
AccountEntitlementsService,
SubscriptionReconciliationService,
ReconciliationRunLock,
Expand Down
4 changes: 2 additions & 2 deletions api/src/checks/checks.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -234,7 +234,7 @@ export class ChecksResolver {
}

@Mutation(() => CheckModel)
@AccountSessionOnly()
@AccountAccess()
async moveCheck(
@CurrentUser() principal: ApiPrincipal,
@Args('checkId', { type: () => ID }) checkId: string,
Expand Down
7 changes: 7 additions & 0 deletions api/src/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ type AuditLogModel {
targetType: String
}

type BillingLink {
url: String!
}

type ChannelModel {
configJson: String!
enabled: Boolean!
Expand Down Expand Up @@ -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!
Expand Down
6 changes: 3 additions & 3 deletions api/src/telegram/telegram.resolver.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down
48 changes: 48 additions & 0 deletions api/src/tokens/account-access.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
41 changes: 41 additions & 0 deletions api/src/tokens/account-access.guard.ts
Original file line number Diff line number Diff line change
@@ -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));
}
2 changes: 1 addition & 1 deletion api/src/tokens/api-principal.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading
Loading