Skip to content
Draft
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: 5 additions & 0 deletions .changeset/design-system-tier-limit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": minor
---

Add `fetchBuilderDesignSystemTierLimit`, `designSystemTierUpgradeUrl`, and the `@agent-native/core/client/design-system-tier-limit` helpers so apps can show a design-system plan/tier cap and an upgrade link before create, and surface the same information from a 402 on the create/index call.
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@
"./client/collab": "./dist/client/collab/index.js",
"./client/composer": "./dist/client/composer/index.js",
"./client/conversation": "./dist/client/conversation/index.js",
"./client/design-system-tier-limit": "./dist/client/design-system-tier-limit.js",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 public core export has no changeset

This adds a new public @agent-native/core/client/design-system-tier-limit export, but the PR contains no .changeset/*.md. Since packages/core is publishable, the required changeset check will fail and the export will not receive a versioned release.

Additional Info
Reported by 1/3 agents; repository guidance requires changesets for publishable core changes.

Fix in Builder

"./client/dev-overlay": "./dist/client/dev-overlay/index.js",
"./client/editor": "./dist/client/tombstone/editor.js",
"./client/rich-markdown-editor": "./dist/client/tombstone/rich-markdown-editor.js",
Expand Down
105 changes: 105 additions & 0 deletions packages/core/src/client/design-system-tier-limit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, expect, it } from "vitest";

import {
DESIGN_SYSTEM_TIER_LIMIT_ERROR_CODE,
isDesignSystemCodeIndexingAllowed,
isDesignSystemTierAtMax,
readDesignSystemTierLimitFailure,
type DesignSystemTierLimit,
} from "./design-system-tier-limit.js";

function tierLimit(
overrides: Partial<DesignSystemTierLimit> = {},
): DesignSystemTierLimit {
return {
status: "ok",
plan: "free",
current: 0,
max: 1,
atMax: false,
codeIndexingAllowed: false,
upgradeUrl: null,
...overrides,
};
}

describe("isDesignSystemTierAtMax", () => {
it("is false while the lookup is unresolved or unavailable", () => {
expect(isDesignSystemTierAtMax(undefined)).toBe(false);
expect(isDesignSystemTierAtMax(tierLimit({ status: "unavailable" }))).toBe(
false,
);
});

it("reflects atMax only once the lookup resolves", () => {
expect(isDesignSystemTierAtMax(tierLimit({ atMax: true }))).toBe(true);
expect(isDesignSystemTierAtMax(tierLimit({ atMax: false }))).toBe(false);
});
});

describe("isDesignSystemCodeIndexingAllowed", () => {
it("fails closed while the lookup is unresolved or unavailable, even if a stale value says allowed", () => {
expect(isDesignSystemCodeIndexingAllowed(undefined)).toBe(false);
expect(
isDesignSystemCodeIndexingAllowed(
tierLimit({ status: "unavailable", codeIndexingAllowed: true }),
),
).toBe(false);
});

it("allows code indexing only once the plan is confirmed to permit it", () => {
expect(
isDesignSystemCodeIndexingAllowed(
tierLimit({ plan: "enterprise", codeIndexingAllowed: true }),
),
).toBe(true);
expect(
isDesignSystemCodeIndexingAllowed(
tierLimit({ plan: "pro", codeIndexingAllowed: false }),
),
).toBe(false);
});
});

describe("readDesignSystemTierLimitFailure", () => {
it("returns null for errors that are not the tier-limit contract error", () => {
expect(
readDesignSystemTierLimitFailure(new Error("boom"), "fallback"),
).toBeNull();
expect(readDesignSystemTierLimitFailure(null, "fallback")).toBeNull();
});

it("recovers plan/current/max/upgradeUrl from the error details", () => {
const error = Object.assign(
new Error("You have reached your design-system limit"),
{
errorCode: DESIGN_SYSTEM_TIER_LIMIT_ERROR_CODE,
details: {
plan: "pro",
current: 3,
max: 3,
upgradeUrl: "https://builder.io/account/subscription",
},
},
);

expect(readDesignSystemTierLimitFailure(error, "fallback")).toEqual({
message: "You have reached your design-system limit",
plan: "pro",
current: 3,
max: 3,
upgradeUrl: "https://builder.io/account/subscription",
});
});

it("falls back to the provided message when the error carries no message", () => {
const error = {
errorCode: DESIGN_SYSTEM_TIER_LIMIT_ERROR_CODE,
details: {},
};

expect(readDesignSystemTierLimitFailure(error, "fallback")).toMatchObject(
{ message: "fallback", plan: null, current: null, max: null },
);
});
});
89 changes: 89 additions & 0 deletions packages/core/src/client/design-system-tier-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* DSI tier-restriction contract shared by every design-system creation
* surface (Design, Slides). Both the proactive `get-design-system-tier-limit`
* action read and the reactive 402 from `index-design-system-with-builder` /
* `create-design-system` carry the same shape -- see
* `fetchBuilderDesignSystemTierLimit` and `assertBuilderDesignSystemIndexOk`
* in `@agent-native/core/server`. Kept in one place so a UI never has to
* re-derive "which plans allow code indexing" from a plan string.
*/

