Skip to content

Commit e3072a3

Browse files
authored
feat(project): port complete project schema (#1942)
1 parent d58ff0b commit e3072a3

37 files changed

Lines changed: 4380 additions & 77 deletions

src/core/project/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
export { FsProjectManager } from "./manager";
2+
export { ProjectNameSchema, ProjectSpecSchema } from "./schema";
3+
export type { ProjectRuntime, ProjectSpec } from "./schema";

src/core/project/schema.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export {
2+
AgentCoreProjectSpecSchema as ProjectSpecSchema,
3+
ProjectNameSchema,
4+
} from "./schema/project";
5+
export type { AgentCoreProjectSpec as ProjectSpec } from "./schema/project";
6+
export type { AgentEnvSpec as ProjectRuntime } from "./schema/runtime";
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, expect, it } from "bun:test";
2+
import { ABTestSchema } from "./ab-test";
3+
const configurationBundle = { bundleArn: "arn:bundle", bundleVersion: "1" };
4+
const base = {
5+
name: "Experiment",
6+
gatewayRef: "{{gateway:main}}",
7+
variants: [
8+
{ name: "C", weight: 50, variantConfiguration: { configurationBundle } },
9+
{ name: "T1", weight: 50, variantConfiguration: { configurationBundle } },
10+
],
11+
evaluationConfig: { onlineEvaluationConfigArn: "arn:evaluation" },
12+
};
13+
describe("ABTestSchema custom validation", () => {
14+
it("requires one control and one treatment variant", () => {
15+
const result = ABTestSchema.safeParse({
16+
...base,
17+
variants: base.variants.map((variant) => ({ ...variant, name: "C" })),
18+
});
19+
expect(result.success).toBe(false);
20+
});
21+
it("requires variant weights to sum to 100", () => {
22+
const result = ABTestSchema.safeParse({
23+
...base,
24+
variants: [
25+
{ ...base.variants[0], weight: 60 },
26+
{ ...base.variants[1], weight: 60 },
27+
],
28+
});
29+
expect(result.success).toBe(false);
30+
});
31+
it("binds variant configuration to the selected mode", () => {
32+
const targetBased = ABTestSchema.safeParse({
33+
...base,
34+
mode: "target-based",
35+
variants: [
36+
{ name: "C", weight: 50, variantConfiguration: { target: { targetName: "control" } } },
37+
{ name: "T1", weight: 50, variantConfiguration: { target: { targetName: "treatment" } } },
38+
],
39+
});
40+
expect(targetBased.success).toBe(true);
41+
expect(ABTestSchema.safeParse({ ...base, mode: "target-based" }).success).toBe(false);
42+
});
43+
});

