Skip to content
Open
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
5 changes: 4 additions & 1 deletion apps/api/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { createEmailOtpConfig } from "./email-otp";
import { createEmailSender, findEmailProvider } from "./email-providers";
import { emailButton, emailParagraph, emailTemplate } from "./email-template";
import { createMagicLinkConfig } from "./magic-link";
import { readInitialCreditCents } from "./initial-credit";
import { seedOrgDb } from "./org";
import { hoistOrgLogo } from "./hoist-org-logo";
import { identifyAuthenticatedUser } from "./posthog-identify";
Expand Down Expand Up @@ -240,7 +241,9 @@ const plugins = [
organization({
organizationCreation: {
afterCreate: async (data) => {
await seedOrgDb(data.organization.id, data.member.userId);
await seedOrgDb(data.organization.id, data.member.userId, {
signupGrantCents: readInitialCreditCents(data.organization.metadata),
});
},
},
organizationHooks: {
Expand Down
58 changes: 58 additions & 0 deletions apps/api/src/auth/initial-credit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from "bun:test";
import { readInitialCreditCents } from "./initial-credit";

describe("readInitialCreditCents", () => {
it("reads a valid amount from an object", () => {
expect(readInitialCreditCents({ initialCreditCents: 2500 })).toBe(2500);
});

it("reads a valid amount from a JSON string (as Better Auth may pass it)", () => {
expect(
readInitialCreditCents(JSON.stringify({ initialCreditCents: 1000 })),
).toBe(1000);
});

it("accepts 0 (explicit no-grant)", () => {
expect(readInitialCreditCents({ initialCreditCents: 0 })).toBe(0);
});

it("preserves other metadata keys without interfering", () => {
expect(
readInitialCreditCents({ description: "x", initialCreditCents: 500 }),
).toBe(500);
});

it("falls back to undefined for non-object / missing / malformed input", () => {
for (const input of [
undefined,
null,
"",
"not json",
42,
[],
{ other: 1 },
]) {
expect(readInitialCreditCents(input)).toBeUndefined();
}
});

it("rejects out-of-range or non-integer amounts", () => {
for (const value of [
-1,
1.5,
Number.NaN,
Number.POSITIVE_INFINITY,
100_001,
]) {
expect(
readInitialCreditCents({ initialCreditCents: value }),
).toBeUndefined();
}
});

it("accepts exactly the cap", () => {
expect(readInitialCreditCents({ initialCreditCents: 100_000 })).toBe(
100_000,
);
});
});
30 changes: 30 additions & 0 deletions apps/api/src/auth/initial-credit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { MAX_SIGNUP_GRANT_CENTS } from "@/billing/gateway-admin";

/**
* Extract a control-plane-supplied initial AI-credit override (in cents) from
* an org's Better Auth metadata, which reaches the afterCreate hook as either
* an object or a raw JSON string. Returns undefined for anything that isn't a
* finite non-negative integer within the cap, so a malformed value falls back
* to the deployment default rather than granting a garbage amount.
*/
export function readInitialCreditCents(metadata: unknown): number | undefined {
let bag: unknown = metadata;
if (typeof bag === "string") {
try {
bag = JSON.parse(bag);
} catch {
return undefined;
}
}
if (typeof bag !== "object" || bag === null) return undefined;
const value = (bag as Record<string, unknown>).initialCreditCents;
if (
typeof value !== "number" ||
!Number.isInteger(value) ||
value < 0 ||
value > MAX_SIGNUP_GRANT_CENTS
) {
return undefined;
}
return value;
}
28 changes: 27 additions & 1 deletion apps/api/src/auth/org.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import {
getWellKnownSelfConnection,
} from "@decocms/shared/sdk";
import { decoAiGatewayAdapter } from "@/ai-providers/adapters/deco-ai-gateway";
import {
gatewayAdminConfigured,
grantGatewaySignupCredit,
} from "@/billing/gateway-admin";
import { getBaseUrl } from "@/core/server-constants";
import { getDb } from "@/database";
import { CredentialVault } from "@/encryption/credential-vault";
Expand Down Expand Up @@ -86,8 +90,16 @@ function getDefaultOrgMcps(organizationId: string): MCPCreationSpec[] {
* Create default MCP connections for a new organization
* This is deferred to run after the Better Auth request completes
* to avoid deadlocks when issuing tokens
*
* `opts.signupGrantCents` overrides the deployment-default AI-credit grant for
* this org (the control-plane passes it per-org at creation); omitted → the
* `signupGrantCents` setting applies.
*/
export async function seedOrgDb(organizationId: string, createdBy: string) {
export async function seedOrgDb(
organizationId: string,
createdBy: string,
opts?: { signupGrantCents?: number },
) {
try {
const database = getDb();
const settings = getSettings();
Expand Down Expand Up @@ -189,6 +201,20 @@ export async function seedOrgDb(organizationId: string, createdBy: string) {
console.error("Failed to auto-provision Deco AI Gateway key:", err);
}
}

// Idempotent at the gateway per `signup-credit:<orgId>`; fail-soft.
const signupGrantCents =
opts?.signupGrantCents ?? settings.signupGrantCents;
if (signupGrantCents > 0 && gatewayAdminConfigured()) {
try {
await grantGatewaySignupCredit({
organizationId,
amountCents: signupGrantCents,
});
} catch (err) {
console.error("Failed to grant signup AI credit:", err);
}
}
} catch (err) {
console.error("Error creating default MCP connections:", err);
}
Expand Down
36 changes: 36 additions & 0 deletions apps/api/src/billing/gateway-admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@

import { getSettings } from "../settings";

/** Fat-finger cap on any signup/initial-credit grant ($1,000). Keep in sync
* with the DECO_AI_GATEWAY_SIGNUP_GRANT_CENTS cap in resolve-config.ts. */
export const MAX_SIGNUP_GRANT_CENTS = 100_000;

/** Whether this deployment can reach the gateway admin API at all
* (self-hosted deployments can't). */
export function gatewayAdminConfigured(): boolean {
Expand Down Expand Up @@ -33,6 +37,38 @@ async function postGatewayAdmin(
}
}

/**
* Grant the one-time signup credit to a new org's gateway ledger. Idempotent
* at the gateway per referenceId (unique ledger index): the deterministic
* `signup-credit:<orgId>` reference collapses any replay — a re-run of
* seedOrgDb, a retry — to a no-op, so the org is credited exactly once without
* Studio holding any local "already granted" state.
*
* Fail-soft is the CALLER's job: org creation must never fail on a grant error
* (see seedOrgDb), mirroring the auto-provision path.
*/
export async function grantGatewaySignupCredit(input: {
organizationId: string;
amountCents: number;
}): Promise<void> {
if (!gatewayAdminConfigured()) {
// Config was removed mid-flight; throw so the fail-soft caller logs it.
throw new Error(
"gateway admin not configured — cannot grant signup credit",
);
}
await postGatewayAdmin(
"/api/admin/credits",
{
orgId: input.organizationId,
amountCents: input.amountCents,
description: "Studio signup credit",
referenceId: `signup-credit:${input.organizationId}`,
},
"gateway signup credit grant",
);
}

/**
* Credit purchased AI credits to the org's gateway ledger. The top-up webhook
* THROWS on failure and lets Stripe's redelivery be the retry queue — the
Expand Down
34 changes: 34 additions & 0 deletions apps/api/src/settings/resolve-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,40 @@ describe("resolveConfig topup fee percent", () => {
});
});

describe("resolveConfig signup grant cents", () => {
it("defaults to 2300 (nets ~$25 with the gateway's $2)", () => {
expect(resolveConfig(flags, {}).settings.signupGrantCents).toBe(2300);
});

it("honors an override", () => {
expect(
resolveConfig(flags, { DECO_AI_GATEWAY_SIGNUP_GRANT_CENTS: "5000" })
.settings.signupGrantCents,
).toBe(5000);
});

it("allows 0 to disable the signup grant", () => {
expect(
resolveConfig(flags, { DECO_AI_GATEWAY_SIGNUP_GRANT_CENTS: "0" }).settings
.signupGrantCents,
).toBe(0);
});

it("rejects a non-numeric value at boot (fail-fast, not a silent default)", () => {
expect(() =>
resolveConfig(flags, { DECO_AI_GATEWAY_SIGNUP_GRANT_CENTS: "lots" }),
).toThrow(
"DECO_AI_GATEWAY_SIGNUP_GRANT_CENTS must be a non-negative integer",
);
});

it("rejects a value above the $1,000 fat-finger cap", () => {
expect(() =>
resolveConfig(flags, { DECO_AI_GATEWAY_SIGNUP_GRANT_CENTS: "250000" }),
).toThrow("DECO_AI_GATEWAY_SIGNUP_GRANT_CENTS must be at most 100000");
});
});

describe("resolveConfig decopilot max concurrent subagents", () => {
it("defaults to 4", () => {
expect(
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/settings/resolve-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,13 @@ export function resolveConfig(
aiGatewayEnabled: toBool(envVars.DECO_AI_GATEWAY_ENABLED),
aiGatewayUrl: envVars.DECO_AI_GATEWAY_URL || "https://ai-site.deco.site",
aiGatewayAdminToken: envVars.DECO_AI_GATEWAY_ADMIN_TOKEN,
// Default 2300 nets ~$25 with the gateway's $2; cap $1,000; 0 disables.
signupGrantCents: toNonNegativeIntegerOrDefault(
"DECO_AI_GATEWAY_SIGNUP_GRANT_CENTS",
envVars.DECO_AI_GATEWAY_SIGNUP_GRANT_CENTS,
2300,
100_000,
),
stripeWebhookSecret: envVars.STRIPE_WEBHOOK_SECRET,
stripeSecretKey: envVars.STRIPE_SECRET_KEY,
stripeOrgPriceId: envVars.STRIPE_ORG_PRICE_ID,
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/settings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ export interface Settings {
/** Bearer for the gateway's /api/admin/* (top-up credits). Absent → the
* top-up tool falls back to the gateway's own checkout. */
aiGatewayAdminToken: string | undefined;
/** One-time AI credit granted to every new org on signup, in cents (default
* 2300, which nets ~$25 with the gateway's own $2 provision credit). A
* per-org override may be passed at creation (see readInitialCreditCents).
* 0 disables the grant. Only applied when the gateway admin is configured
* (hosted deployments); self-hosted can't reach the admin API and skip it. */
signupGrantCents: number;

// Stripe (per-org subscription + AI-credit top-ups). Absent → webhook
// 503s, no checkout.
Expand Down
21 changes: 17 additions & 4 deletions apps/api/src/tools/organization/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { z } from "zod";
import { defineTool } from "../../core/define-tool";
import { getUserId, requireAuth } from "../../core/studio-context";
import { isReservedOrganizationSlug } from "@decocms/shared/organization-slugs";
import { MAX_SIGNUP_GRANT_CENTS } from "../../billing/gateway-admin";

export const ORGANIZATION_CREATE = defineTool({
name: "ORGANIZATION_CREATE" as const,
Expand All @@ -32,6 +33,15 @@ export const ORGANIZATION_CREATE = defineTool({
),
name: z.string().min(1).max(255),
description: z.string().optional(),
initialCreditCents: z
.number()
.int()
.min(0)
.max(MAX_SIGNUP_GRANT_CENTS)
.optional()
.describe(
"Initial Deco AI Gateway credit to grant this org, in cents (e.g. 2500 = $25). Overrides the deployment default; omit to use it. 0 grants nothing.",
),
}),

outputSchema: z.object({
Expand All @@ -57,13 +67,16 @@ export const ORGANIZATION_CREATE = defineTool({
throw new Error("User ID required to create organization");
}

// Create organization via bound auth client
// initialCreditCents rides in metadata for the afterCreate seed hook.
const metadata: Record<string, unknown> = {};
if (input.description) metadata.description = input.description;
if (input.initialCreditCents !== undefined) {
metadata.initialCreditCents = input.initialCreditCents;
}
const result = await ctx.boundAuth.organization.create({
name: input.name,
slug: input.slug,
metadata: input.description
? { description: input.description }
: undefined,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
userId, // Server-side creation
});

Expand Down
7 changes: 6 additions & 1 deletion packages/shared/src/tools/tool-io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
*/
export interface StudioToolIO {
ORGANIZATION_CREATE: {
input: { slug: string; name: string; description?: string | undefined };
input: {
slug: string;
name: string;
description?: string | undefined;
initialCreditCents?: number | undefined;
};
output: {
id: string;
name: string;
Expand Down
Loading