import { actionErrorMessage } from "./use-action.js";

export const DESIGN_SYSTEM_TIER_LIMIT_ERROR_CODE =
"design_system_tier_limit_exceeded";

/** Response shape of the `get-design-system-tier-limit` action. */
export interface DesignSystemTierLimit {
status: "ok" | "unavailable";
plan: string | null;
current: number | null;
max: number | null;
atMax: boolean;
codeIndexingAllowed: boolean;
upgradeUrl: string | null;
}

export interface DesignSystemTierLimitFailure {
message: string;
plan: string | null;
current: number | null;
max: number | null;
upgradeUrl: string | null;
}

/**
* Read a 402 design-system tier-limit failure off a thrown action error, or
* `null` when the error is something else. `errorCode`/`details` are the only
* fields the action transport preserves from `fail()` -- see
* `readFigmaImportFailure` for the same pattern applied to Figma import.
*/
export function readDesignSystemTierLimitFailure(
error: unknown,
fallbackMessage: string,
): DesignSystemTierLimitFailure | null {
const source = error as
| { errorCode?: unknown; details?: Record<string, unknown> }
| undefined;
if (source?.errorCode !== DESIGN_SYSTEM_TIER_LIMIT_ERROR_CODE) return null;

const details = source.details ?? {};
const text = (value: unknown) =>
typeof value === "string" && value ? value : null;
const num = (value: unknown) => (typeof value === "number" ? value : null);

return {
message:
actionErrorMessage(error) ??
(error instanceof Error ? error.message : undefined) ??
fallbackMessage,
plan: text(details.plan),
current: num(details.current),
max: num(details.max),
upgradeUrl: text(details.upgradeUrl),
};
}

/** True once `current` has reached `max` (unlimited plans never report true). */
export function isDesignSystemTierAtMax(
limit: Pick<DesignSystemTierLimit, "status" | "atMax"> | null | undefined,
): boolean {
return limit?.status === "ok" && limit.atMax === true;
}

/**
* True only once the plan is confirmed to allow code/GitHub indexing.
* Unlike {@link isDesignSystemTierAtMax}, an unresolved or `"unavailable"`
* lookup must read as `false`: nothing re-checks this Enterprise-only
* entitlement server-side at create time, so an unknown answer has to block
* the UI rather than let a non-Enterprise plan through while the tier-limit
* endpoint is loading or down.
*/
export function isDesignSystemCodeIndexingAllowed(
limit:
| Pick<DesignSystemTierLimit, "status" | "codeIndexingAllowed">
| null
| undefined,
): boolean {
return limit?.status === "ok" && limit.codeIndexingAllowed === true;
}
158 changes: 158 additions & 0 deletions packages/core/src/server/builder-design-systems.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
collectBuilderDesignSystemGitHubFiles,
createBuilderDesignSystemProxyFields,
fetchBuilderDesignSystemDocs,
fetchBuilderDesignSystemTierLimit,
hydrateBuilderDesignSystemReference,
indexBuilderDesignSystem,
localBuilderDesignSystemId,
Expand Down Expand Up @@ -903,6 +904,72 @@ describe("Builder design-system helpers", () => {
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it("surfaces a 402 design-system tier limit as a structured, actionable failure", async () => {
process.env.BUILDER_PRIVATE_KEY = "builder-private";
process.env.BUILDER_PUBLIC_KEY = "builder-public";
process.env.BUILDER_DESIGN_SYSTEMS_BASE_URL =
"https://builder.example.test/design-systems/v1";
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
error: {
message: "Design system limit reached for this plan",
plan: "Pro",
current: 3,
max: 3,
upgradeUrl: "https://builder.io/account/subscription?plan=pro",
},
}),
{ status: 402 },
),
);
vi.stubGlobal("fetch", fetchMock);