src/core/project/schema/ab-test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { z } from "zod";
2+
export const ABTestNameSchema = z
3+
.string()
4+
.min(1, "Name is required")
5+
.max(48)
6+
.regex(
7+
/^[a-zA-Z][a-zA-Z0-9_]{0,47}$/,
8+
"Must begin with a letter and contain only alphanumeric characters and underscores (max 48 chars)",
9+
);
10+
export const ABTestDescriptionSchema = z.string().min(1).max(200).optional();
11+
export const ABTestModeSchema = z
12+
.enum(["config-bundle", "target-based"])
13+
.optional()
14+
.default("config-bundle");
15+
export type ABTestMode = z.infer<typeof ABTestModeSchema>;
16+
export const VariantNameSchema = z.enum(["C", "T1"]);
17+
export const VariantWeightSchema = z.number().int().min(1).max(100);
18+
export const ConfigurationBundleRefSchema = z.object({
19+
bundleArn: z.string().min(1),
20+
bundleVersion: z.string().min(1),
21+
});
22+
export type ConfigurationBundleRef = z.infer<typeof ConfigurationBundleRefSchema>;
23+
export const TargetRefSchema = z.object({
24+
targetName: z.string().min(1).max(100),
25+
});
26+
export type TargetRef = z.infer<typeof TargetRefSchema>;
27+
const ConfigBundleVariantConfigSchema = z.object({
28+
configurationBundle: ConfigurationBundleRefSchema,
29+
target: z.never().optional(),
30+
});
31+
const TargetVariantConfigSchema = z.object({
32+
configurationBundle: z.never().optional(),
33+
target: TargetRefSchema,
34+
});
35+
export const VariantConfigurationSchema = z.union([
36+
ConfigBundleVariantConfigSchema,
37+
TargetVariantConfigSchema,
38+
]);
39+
export type VariantConfiguration = z.infer<typeof VariantConfigurationSchema>;
40+
export const ABTestVariantSchema = z.object({
41+
name: VariantNameSchema,
42+
weight: VariantWeightSchema,
43+
variantConfiguration: VariantConfigurationSchema,
44+
});
45+
export type ABTestVariant = z.infer<typeof ABTestVariantSchema>;
46+
export const PerVariantOnlineEvaluationConfigSchema = z.object({
47+
treatmentName: VariantNameSchema,
48+
onlineEvaluationConfigArn: z.string().min(1),
49+
});
50+
export type PerVariantOnlineEvaluationConfig = z.infer<
51+
typeof PerVariantOnlineEvaluationConfigSchema
52+
>;
53+
export const ABTestEvaluationConfigSchema = z.union([
54+
z.object({ onlineEvaluationConfigArn: z.string().min(1) }),
55+
z.object({
56+
perVariantOnlineEvaluationConfig: z.array(PerVariantOnlineEvaluationConfigSchema).length(2),
57+
}),
58+
]);
59+
export type ABTestEvaluationConfig = z.infer<typeof ABTestEvaluationConfigSchema>;
60+
export const GatewayFilterSchema = z.object({
61+
targetPaths: z.array(z.string().min(1).max(500)).max(1),
62+
});
63+
export type GatewayFilter = z.infer<typeof GatewayFilterSchema>;
64+
export const TrafficRouteOnHeaderSchema = z.object({
65+
headerName: z.string().min(1),
66+
});
67+
export const TrafficAllocationConfigSchema = z.object({
68+
routeOnHeader: TrafficRouteOnHeaderSchema,
69+
});
70+
export type TrafficAllocationConfig = z.infer<typeof TrafficAllocationConfigSchema>;
71+
export const ABTestSchema = z
72+
.object({
73+
name: ABTestNameSchema,
74+
description: ABTestDescriptionSchema,
75+
mode: ABTestModeSchema,
76+
gatewayRef: z.string().min(1),
77+
roleArn: z.string().min(1).optional(),
78+
variants: z.array(ABTestVariantSchema).length(2),
79+
evaluationConfig: ABTestEvaluationConfigSchema,
80+
gatewayFilter: GatewayFilterSchema.optional(),
81+
enableOnCreate: z.boolean().optional(),
82+
promoted: z.boolean().optional(),
83+
})
84+
.refine(
85+
(data) => {
86+
const names = data.variants.map((v) => v.name);
87+
return names.includes("C") && names.includes("T1");
88+
},
89+
{
90+
message: "Variants must include exactly one control (C) and one treatment (T1)",
91+
path: ["variants"],
92+
},
93+
)
94+
.refine((data) => data.variants.reduce((sum, v) => sum + v.weight, 0) === 100, {
95+
message: "Variant weights must sum to 100",
96+
path: ["variants"],
97+
})
98+
.refine(
99+
(data) => {
100+
if (data.mode === "target-based") {
101+
return data.variants.every((v) => v.variantConfiguration.target != null);
102+
}
103+
return data.variants.every((v) => v.variantConfiguration.configurationBundle != null);
104+
},
105+
{
106+
message:
107+
"Target-based mode requires target on each variant; config-bundle mode requires configurationBundle",
108+
path: ["variants"],
109+
},
110+
);
111+
export type ABTest = z.infer<typeof ABTestSchema>;
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, expect, it } from "bun:test";
2+
import {
3+
ClaimMatchValueSchema,
4+
CustomClaimValidationSchema,
5+
CustomJwtAuthorizerConfigSchema,
6+
PrivateEndpointSchema,
7+
} from "./auth";
8+
const lattice = {
9+
selfManagedLatticeResource: {
10+
resourceConfigurationIdentifier: "rcfg-0123456789abcdef0",
11+
},
12+
};
13+
const vpc = {
14+
managedVpcResource: {
15+
vpcIdentifier: "vpc-0123456789abcdef0",
16+
subnetIds: ["subnet-0123456789abcdef0"],
17+
endpointIpAddressType: "IPV4" as const,
18+
},
19+
};
20+
describe("auth custom validation", () => {
21+
it("requires exactly one claim match value representation", () => {
22+
expect(ClaimMatchValueSchema.safeParse({ matchValueString: "admin" }).success).toBe(true);
23+
expect(
24+
ClaimMatchValueSchema.safeParse({
25+
matchValueString: "admin",
26+
matchValueStringList: ["admin"],
27+
}).success,
28+
).toBe(false);
29+
expect(ClaimMatchValueSchema.safeParse({}).success).toBe(false);
30+
});
31+
it("rejects reserved custom claim names", () => {
32+
expect(
33+
CustomClaimValidationSchema.safeParse({
34+
inboundTokenClaimName: "client_id",
35+
inboundTokenClaimValueType: "STRING",
36+
authorizingClaimMatchValue: {
37+
claimMatchOperator: "EQUALS",
38+
claimMatchValue: { matchValueString: "user" },
39+
},
40+
}).success,
41+
).toBe(false);
42+
});
43+
it("requires exactly one private endpoint arm", () => {
44+
expect(PrivateEndpointSchema.safeParse(lattice).success).toBe(true);
45+
expect(PrivateEndpointSchema.safeParse(vpc).success).toBe(true);
46+
expect(PrivateEndpointSchema.safeParse({ ...lattice, ...vpc }).success).toBe(false);
47+
expect(PrivateEndpointSchema.safeParse({}).success).toBe(false);
48+
});
49+
it("requires override arms to match the base endpoint and domains to be unique", () => {
50+
const base = {
51+
discoveryUrl: "https://example.com/.well-known/openid-configuration",
52+
allowedAudience: ["audience"],
53+
privateEndpoint: lattice,
54+
};
55+
expect(
56+
CustomJwtAuthorizerConfigSchema.safeParse({
57+
...base,
58+
privateEndpointOverrides: [{ domain: "example.com", privateEndpoint: vpc }],
59+
}).success,
60+
).toBe(false);
61+
expect(
62+
CustomJwtAuthorizerConfigSchema.safeParse({
63+
...base,
64+
privateEndpointOverrides: [
65+
{ domain: "example.com", privateEndpoint: lattice },
66+
{ domain: "example.com", privateEndpoint: lattice },
67+
],
68+
}).success,
69+
).toBe(false);
70+
});
71+
it("does not allow endpoint overrides without a base endpoint", () => {
72+
const result = CustomJwtAuthorizerConfigSchema.safeParse({
73+
discoveryUrl: "https://example.com/.well-known/openid-configuration",
74+
allowedAudience: ["audience"],
75+
privateEndpointOverrides: [{ domain: "example.com", privateEndpoint: lattice }],
76+
});
77+
expect(result.success).toBe(false);
78+
});
79+
});

0 commit comments

Comments
 (0)