await expect(
indexBuilderDesignSystem({
sources: [{ kind: "file", uploadToken: "upload-token" }],
}),
).rejects.toMatchObject({
actionContractError: true,
errorCode: "design_system_tier_limit_exceeded",
statusCode: 402,
details: {
plan: "pro",
current: 3,
max: 3,
upgradeUrl: "https://builder.io/account/subscription?plan=pro",
},
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("falls back to a default upgrade link when the 402 body carries no upgradeUrl", async () => {
process.env.BUILDER_PRIVATE_KEY = "builder-private";
process.env.BUILDER_PUBLIC_KEY = "builder-public";
process.env.BUILDER_DESIGN_SYSTEMS_BASE_URL =
"https://builder.example.test/design-systems/v1";
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ plan: "free", current: 1, max: 1 }), {
status: 402,
}),
);
vi.stubGlobal("fetch", fetchMock);

const rejection = await indexBuilderDesignSystem({
sources: [{ kind: "file", uploadToken: "upload-token" }],
}).catch((error) => error);

expect(rejection).toMatchObject({
errorCode: "design_system_tier_limit_exceeded",
statusCode: 402,
details: { plan: "free", current: 1, max: 1 },
});
expect(
typeof rejection.details.upgradeUrl === "string" &&
rejection.details.upgradeUrl.includes("builder.io"),
).toBe(true);
});

it("keeps an unscoped public repository as a native Builder source", async () => {
delete process.env.GITHUB_TOKEN;
process.env.BUILDER_PRIVATE_KEY = "builder-private";
Expand Down Expand Up @@ -956,4 +1023,95 @@ describe("Builder design-system helpers", () => {
"builder-ds-Brand-Kit-2026",
);
});

describe("fetchBuilderDesignSystemTierLimit", () => {
it("reads plan, current count, and max from the tier-limit endpoint", async () => {
process.env.BUILDER_PRIVATE_KEY = "builder-private";
process.env.BUILDER_PUBLIC_KEY = "builder-public";
process.env.BUILDER_DESIGN_SYSTEMS_BASE_URL =
"https://builder.example.test/design-systems/v1";
const fetchMock = vi.fn(async (input: string | URL) => {
expect(String(input)).toBe(
"https://builder.example.test/design-systems/v1/tier-limit?apiKey=builder-public",
);
return new Response(
JSON.stringify({ plan: "Team", current: 3, max: 3 }),
{ status: 200 },
);
});
vi.stubGlobal("fetch", fetchMock);

await expect(fetchBuilderDesignSystemTierLimit()).resolves.toEqual({
status: "ok",
plan: "team",
current: 3,
max: 3,
atMax: true,
codeIndexingAllowed: false,
upgradeUrl: expect.stringContaining("builder.io"),
});
});

it("treats a null max as unlimited and allows code indexing on Enterprise", async () => {
process.env.BUILDER_PRIVATE_KEY = "builder-private";
process.env.BUILDER_PUBLIC_KEY = "builder-public";
process.env.BUILDER_DESIGN_SYSTEMS_BASE_URL =
"https://builder.example.test/design-systems/v1";
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(
JSON.stringify({ plan: "enterprise", current: 42, max: null }),
{ status: 200 },
),
);
vi.stubGlobal("fetch", fetchMock);

const limit = await fetchBuilderDesignSystemTierLimit();
expect(limit.plan).toBe("enterprise");
expect(limit.max).toBeNull();
expect(limit.atMax).toBe(false);
expect(limit.codeIndexingAllowed).toBe(true);
});

it("fails open on the count cap but closed on code indexing when the tier-limit endpoint is unreachable", async () => {
process.env.BUILDER_PRIVATE_KEY = "builder-private";
process.env.BUILDER_PUBLIC_KEY = "builder-public";
process.env.BUILDER_DESIGN_SYSTEMS_BASE_URL =
"https://builder.example.test/design-systems/v1";
const fetchMock = vi.fn().mockRejectedValue(new Error("network down"));
vi.stubGlobal("fetch", fetchMock);

await expect(fetchBuilderDesignSystemTierLimit()).resolves.toEqual({
status: "unavailable",
plan: null,
current: null,
max: null,
atMax: false,
codeIndexingAllowed: false,
upgradeUrl: null,
});
});

it("fails open on the count cap but closed on code indexing when the tier-limit endpoint responds with an error status", async () => {
process.env.BUILDER_PRIVATE_KEY = "builder-private";
process.env.BUILDER_PUBLIC_KEY = "builder-public";
process.env.BUILDER_DESIGN_SYSTEMS_BASE_URL =
"https://builder.example.test/design-systems/v1";
const fetchMock = vi
.fn()
.mockResolvedValue(new Response("Internal error", { status: 500 }));
vi.stubGlobal("fetch", fetchMock);

await expect(fetchBuilderDesignSystemTierLimit()).resolves.toEqual({
status: "unavailable",
plan: null,
current: null,
max: null,
atMax: false,
codeIndexingAllowed: false,
upgradeUrl: null,
});
});
});
});
Loading
Loading